1use serde::Serialize;
17use serde_json::Value;
18
19#[path = "actions_help.rs"]
20mod help;
21#[path = "actions_python.rs"]
22mod python;
23pub use help::rest_help;
24
25#[derive(Debug, thiserror::Error)]
29pub enum ActionError {
30 #[error(transparent)]
32 Validation(#[from] ActionValidationError),
33}
34
35impl ActionError {
36 pub fn as_validation(&self) -> Option<&ActionValidationError> {
38 match self {
39 Self::Validation(error) => Some(error),
40 }
41 }
42}
43
44#[derive(Debug, thiserror::Error)]
46pub enum ActionValidationError {
47 #[error("action is required")]
49 MissingAction,
50 #[error("`{field}` is required and must not be empty")]
52 MissingField {
53 field: String,
55 },
56 #[error("`{field}` must be a string")]
58 WrongType {
59 field: String,
61 },
62 #[error("action={action} is not available over REST; use MCP or action=help for documentation")]
64 NotAvailableOverRest {
65 action: String,
67 },
68 #[error("unknown soma action: {action}; use action=help for documentation")]
70 UnknownAction {
71 action: String,
73 },
74}
75
76pub type ValidationError = ActionValidationError;
78
79impl ActionValidationError {
80 pub fn code(&self) -> &'static str {
82 match self {
83 Self::MissingAction => "missing_action",
84 Self::MissingField { .. } => "missing_field",
85 Self::WrongType { .. } => "wrong_type",
86 Self::NotAvailableOverRest { .. } => "not_available_over_rest",
87 Self::UnknownAction { .. } => "unknown_action",
88 }
89 }
90
91 pub fn field(&self) -> Option<&str> {
93 match self {
94 Self::MissingAction => Some("action"),
95 Self::MissingField { field } | Self::WrongType { field } => Some(field.as_str()),
96 Self::NotAvailableOverRest { .. } | Self::UnknownAction { .. } => Some("action"),
97 }
98 }
99
100 pub fn bad_value(&self) -> Option<&str> {
102 match self {
103 Self::NotAvailableOverRest { action } | Self::UnknownAction { action } => {
104 Some(action.as_str())
105 }
106 Self::MissingAction | Self::MissingField { .. } | Self::WrongType { .. } => None,
107 }
108 }
109
110 pub fn remediation(&self) -> String {
112 match self {
113 Self::MissingAction => {
114 format!(
115 "Set `action` to one of: {}. Use action=help for the full schema.",
116 action_names().join(", ")
117 )
118 }
119 Self::MissingField { field } => {
120 format!("Provide a non-empty `{field}` value, or use action=help for examples.")
121 }
122 Self::WrongType { field } => {
123 format!("Pass `{field}` as a JSON string, or use action=help for examples.")
124 }
125 Self::NotAvailableOverRest { action } => {
126 format!("Call action={action} through MCP, or call action=help over REST.")
127 }
128 Self::UnknownAction { .. } => {
129 format!(
130 "Retry with one of the supported actions: {}. Use action=help for examples.",
131 action_names().join(", ")
132 )
133 }
134 }
135 }
136}
137
138pub use crate::scopes::{DENY_SCOPE, READ_SCOPE, WRITE_SCOPE, scopes_satisfy};
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum ActionTransport {
148 Any,
150 McpOnly,
152}
153
154impl ActionTransport {
155 pub fn mcp(self) -> bool {
157 matches!(self, Self::Any | Self::McpOnly)
158 }
159
160 pub fn cli(self) -> bool {
162 matches!(self, Self::Any)
163 }
164
165 pub fn rest(self) -> bool {
167 matches!(self, Self::Any)
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum ActionCost {
174 Cheap,
176 Moderate,
178 Expensive,
180 Write,
182}
183
184impl ActionCost {
185 pub fn as_str(self) -> &'static str {
187 match self {
188 Self::Cheap => "cheap",
189 Self::Moderate => "moderate",
190 Self::Expensive => "expensive",
191 Self::Write => "write",
192 }
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub struct ParamSpec {
199 pub name: &'static str,
201 pub ty: &'static str,
203 pub required: bool,
205 pub description: &'static str,
207 pub max_len: Option<usize>,
209 pub enum_values: &'static [&'static str],
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub struct CliFlagSpec {
216 pub name: &'static str,
218 pub value_name: Option<&'static str>,
220 pub required: bool,
222 pub description: &'static str,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub struct CliSpec {
229 pub command: &'static str,
231 pub usage: &'static str,
233 pub flags: &'static [CliFlagSpec],
235 pub description: &'static str,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct ActionSpec {
242 pub name: &'static str,
244 pub description: &'static str,
246 pub required_scope: Option<&'static str>,
248 pub transport: ActionTransport,
250 pub rest_method: Option<&'static str>,
252 pub rest_path: Option<&'static str>,
254 pub destructive: bool,
256 pub requires_admin: bool,
258 pub cost: ActionCost,
260 pub params: &'static [ParamSpec],
262 pub returns: &'static str,
264 pub cli: Option<CliSpec>,
266}
267
268const GREET_PARAMS: &[ParamSpec] = &[ParamSpec {
269 name: "name",
270 ty: "string",
271 required: false,
272 description: "Name to greet. Omit to greet the world.",
273 max_len: Some(4096),
274 enum_values: &[],
275}];
276
277const ECHO_PARAMS: &[ParamSpec] = &[ParamSpec {
278 name: "message",
279 ty: "string",
280 required: true,
281 description: "Message to echo back. Must not be empty.",
282 max_len: Some(4096),
283 enum_values: &[],
284}];
285
286const GREET_CLI_FLAGS: &[CliFlagSpec] = &[CliFlagSpec {
287 name: "--name",
288 value_name: Some("NAME"),
289 required: false,
290 description: "Name to greet. Omit to greet the world.",
291}];
292
293const ECHO_CLI_FLAGS: &[CliFlagSpec] = &[CliFlagSpec {
294 name: "--message",
295 value_name: Some("MSG"),
296 required: true,
297 description: "Message to echo back. Must not be empty.",
298}];
299
300pub const ACTION_SPECS: &[ActionSpec] = &[
305 ActionSpec {
306 name: "greet",
307 description: "Return a greeting.",
308 required_scope: Some(READ_SCOPE),
309 transport: ActionTransport::Any,
310 rest_method: Some("POST"),
311 rest_path: Some("/v1/greet"),
312 destructive: false,
313 requires_admin: false,
314 cost: ActionCost::Cheap,
315 params: GREET_PARAMS,
316 returns: "Greeting",
317 cli: Some(CliSpec {
318 command: "greet",
319 usage: "soma greet [--name NAME]",
320 flags: GREET_CLI_FLAGS,
321 description: "Greet NAME, or the world when omitted.",
322 }),
323 },
324 ActionSpec {
325 name: "echo",
326 description: "Echo a message back unchanged.",
327 required_scope: Some(READ_SCOPE),
328 transport: ActionTransport::Any,
329 rest_method: Some("POST"),
330 rest_path: Some("/v1/echo"),
331 destructive: false,
332 requires_admin: false,
333 cost: ActionCost::Cheap,
334 params: ECHO_PARAMS,
335 returns: "EchoResult",
336 cli: Some(CliSpec {
337 command: "echo",
338 usage: "soma echo --message MSG",
339 flags: ECHO_CLI_FLAGS,
340 description: "Echo MSG back unchanged.",
341 }),
342 },
343 ActionSpec {
344 name: "status",
345 description: "Return server status and configuration info.",
346 required_scope: Some(READ_SCOPE),
347 transport: ActionTransport::Any,
348 rest_method: Some("GET"),
349 rest_path: Some("/v1/status"),
350 destructive: false,
351 requires_admin: false,
352 cost: ActionCost::Cheap,
353 params: &[],
354 returns: "Status",
355 cli: Some(CliSpec {
356 command: "status",
357 usage: "soma status",
358 flags: &[],
359 description: "Show service status.",
360 }),
361 },
362 python::ENVIRONMENT_STATUS,
363 python::ENVIRONMENT_PRUNE_PLAN,
364 python::ENVIRONMENT_PRUNE,
365 python::ENVIRONMENT_REPAIR,
366 python::ENVIRONMENT_UPDATE,
367 python::WORKER_STATUS,
368 python::WORKER_CANCEL,
369 python::WORKER_RESET,
370 python::GENERATION_STATUS,
371 python::GENERATION_ROLLBACK,
372 python::GRADUATION_STATUS,
373 python::GRADUATION_APPLY,
374 ActionSpec {
375 name: "elicit_name",
376 description: "Ask the MCP client to collect a name, then return a personalised greeting.",
377 required_scope: Some(READ_SCOPE),
378 transport: ActionTransport::McpOnly,
379 rest_method: None,
380 rest_path: None,
381 destructive: false,
382 requires_admin: false,
383 cost: ActionCost::Cheap,
384 params: &[],
385 returns: "Greeting",
386 cli: None,
387 },
388 ActionSpec {
389 name: "scaffold_intent",
390 description: "Collect scaffold setup intent through MCP elicitation and return JSON for the scaffold-project skill.",
391 required_scope: Some(READ_SCOPE),
392 transport: ActionTransport::McpOnly,
393 rest_method: None,
394 rest_path: None,
395 destructive: false,
396 requires_admin: false,
397 cost: ActionCost::Moderate,
398 params: &[],
399 returns: "ScaffoldIntentReport",
400 cli: None,
401 },
402 ActionSpec {
403 name: "help",
404 description: "Show the action reference.",
405 required_scope: None,
406 transport: ActionTransport::Any,
407 rest_method: Some("GET"),
408 rest_path: Some("/v1/help"),
409 destructive: false,
410 requires_admin: false,
411 cost: ActionCost::Cheap,
412 params: &[],
413 returns: "HelpPayload",
414 cli: Some(CliSpec {
415 command: "help",
416 usage: "soma help",
417 flags: &[],
418 description: "Show JSON action reference.",
419 }),
420 },
421];
422
423pub fn action_names() -> Vec<&'static str> {
425 ACTION_SPECS.iter().map(|spec| spec.name).collect()
426}
427
428pub fn is_known_action(action: &str) -> bool {
430 ACTION_SPECS.iter().any(|spec| spec.name == action)
431}
432
433pub fn rest_action_names() -> Vec<&'static str> {
435 ACTION_SPECS
436 .iter()
437 .filter(|spec| spec.transport.rest())
438 .map(|spec| spec.name)
439 .collect()
440}
441
442pub fn cli_action_names() -> Vec<&'static str> {
444 ACTION_SPECS
445 .iter()
446 .filter(|spec| spec.cli.is_some())
447 .map(|spec| spec.name)
448 .collect()
449}
450
451pub fn cli_commands() -> Vec<&'static str> {
453 ACTION_SPECS
454 .iter()
455 .filter_map(|spec| spec.cli.map(|cli| cli.command))
456 .collect()
457}
458
459pub fn is_rest_action(action: &str) -> bool {
461 action_spec(action)
462 .map(|spec| spec.transport.rest())
463 .unwrap_or(false)
464}
465
466pub fn mcp_only_action_names() -> Vec<&'static str> {
468 ACTION_SPECS
469 .iter()
470 .filter(|spec| spec.transport == ActionTransport::McpOnly)
471 .map(|spec| spec.name)
472 .collect()
473}
474
475pub fn required_scope_for_action(action: &str) -> Option<&'static str> {
479 action_spec(action)
480 .map(|spec| spec.required_scope)
481 .unwrap_or(Some(DENY_SCOPE))
482}
483
484pub fn action_spec(action: &str) -> Option<&'static ActionSpec> {
486 ACTION_SPECS.iter().find(|spec| spec.name == action)
487}
488
489pub fn require_confirmation_if_destructive(
498 action: &str,
499 params: &Value,
500) -> Result<(), Box<crate::errors::ToolError>> {
501 let Some(spec) = action_spec(action) else {
502 return Ok(());
503 };
504 if !spec.destructive {
505 return Ok(());
506 }
507 let confirmed = params
508 .get("confirm")
509 .and_then(Value::as_bool)
510 .unwrap_or(false);
511 if confirmed {
512 return Ok(());
513 }
514 Err(Box::new(
516 crate::errors::ToolError::validation(
517 "confirmation_required",
518 format!("action '{action}' is destructive and requires \"confirm\": true"),
519 "Re-send the request with \"confirm\": true to proceed.",
520 )
521 .with_field("confirm"),
522 ))
523}
524
525#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
527pub struct SurfaceAvailability {
528 pub mcp: bool,
530 pub cli: bool,
532 pub rest: bool,
534 pub web_ui: bool,
536}
537
538#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
540pub struct ParamDoc {
541 pub name: String,
543 pub ty: String,
545 pub required: bool,
547 pub description: String,
549 pub max_len: Option<usize>,
551 pub enum_values: Vec<String>,
553}
554
555#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
557pub struct ActionDoc {
558 pub service: String,
560 pub action: String,
562 pub description: String,
564 pub destructive: bool,
566 pub requires_admin: bool,
568 pub cost: String,
570 pub required_scope: Option<String>,
572 pub params: Vec<ParamDoc>,
574 pub returns: String,
576 pub surface_availability: SurfaceAvailability,
578 pub auth_posture: String,
580 pub mcp_only_exception: Option<String>,
582 pub cli: Option<CliDoc>,
584}
585
586#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
588pub struct CliFlagDoc {
589 pub name: String,
591 pub value_name: Option<String>,
593 pub required: bool,
595 pub description: String,
597}
598
599#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
601pub struct CliDoc {
602 pub command: String,
604 pub usage: String,
606 pub description: String,
608 pub flags: Vec<CliFlagDoc>,
610}
611
612pub fn action_catalog() -> Vec<ActionDoc> {
614 ACTION_SPECS
615 .iter()
616 .map(|spec| ActionDoc {
617 service: "soma".to_owned(),
618 action: spec.name.to_owned(),
619 description: spec.description.to_owned(),
620 destructive: spec.destructive,
621 requires_admin: spec.requires_admin,
622 cost: spec.cost.as_str().to_owned(),
623 required_scope: spec.required_scope.map(ToOwned::to_owned),
624 params: spec
625 .params
626 .iter()
627 .map(|param| ParamDoc {
628 name: param.name.to_owned(),
629 ty: param.ty.to_owned(),
630 required: param.required,
631 description: param.description.to_owned(),
632 max_len: param.max_len,
633 enum_values: param
634 .enum_values
635 .iter()
636 .map(|value| (*value).to_owned())
637 .collect(),
638 })
639 .collect(),
640 returns: spec.returns.to_owned(),
641 surface_availability: SurfaceAvailability {
642 mcp: spec.transport.mcp(),
643 cli: spec.transport.cli(),
644 rest: spec.transport.rest(),
645 web_ui: false,
646 },
647 auth_posture: match spec.required_scope {
648 Some(scope) => format!("requires `{scope}` on authenticated transports"),
649 None => "public action; no action scope required".to_owned(),
650 },
651 mcp_only_exception: (spec.transport == ActionTransport::McpOnly)
652 .then(|| "MCP-only because it requires client-rendered elicitation.".to_owned()),
653 cli: spec.cli.map(|cli| CliDoc {
654 command: cli.command.to_owned(),
655 usage: cli.usage.to_owned(),
656 description: cli.description.to_owned(),
657 flags: cli
658 .flags
659 .iter()
660 .map(|flag| CliFlagDoc {
661 name: flag.name.to_owned(),
662 value_name: flag.value_name.map(ToOwned::to_owned),
663 required: flag.required,
664 description: flag.description.to_owned(),
665 })
666 .collect(),
667 }),
668 })
669 .collect()
670}
671
672#[derive(Debug, Clone, PartialEq, Eq)]
674pub enum SomaAction {
675 Greet {
677 name: Option<String>,
679 },
680 Echo {
682 message: String,
684 },
685 Status,
687 PythonEnvironmentStatus,
689 PythonEnvironmentPrunePlan {
691 stale_before_unix_seconds: u64,
693 max_entries: usize,
695 },
696 PythonEnvironmentPrune {
698 stale_before_unix_seconds: u64,
700 max_entries: usize,
702 },
703 PythonEnvironmentRepair {
705 provider_path: String,
707 },
708 PythonEnvironmentUpdate {
710 provider_path: String,
712 },
713 PythonWorkerStatus,
715 PythonWorkerCancel {
717 provider: String,
719 },
720 PythonWorkerReset {
722 provider: String,
724 },
725 PythonGenerationStatus,
727 PythonGenerationRollback {
729 generation_id: u64,
731 },
732 PythonGraduationStatus {
734 workspace: String,
736 },
737 PythonGraduationApply {
739 operation: String,
741 workspace: String,
743 source: Option<String>,
745 component: Option<String>,
747 fixtures: Option<String>,
749 wheelhouse: Option<String>,
751 },
752 Help,
754 ElicitName,
756 ScaffoldIntent,
758}
759
760impl SomaAction {
761 pub fn name(&self) -> &'static str {
763 match self {
764 Self::Greet { .. } => "greet",
765 Self::Echo { .. } => "echo",
766 Self::Status => "status",
767 Self::PythonEnvironmentStatus => "python_environment_status",
768 Self::PythonEnvironmentPrunePlan { .. } => "python_environment_prune_plan",
769 Self::PythonEnvironmentPrune { .. } => "python_environment_prune",
770 Self::PythonEnvironmentRepair { .. } => "python_environment_repair",
771 Self::PythonEnvironmentUpdate { .. } => "python_environment_update",
772 Self::PythonWorkerStatus => "python_worker_status",
773 Self::PythonWorkerCancel { .. } => "python_worker_cancel",
774 Self::PythonWorkerReset { .. } => "python_worker_reset",
775 Self::PythonGenerationStatus => "python_generation_status",
776 Self::PythonGenerationRollback { .. } => "python_generation_rollback",
777 Self::PythonGraduationStatus { .. } => "python_graduation_status",
778 Self::PythonGraduationApply { .. } => "python_graduation_apply",
779 Self::Help => "help",
780 Self::ElicitName => "elicit_name",
781 Self::ScaffoldIntent => "scaffold_intent",
782 }
783 }
784
785 pub fn from_mcp_args(args: &Value) -> anyhow::Result<Self> {
787 let action = match args.get("action") {
788 None => return Err(action_error(ValidationError::MissingAction)),
789 Some(Value::String(action)) => action.as_str(),
790 Some(_) => {
791 return Err(action_error(ValidationError::WrongType {
792 field: "action".into(),
793 }));
794 }
795 };
796 Self::from_params(action, args)
797 }
798
799 pub fn from_rest(action: &str, params: &Value) -> anyhow::Result<Self> {
801 if action.is_empty() {
802 return Err(action_error(ValidationError::MissingAction));
803 }
804 if action_spec(action)
805 .map(|spec| spec.transport == ActionTransport::McpOnly)
806 .unwrap_or(false)
807 {
808 return Err(action_error(ValidationError::NotAvailableOverRest {
809 action: action.to_owned(),
810 }));
811 }
812 Self::from_params(action, params)
813 }
814
815 fn from_params(action: &str, params: &Value) -> anyhow::Result<Self> {
816 match action {
817 "greet" => Ok(Self::Greet {
818 name: optional_string_param(params, "name")?,
819 }),
820 "echo" => {
821 let message = optional_string_param(params, "message")?
822 .filter(|m| !m.is_empty())
823 .ok_or_else(|| {
824 action_error(ValidationError::MissingField {
825 field: "message".into(),
826 })
827 })?;
828 Ok(Self::Echo { message })
829 }
830 "status" => Ok(Self::Status),
831 "python_environment_status" => Ok(Self::PythonEnvironmentStatus),
832 "python_environment_prune_plan" => Ok(Self::PythonEnvironmentPrunePlan {
833 stale_before_unix_seconds: required_u64_param(params, "stale_before_unix_seconds")?,
834 max_entries: bounded_entries(params)?,
835 }),
836 "python_environment_prune" => Ok(Self::PythonEnvironmentPrune {
837 stale_before_unix_seconds: required_u64_param(params, "stale_before_unix_seconds")?,
838 max_entries: bounded_entries(params)?,
839 }),
840 "python_environment_repair" => Ok(Self::PythonEnvironmentRepair {
841 provider_path: required_string_param(params, "provider_path")?,
842 }),
843 "python_environment_update" => Ok(Self::PythonEnvironmentUpdate {
844 provider_path: required_string_param(params, "provider_path")?,
845 }),
846 "python_worker_status" => Ok(Self::PythonWorkerStatus),
847 "python_worker_cancel" => Ok(Self::PythonWorkerCancel {
848 provider: required_string_param(params, "provider")?,
849 }),
850 "python_worker_reset" => Ok(Self::PythonWorkerReset {
851 provider: required_string_param(params, "provider")?,
852 }),
853 "python_generation_status" => Ok(Self::PythonGenerationStatus),
854 "python_generation_rollback" => Ok(Self::PythonGenerationRollback {
855 generation_id: required_u64_param(params, "generation_id")?,
856 }),
857 "python_graduation_status" => Ok(Self::PythonGraduationStatus {
858 workspace: required_string_param(params, "workspace")?,
859 }),
860 "python_graduation_apply" => Ok(Self::PythonGraduationApply {
861 operation: required_string_param(params, "operation")?,
862 workspace: required_string_param(params, "workspace")?,
863 source: optional_string_param(params, "source")?,
864 component: optional_string_param(params, "component")?,
865 fixtures: optional_string_param(params, "fixtures")?,
866 wheelhouse: optional_string_param(params, "wheelhouse")?,
867 }),
868 "help" => Ok(Self::Help),
869 "elicit_name" => Ok(Self::ElicitName),
870 "scaffold_intent" => Ok(Self::ScaffoldIntent),
871 other => Err(action_error(ValidationError::UnknownAction {
872 action: other.to_owned(),
873 })),
874 }
875 }
876}
877
878fn optional_string_param(params: &Value, name: &str) -> Result<Option<String>, ValidationError> {
879 match params.get(name) {
880 None => Ok(None),
881 Some(value) => value
882 .as_str()
883 .map(|s| Some(s.to_owned()))
884 .ok_or_else(|| ValidationError::WrongType { field: name.into() }),
885 }
886}
887
888fn required_string_param(params: &Value, name: &str) -> Result<String, ValidationError> {
889 optional_string_param(params, name)?
890 .filter(|value| !value.is_empty())
891 .ok_or_else(|| ValidationError::MissingField { field: name.into() })
892}
893
894fn required_u64_param(params: &Value, name: &str) -> Result<u64, ValidationError> {
895 params
896 .get(name)
897 .and_then(Value::as_u64)
898 .ok_or_else(|| ValidationError::WrongType { field: name.into() })
899}
900
901fn bounded_entries(params: &Value) -> Result<usize, ValidationError> {
902 let entries = params.get("max_entries").map_or(Ok(100_u64), |value| {
903 value.as_u64().ok_or_else(|| ValidationError::WrongType {
904 field: "max_entries".into(),
905 })
906 })?;
907 if !(1..=1000).contains(&entries) {
908 return Err(ValidationError::WrongType {
909 field: "max_entries".into(),
910 });
911 }
912 Ok(entries as usize)
913}
914
915fn action_error(error: ValidationError) -> anyhow::Error {
916 error.into()
917}
918
919pub fn action_validation_error(error: &anyhow::Error) -> Option<&ActionValidationError> {
921 if let Some(error) = error.downcast_ref::<ActionError>() {
922 return error.as_validation();
923 }
924 error.downcast_ref::<ActionValidationError>()
925}
926
927pub fn is_validation_error(error: &anyhow::Error) -> bool {
929 action_validation_error(error).is_some()
930}
931
932#[cfg(test)]
947#[path = "actions_tests.rs"]
948mod tests;