1use serde::Serialize;
17use serde_json::{json, Value};
18
19#[derive(Debug, thiserror::Error)]
23pub enum ActionError {
24 #[error(transparent)]
26 Validation(#[from] ActionValidationError),
27}
28
29impl ActionError {
30 pub fn as_validation(&self) -> Option<&ActionValidationError> {
32 match self {
33 Self::Validation(error) => Some(error),
34 }
35 }
36}
37
38#[derive(Debug, thiserror::Error)]
40pub enum ActionValidationError {
41 #[error("action is required")]
43 MissingAction,
44 #[error("`{field}` is required and must not be empty")]
46 MissingField {
47 field: String,
49 },
50 #[error("`{field}` must be a string")]
52 WrongType {
53 field: String,
55 },
56 #[error(
58 "action={action} is not available over REST; use MCP or action=help for documentation"
59 )]
60 NotAvailableOverRest {
61 action: String,
63 },
64 #[error("unknown soma action: {action}; use action=help for documentation")]
66 UnknownAction {
67 action: String,
69 },
70}
71
72pub type ValidationError = ActionValidationError;
74
75impl ActionValidationError {
76 pub fn code(&self) -> &'static str {
78 match self {
79 Self::MissingAction => "missing_action",
80 Self::MissingField { .. } => "missing_field",
81 Self::WrongType { .. } => "wrong_type",
82 Self::NotAvailableOverRest { .. } => "not_available_over_rest",
83 Self::UnknownAction { .. } => "unknown_action",
84 }
85 }
86
87 pub fn field(&self) -> Option<&str> {
89 match self {
90 Self::MissingAction => Some("action"),
91 Self::MissingField { field } | Self::WrongType { field } => Some(field.as_str()),
92 Self::NotAvailableOverRest { .. } | Self::UnknownAction { .. } => Some("action"),
93 }
94 }
95
96 pub fn bad_value(&self) -> Option<&str> {
98 match self {
99 Self::NotAvailableOverRest { action } | Self::UnknownAction { action } => {
100 Some(action.as_str())
101 }
102 Self::MissingAction | Self::MissingField { .. } | Self::WrongType { .. } => None,
103 }
104 }
105
106 pub fn remediation(&self) -> String {
108 match self {
109 Self::MissingAction => {
110 format!(
111 "Set `action` to one of: {}. Use action=help for the full schema.",
112 action_names().join(", ")
113 )
114 }
115 Self::MissingField { field } => {
116 format!("Provide a non-empty `{field}` value, or use action=help for examples.")
117 }
118 Self::WrongType { field } => {
119 format!("Pass `{field}` as a JSON string, or use action=help for examples.")
120 }
121 Self::NotAvailableOverRest { action } => {
122 format!("Call action={action} through MCP, or call action=help over REST.")
123 }
124 Self::UnknownAction { .. } => {
125 format!(
126 "Retry with one of the supported actions: {}. Use action=help for examples.",
127 action_names().join(", ")
128 )
129 }
130 }
131 }
132}
133
134pub use crate::scopes::{scopes_satisfy, DENY_SCOPE, READ_SCOPE, WRITE_SCOPE};
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum ActionTransport {
144 Any,
146 McpOnly,
148}
149
150impl ActionTransport {
151 pub fn mcp(self) -> bool {
153 matches!(self, Self::Any | Self::McpOnly)
154 }
155
156 pub fn cli(self) -> bool {
158 matches!(self, Self::Any)
159 }
160
161 pub fn rest(self) -> bool {
163 matches!(self, Self::Any)
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum ActionCost {
170 Cheap,
172 Moderate,
174 Expensive,
176 Write,
178}
179
180impl ActionCost {
181 pub fn as_str(self) -> &'static str {
183 match self {
184 Self::Cheap => "cheap",
185 Self::Moderate => "moderate",
186 Self::Expensive => "expensive",
187 Self::Write => "write",
188 }
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub struct ParamSpec {
195 pub name: &'static str,
197 pub ty: &'static str,
199 pub required: bool,
201 pub description: &'static str,
203 pub max_len: Option<usize>,
205 pub enum_values: &'static [&'static str],
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub struct CliFlagSpec {
212 pub name: &'static str,
214 pub value_name: Option<&'static str>,
216 pub required: bool,
218 pub description: &'static str,
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub struct CliSpec {
225 pub command: &'static str,
227 pub usage: &'static str,
229 pub flags: &'static [CliFlagSpec],
231 pub description: &'static str,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct ActionSpec {
238 pub name: &'static str,
240 pub description: &'static str,
242 pub required_scope: Option<&'static str>,
244 pub transport: ActionTransport,
246 pub rest_method: Option<&'static str>,
248 pub rest_path: Option<&'static str>,
250 pub destructive: bool,
252 pub requires_admin: bool,
254 pub cost: ActionCost,
256 pub params: &'static [ParamSpec],
258 pub returns: &'static str,
260 pub cli: Option<CliSpec>,
262}
263
264const GREET_PARAMS: &[ParamSpec] = &[ParamSpec {
265 name: "name",
266 ty: "string",
267 required: false,
268 description: "Name to greet. Omit to greet the world.",
269 max_len: Some(4096),
270 enum_values: &[],
271}];
272
273const ECHO_PARAMS: &[ParamSpec] = &[ParamSpec {
274 name: "message",
275 ty: "string",
276 required: true,
277 description: "Message to echo back. Must not be empty.",
278 max_len: Some(4096),
279 enum_values: &[],
280}];
281
282const GREET_CLI_FLAGS: &[CliFlagSpec] = &[CliFlagSpec {
283 name: "--name",
284 value_name: Some("NAME"),
285 required: false,
286 description: "Name to greet. Omit to greet the world.",
287}];
288
289const ECHO_CLI_FLAGS: &[CliFlagSpec] = &[CliFlagSpec {
290 name: "--message",
291 value_name: Some("MSG"),
292 required: true,
293 description: "Message to echo back. Must not be empty.",
294}];
295
296pub const ACTION_SPECS: &[ActionSpec] = &[
301 ActionSpec {
302 name: "greet",
303 description: "Return a greeting.",
304 required_scope: Some(READ_SCOPE),
305 transport: ActionTransport::Any,
306 rest_method: Some("POST"),
307 rest_path: Some("/v1/greet"),
308 destructive: false,
309 requires_admin: false,
310 cost: ActionCost::Cheap,
311 params: GREET_PARAMS,
312 returns: "Greeting",
313 cli: Some(CliSpec {
314 command: "greet",
315 usage: "soma greet [--name NAME]",
316 flags: GREET_CLI_FLAGS,
317 description: "Greet NAME, or the world when omitted.",
318 }),
319 },
320 ActionSpec {
321 name: "echo",
322 description: "Echo a message back unchanged.",
323 required_scope: Some(READ_SCOPE),
324 transport: ActionTransport::Any,
325 rest_method: Some("POST"),
326 rest_path: Some("/v1/echo"),
327 destructive: false,
328 requires_admin: false,
329 cost: ActionCost::Cheap,
330 params: ECHO_PARAMS,
331 returns: "EchoResult",
332 cli: Some(CliSpec {
333 command: "echo",
334 usage: "soma echo --message MSG",
335 flags: ECHO_CLI_FLAGS,
336 description: "Echo MSG back unchanged.",
337 }),
338 },
339 ActionSpec {
340 name: "status",
341 description: "Return server status and configuration info.",
342 required_scope: Some(READ_SCOPE),
343 transport: ActionTransport::Any,
344 rest_method: Some("GET"),
345 rest_path: Some("/v1/status"),
346 destructive: false,
347 requires_admin: false,
348 cost: ActionCost::Cheap,
349 params: &[],
350 returns: "Status",
351 cli: Some(CliSpec {
352 command: "status",
353 usage: "soma status",
354 flags: &[],
355 description: "Show service status.",
356 }),
357 },
358 ActionSpec {
359 name: "elicit_name",
360 description: "Ask the MCP client to collect a name, then return a personalised greeting.",
361 required_scope: Some(READ_SCOPE),
362 transport: ActionTransport::McpOnly,
363 rest_method: None,
364 rest_path: None,
365 destructive: false,
366 requires_admin: false,
367 cost: ActionCost::Cheap,
368 params: &[],
369 returns: "Greeting",
370 cli: None,
371 },
372 ActionSpec {
373 name: "scaffold_intent",
374 description: "Collect scaffold setup intent through MCP elicitation and return JSON for the scaffold-project skill.",
375 required_scope: Some(READ_SCOPE),
376 transport: ActionTransport::McpOnly,
377 rest_method: None,
378 rest_path: None,
379 destructive: false,
380 requires_admin: false,
381 cost: ActionCost::Moderate,
382 params: &[],
383 returns: "ScaffoldIntentReport",
384 cli: None,
385 },
386 ActionSpec {
387 name: "help",
388 description: "Show the action reference.",
389 required_scope: None,
390 transport: ActionTransport::Any,
391 rest_method: Some("GET"),
392 rest_path: Some("/v1/help"),
393 destructive: false,
394 requires_admin: false,
395 cost: ActionCost::Cheap,
396 params: &[],
397 returns: "HelpPayload",
398 cli: Some(CliSpec {
399 command: "help",
400 usage: "soma help",
401 flags: &[],
402 description: "Show JSON action reference.",
403 }),
404 },
405];
406
407pub fn action_names() -> Vec<&'static str> {
409 ACTION_SPECS.iter().map(|spec| spec.name).collect()
410}
411
412pub fn is_known_action(action: &str) -> bool {
414 ACTION_SPECS.iter().any(|spec| spec.name == action)
415}
416
417pub fn rest_action_names() -> Vec<&'static str> {
419 ACTION_SPECS
420 .iter()
421 .filter(|spec| spec.transport.rest())
422 .map(|spec| spec.name)
423 .collect()
424}
425
426pub fn cli_action_names() -> Vec<&'static str> {
428 ACTION_SPECS
429 .iter()
430 .filter(|spec| spec.cli.is_some())
431 .map(|spec| spec.name)
432 .collect()
433}
434
435pub fn cli_commands() -> Vec<&'static str> {
437 ACTION_SPECS
438 .iter()
439 .filter_map(|spec| spec.cli.map(|cli| cli.command))
440 .collect()
441}
442
443pub fn is_rest_action(action: &str) -> bool {
445 action_spec(action)
446 .map(|spec| spec.transport.rest())
447 .unwrap_or(false)
448}
449
450pub fn mcp_only_action_names() -> Vec<&'static str> {
452 ACTION_SPECS
453 .iter()
454 .filter(|spec| spec.transport == ActionTransport::McpOnly)
455 .map(|spec| spec.name)
456 .collect()
457}
458
459pub fn required_scope_for_action(action: &str) -> Option<&'static str> {
463 action_spec(action)
464 .map(|spec| spec.required_scope)
465 .unwrap_or(Some(DENY_SCOPE))
466}
467
468pub fn action_spec(action: &str) -> Option<&'static ActionSpec> {
470 ACTION_SPECS.iter().find(|spec| spec.name == action)
471}
472
473pub fn require_confirmation_if_destructive(
482 action: &str,
483 params: &Value,
484) -> Result<(), Box<crate::errors::ToolError>> {
485 let Some(spec) = action_spec(action) else {
486 return Ok(());
487 };
488 if !spec.destructive {
489 return Ok(());
490 }
491 let confirmed = params
492 .get("confirm")
493 .and_then(Value::as_bool)
494 .unwrap_or(false);
495 if confirmed {
496 return Ok(());
497 }
498 Err(Box::new(
500 crate::errors::ToolError::validation(
501 "confirmation_required",
502 format!("action '{action}' is destructive and requires \"confirm\": true"),
503 "Re-send the request with \"confirm\": true to proceed.",
504 )
505 .with_field("confirm"),
506 ))
507}
508
509#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
511pub struct SurfaceAvailability {
512 pub mcp: bool,
514 pub cli: bool,
516 pub rest: bool,
518 pub web_ui: bool,
520}
521
522#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
524pub struct ParamDoc {
525 pub name: String,
527 pub ty: String,
529 pub required: bool,
531 pub description: String,
533 pub max_len: Option<usize>,
535 pub enum_values: Vec<String>,
537}
538
539#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
541pub struct ActionDoc {
542 pub service: String,
544 pub action: String,
546 pub description: String,
548 pub destructive: bool,
550 pub requires_admin: bool,
552 pub cost: String,
554 pub required_scope: Option<String>,
556 pub params: Vec<ParamDoc>,
558 pub returns: String,
560 pub surface_availability: SurfaceAvailability,
562 pub auth_posture: String,
564 pub mcp_only_exception: Option<String>,
566 pub cli: Option<CliDoc>,
568}
569
570#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
572pub struct CliFlagDoc {
573 pub name: String,
575 pub value_name: Option<String>,
577 pub required: bool,
579 pub description: String,
581}
582
583#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
585pub struct CliDoc {
586 pub command: String,
588 pub usage: String,
590 pub description: String,
592 pub flags: Vec<CliFlagDoc>,
594}
595
596pub fn action_catalog() -> Vec<ActionDoc> {
598 ACTION_SPECS
599 .iter()
600 .map(|spec| ActionDoc {
601 service: "soma".to_owned(),
602 action: spec.name.to_owned(),
603 description: spec.description.to_owned(),
604 destructive: spec.destructive,
605 requires_admin: spec.requires_admin,
606 cost: spec.cost.as_str().to_owned(),
607 required_scope: spec.required_scope.map(ToOwned::to_owned),
608 params: spec
609 .params
610 .iter()
611 .map(|param| ParamDoc {
612 name: param.name.to_owned(),
613 ty: param.ty.to_owned(),
614 required: param.required,
615 description: param.description.to_owned(),
616 max_len: param.max_len,
617 enum_values: param
618 .enum_values
619 .iter()
620 .map(|value| (*value).to_owned())
621 .collect(),
622 })
623 .collect(),
624 returns: spec.returns.to_owned(),
625 surface_availability: SurfaceAvailability {
626 mcp: spec.transport.mcp(),
627 cli: spec.transport.cli(),
628 rest: spec.transport.rest(),
629 web_ui: false,
630 },
631 auth_posture: match spec.required_scope {
632 Some(scope) => format!("requires `{scope}` on authenticated transports"),
633 None => "public action; no action scope required".to_owned(),
634 },
635 mcp_only_exception: (spec.transport == ActionTransport::McpOnly)
636 .then(|| "MCP-only because it requires client-rendered elicitation.".to_owned()),
637 cli: spec.cli.map(|cli| CliDoc {
638 command: cli.command.to_owned(),
639 usage: cli.usage.to_owned(),
640 description: cli.description.to_owned(),
641 flags: cli
642 .flags
643 .iter()
644 .map(|flag| CliFlagDoc {
645 name: flag.name.to_owned(),
646 value_name: flag.value_name.map(ToOwned::to_owned),
647 required: flag.required,
648 description: flag.description.to_owned(),
649 })
650 .collect(),
651 }),
652 })
653 .collect()
654}
655
656#[derive(Debug, Clone, PartialEq, Eq)]
658pub enum SomaAction {
659 Greet {
661 name: Option<String>,
663 },
664 Echo {
666 message: String,
668 },
669 Status,
671 Help,
673 ElicitName,
675 ScaffoldIntent,
677}
678
679impl SomaAction {
680 pub fn name(&self) -> &'static str {
682 match self {
683 Self::Greet { .. } => "greet",
684 Self::Echo { .. } => "echo",
685 Self::Status => "status",
686 Self::Help => "help",
687 Self::ElicitName => "elicit_name",
688 Self::ScaffoldIntent => "scaffold_intent",
689 }
690 }
691
692 pub fn from_mcp_args(args: &Value) -> anyhow::Result<Self> {
694 let action = match args.get("action") {
695 None => return Err(action_error(ValidationError::MissingAction)),
696 Some(Value::String(action)) => action.as_str(),
697 Some(_) => {
698 return Err(action_error(ValidationError::WrongType {
699 field: "action".into(),
700 }));
701 }
702 };
703 Self::from_params(action, args)
704 }
705
706 pub fn from_rest(action: &str, params: &Value) -> anyhow::Result<Self> {
708 if action.is_empty() {
709 return Err(action_error(ValidationError::MissingAction));
710 }
711 if action_spec(action)
712 .map(|spec| spec.transport == ActionTransport::McpOnly)
713 .unwrap_or(false)
714 {
715 return Err(action_error(ValidationError::NotAvailableOverRest {
716 action: action.to_owned(),
717 }));
718 }
719 Self::from_params(action, params)
720 }
721
722 fn from_params(action: &str, params: &Value) -> anyhow::Result<Self> {
723 match action {
724 "greet" => Ok(Self::Greet {
725 name: optional_string_param(params, "name")?,
726 }),
727 "echo" => {
728 let message = optional_string_param(params, "message")?
729 .filter(|m| !m.is_empty())
730 .ok_or_else(|| {
731 action_error(ValidationError::MissingField {
732 field: "message".into(),
733 })
734 })?;
735 Ok(Self::Echo { message })
736 }
737 "status" => Ok(Self::Status),
738 "help" => Ok(Self::Help),
739 "elicit_name" => Ok(Self::ElicitName),
740 "scaffold_intent" => Ok(Self::ScaffoldIntent),
741 other => Err(action_error(ValidationError::UnknownAction {
742 action: other.to_owned(),
743 })),
744 }
745 }
746}
747
748pub fn rest_help() -> Value {
750 json!({
751 "actions": rest_action_names(),
752 "mcp_only_actions": mcp_only_action_names(),
753 "catalog": action_catalog(),
754 "preferred_rest_style": "direct_routes",
755 "usage": "Use direct REST routes such as POST /v1/echo or GET /v1/status. MCP keeps a single action-dispatched tool; REST does not expose an action envelope.",
756 "examples": {
757 "greet": {"method": "POST", "path": "/v1/greet", "body": {"name": "Alice"}},
758 "echo": {"method": "POST", "path": "/v1/echo", "body": {"message": "Hello!"}},
759 "status": {"method": "GET", "path": "/v1/status"},
760 }
761 })
762}
763
764fn optional_string_param(params: &Value, name: &str) -> Result<Option<String>, ValidationError> {
765 match params.get(name) {
766 None => Ok(None),
767 Some(value) => value
768 .as_str()
769 .map(|s| Some(s.to_owned()))
770 .ok_or_else(|| ValidationError::WrongType { field: name.into() }),
771 }
772}
773
774fn action_error(error: ValidationError) -> anyhow::Error {
775 error.into()
776}
777
778pub fn action_validation_error(error: &anyhow::Error) -> Option<&ActionValidationError> {
780 if let Some(error) = error.downcast_ref::<ActionError>() {
781 return error.as_validation();
782 }
783 error.downcast_ref::<ActionValidationError>()
784}
785
786pub fn is_validation_error(error: &anyhow::Error) -> bool {
788 action_validation_error(error).is_some()
789}
790
791#[cfg(test)]
806#[path = "actions_tests.rs"]
807mod tests;