Skip to main content

soma_application/
app.rs

1use std::sync::Arc;
2
3use serde_json::Value;
4use soma_domain::{
5    scopes::{READ_SCOPE, WRITE_SCOPE},
6    token_limit::MAX_RESPONSE_BYTES,
7    AuthorizationMode, Principal, Surface,
8};
9use soma_provider_core::{ProviderPrompt, ProviderResource};
10
11use crate::{
12    ApplicationError, ApplicationPorts, CatalogSnapshot, CodeModeExecuteRequest, DoctorReport,
13    ElicitedName, ElicitedNameOutcome, ExecuteActionRequest, ExecuteActionResponse,
14    ExecutionContext, GatewayExecuteRequest, GatewayPromptRoute, GatewayReloadRequest,
15    GatewayResourceRoute, GatewayRouteScope, GatewayToolRoute, OpenApiExecuteRequest,
16    OperationResponse, ProviderAuthMode, ProviderCall, ProviderPrincipal, ProviderRegistry,
17    ProviderRequestLimits, ProviderSurface, ReadResourceRequest, ResourceContent,
18    ResourceReadOutput, ResourceTemplateSpec, ScaffoldIntent, ScaffoldIntentRequest, SomaService,
19};
20
21#[cfg(test)]
22#[path = "app_tests.rs"]
23mod tests;
24
25/// Shared use-case facade every surface (MCP, REST, CLI) calls into.
26///
27/// Wraps the legacy [`SomaService`] and [`ProviderRegistry`] plus the outbound
28/// [`ApplicationPorts`] (gateway, code mode, OpenAPI), exposing one method per
29/// application operation.
30pub struct SomaApplication {
31    legacy_service: Arc<SomaService>,
32    legacy_registry: Arc<ProviderRegistry>,
33    ports: ApplicationPorts,
34}
35
36impl SomaApplication {
37    /// Assemble the facade from its service, provider registry, and outbound ports.
38    pub fn new(
39        legacy_service: Arc<SomaService>,
40        legacy_registry: Arc<ProviderRegistry>,
41        ports: ApplicationPorts,
42    ) -> Self {
43        Self {
44            legacy_service,
45            legacy_registry,
46            ports,
47        }
48    }
49
50    /// Dispatch an action through the provider registry and return its output.
51    pub async fn execute_action(
52        &self,
53        request: ExecuteActionRequest,
54        context: ExecutionContext,
55    ) -> Result<ExecuteActionResponse, ApplicationError> {
56        let limits = ProviderRequestLimits {
57            max_response_bytes: context
58                .response_limit
59                .unwrap_or(ProviderRequestLimits::default().max_response_bytes),
60            ..ProviderRequestLimits::default()
61        };
62        let call = ProviderCall {
63            provider: String::new(),
64            action: request.action,
65            params: request.params,
66            principal: provider_principal(context.principal.as_ref()),
67            auth_mode: provider_auth_mode(context.authorization_mode),
68            surface: provider_surface(context.surface),
69            destructive_confirmed: context.destructive_confirmation.is_confirmed(),
70            limits,
71            snapshot_id: String::new(),
72        };
73        let output = self.legacy_registry.dispatch(call).await?;
74        Ok(ExecuteActionResponse {
75            output: output.value,
76            request_id: context.request_id.as_str().to_owned(),
77        })
78    }
79
80    /// Build the greeting for the elicited-name demo from the collected outcome.
81    pub fn elicited_name_greeting(&self, outcome: ElicitedName) -> Value {
82        match outcome {
83            ElicitedName::Accepted(name) => self
84                .legacy_service
85                .elicited_name_greeting(ElicitedNameOutcome::Accepted(&name)),
86            ElicitedName::NoInput => self
87                .legacy_service
88                .elicited_name_greeting(ElicitedNameOutcome::NoInput),
89            ElicitedName::Declined => self
90                .legacy_service
91                .elicited_name_greeting(ElicitedNameOutcome::Declined),
92            ElicitedName::Cancelled => self
93                .legacy_service
94                .elicited_name_greeting(ElicitedNameOutcome::Cancelled),
95            ElicitedName::Unsupported => self
96                .legacy_service
97                .elicited_name_greeting(ElicitedNameOutcome::Unsupported),
98        }
99    }
100
101    /// Normalize elicited scaffold requirements into the JSON handoff contract.
102    pub fn scaffold_intent(
103        &self,
104        request: ScaffoldIntentRequest,
105    ) -> Result<Value, ApplicationError> {
106        self.legacy_service
107            .scaffold_intent(ScaffoldIntent {
108                display_name: request.display_name,
109                crate_name: request.crate_name,
110                binary_name: request.binary_name,
111                server_category: request.server_category,
112                env_prefix: request.env_prefix,
113                auth_kind: request.auth_kind,
114                host: request.host,
115                port: request.port,
116                mcp_transport: request.mcp_transport,
117                mcp_primitives: request.mcp_primitives,
118                deployment: request.deployment,
119                plugins: request.plugins,
120                publish_mcp: request.publish_mcp,
121                crawl_urls: request.crawl_urls,
122                crawl_repos: request.crawl_repos,
123                crawl_search_topics: request.crawl_search_topics,
124            })
125            .map_err(|error| ApplicationError::service(&error))
126    }
127
128    /// Return a snapshot of the currently loaded provider catalog.
129    pub fn catalog_snapshot(&self) -> CatalogSnapshot {
130        catalog_snapshot(self.legacy_registry.snapshot().as_ref())
131    }
132
133    /// Resolve a CLI command name to its backing action, or a not-found error.
134    pub fn resolve_cli_action(&self, command: &str) -> Result<String, ApplicationError> {
135        self.legacy_registry
136            .snapshot()
137            .cli_action(command)
138            .map(ToOwned::to_owned)
139            .ok_or_else(|| ApplicationError::not_found("CLI command", command))
140    }
141
142    /// Report whether the action is destructive and requires explicit confirmation.
143    pub fn action_requires_confirmation(&self, action: &str) -> bool {
144        self.legacy_registry
145            .snapshot()
146            .action_requires_confirmation(action)
147    }
148
149    /// Return the name of the provider that owns the given action, if any.
150    pub fn provider_for_action(&self, action: &str) -> Option<String> {
151        self.legacy_registry
152            .snapshot()
153            .provider_for_action(action)
154            .map(ToOwned::to_owned)
155    }
156
157    /// Return the provider validation summary for the loaded catalog.
158    pub fn provider_validation_summary(&self) -> Value {
159        self.legacy_registry.snapshot().validation_summary()
160    }
161
162    /// Return the detailed provider inspection report for the loaded catalog.
163    pub fn provider_inspection_report(&self) -> Value {
164        self.legacy_registry.snapshot().inspection_report()
165    }
166
167    /// Resolve an HTTP method and path to its backing action, if a route matches.
168    pub fn resolve_rest_route(&self, method: &str, path: &str) -> Option<String> {
169        self.legacy_registry
170            .snapshot()
171            .route_action(method, path)
172            .map(ToOwned::to_owned)
173    }
174
175    /// Return the runtime OpenAPI document assembled from the provider catalog.
176    pub fn openapi_document(&self) -> Result<Value, ApplicationError> {
177        serde_json::from_slice(&self.legacy_registry.snapshot().cached_openapi_bytes).map_err(
178            |error| {
179                ApplicationError::new(
180                    "openapi_unavailable",
181                    format!("runtime OpenAPI document is unavailable: {error}"),
182                    false,
183                    "Refresh the provider catalog and retry.",
184                )
185            },
186        )
187    }
188
189    /// Reload file-backed providers and return the resulting catalog snapshot.
190    pub fn refresh_providers(&self) -> Result<CatalogSnapshot, ApplicationError> {
191        self.refresh_providers_in_place()?;
192        Ok(self.catalog_snapshot())
193    }
194
195    /// Reload file-backed providers without returning a snapshot.
196    pub fn refresh_providers_in_place(&self) -> Result<(), ApplicationError> {
197        self.legacy_registry
198            .refresh_file_providers()
199            .map(|_| ())
200            .map_err(|error| {
201                let diagnostic = crate::provider_errors::redact_public(&error.to_string());
202                ApplicationError::new(
203                    "provider_refresh_failed",
204                    format!("provider refresh failed: {diagnostic}"),
205                    false,
206                    "Fix invalid provider files and retry.",
207                )
208            })
209    }
210
211    /// Read a provider resource by URI and return its text or blob content.
212    pub async fn read_resource(
213        &self,
214        request: ReadResourceRequest,
215        context: ExecutionContext,
216    ) -> Result<ResourceContent, ApplicationError> {
217        let output = self
218            .legacy_registry
219            .read_resource(
220                &request.uri,
221                &provider_principal(context.principal.as_ref()),
222                provider_auth_mode(context.authorization_mode),
223            )
224            .await?;
225        Ok(match output {
226            ResourceReadOutput::Text { text, mime_type } => {
227                ResourceContent::Text { text, mime_type }
228            }
229            ResourceReadOutput::Blob {
230                blob_base64,
231                mime_type,
232            } => ResourceContent::Blob {
233                blob_base64,
234                mime_type,
235            },
236        })
237    }
238
239    /// List the exact (non-templated) provider resources in the catalog.
240    pub fn list_resources(&self) -> Vec<ProviderResource> {
241        self.legacy_registry
242            .snapshot()
243            .exact_resources()
244            .cloned()
245            .collect()
246    }
247
248    /// List the dynamic (URI-templated) provider resource templates in the catalog.
249    pub fn list_resource_templates(&self) -> Vec<ResourceTemplateSpec> {
250        self.legacy_registry
251            .snapshot()
252            .dynamic_resource_templates()
253            .iter()
254            .map(|(_, template)| template)
255            .map(|template| ResourceTemplateSpec {
256                uri_template: template.uri_template(),
257                name: template.name.clone(),
258                description: template.description.clone(),
259                mime_type: template.mime_type.clone(),
260            })
261            .collect()
262    }
263
264    /// List the servable provider prompts in the catalog.
265    pub fn list_prompts(&self) -> Vec<ProviderPrompt> {
266        self.legacy_registry
267            .snapshot()
268            .catalogs
269            .iter()
270            .flat_map(|catalog| catalog.prompts.iter())
271            .filter(|prompt| prompt_is_servable(prompt))
272            .cloned()
273            .collect()
274    }
275
276    /// Fetch a servable prompt by name, enforcing scope visibility.
277    pub fn get_prompt(
278        &self,
279        name: &str,
280        context: &ExecutionContext,
281    ) -> Result<ProviderPrompt, ApplicationError> {
282        let prompt = self
283            .legacy_registry
284            .snapshot()
285            .catalogs
286            .iter()
287            .flat_map(|catalog| catalog.prompts.iter())
288            .filter(|prompt| prompt_is_servable(prompt))
289            .find(|prompt| prompt.name == name)
290            .cloned()
291            .ok_or_else(|| ApplicationError::not_found("prompt", name))?;
292        if !scope_visible(prompt.scope.as_deref(), context) {
293            let required = prompt.scope.as_deref().unwrap_or_default();
294            return Err(ApplicationError::new(
295                "insufficient_scope",
296                format!("prompt `{name}` requires scope `{required}`"),
297                false,
298                "Authenticate with a token that includes the required scope.",
299            ));
300        }
301        Ok(prompt)
302    }
303
304    /// Query the MCP gateway status via the gateway port.
305    pub async fn gateway_status(
306        &self,
307        context: ExecutionContext,
308    ) -> Result<OperationResponse, ApplicationError> {
309        let output = self.ports.gateway.status(&context).await?;
310        operation_response(output, &context)
311    }
312
313    /// Reload the MCP gateway configuration via the gateway port.
314    pub async fn gateway_reload(
315        &self,
316        request: GatewayReloadRequest,
317        context: ExecutionContext,
318    ) -> Result<OperationResponse, ApplicationError> {
319        let output = self.ports.gateway.reload(request, &context).await?;
320        operation_response(output, &context)
321    }
322
323    /// Execute a gateway operation via the gateway port.
324    pub async fn gateway_execute(
325        &self,
326        request: GatewayExecuteRequest,
327        context: ExecutionContext,
328    ) -> Result<OperationResponse, ApplicationError> {
329        let output = self.ports.gateway.execute(request, &context).await?;
330        operation_response(output, &context)
331    }
332
333    /// List MCP tools exposed through the gateway, optionally filtered by scope.
334    pub async fn gateway_mcp_tools(
335        &self,
336        scope: Option<&GatewayRouteScope>,
337        context: &ExecutionContext,
338    ) -> Result<Vec<GatewayToolRoute>, ApplicationError> {
339        Ok(self.ports.gateway.list_mcp_tools(scope, context).await?)
340    }
341
342    /// Call an MCP tool through the gateway, returning its result if routed.
343    pub async fn gateway_call_mcp_tool(
344        &self,
345        name: &str,
346        params: Value,
347        scope: Option<&GatewayRouteScope>,
348        context: &ExecutionContext,
349    ) -> Result<Option<Value>, ApplicationError> {
350        Ok(self
351            .ports
352            .gateway
353            .call_mcp_tool(name, params, scope, context)
354            .await?)
355    }
356
357    /// List MCP resources exposed through the gateway, optionally filtered by scope.
358    pub async fn gateway_mcp_resources(
359        &self,
360        scope: Option<&GatewayRouteScope>,
361        context: &ExecutionContext,
362    ) -> Result<Vec<GatewayResourceRoute>, ApplicationError> {
363        Ok(self
364            .ports
365            .gateway
366            .list_mcp_resources(scope, context)
367            .await?)
368    }
369
370    /// Read an MCP resource through the gateway, returning its content if routed.
371    pub async fn gateway_read_mcp_resource(
372        &self,
373        uri: &str,
374        scope: Option<&GatewayRouteScope>,
375        context: &ExecutionContext,
376    ) -> Result<Option<Value>, ApplicationError> {
377        Ok(self
378            .ports
379            .gateway
380            .read_mcp_resource(uri, scope, context)
381            .await?)
382    }
383
384    /// List MCP prompts exposed through the gateway, optionally filtered by scope.
385    pub async fn gateway_mcp_prompts(
386        &self,
387        scope: Option<&GatewayRouteScope>,
388        context: &ExecutionContext,
389    ) -> Result<Vec<GatewayPromptRoute>, ApplicationError> {
390        Ok(self.ports.gateway.list_mcp_prompts(scope, context).await?)
391    }
392
393    /// Fetch an MCP prompt through the gateway, returning its content if routed.
394    pub async fn gateway_get_mcp_prompt(
395        &self,
396        name: &str,
397        arguments: Option<serde_json::Map<String, Value>>,
398        scope: Option<&GatewayRouteScope>,
399        context: &ExecutionContext,
400    ) -> Result<Option<Value>, ApplicationError> {
401        Ok(self
402            .ports
403            .gateway
404            .get_mcp_prompt(name, arguments, scope, context)
405            .await?)
406    }
407
408    /// Execute a Code Mode request via the code mode port.
409    pub async fn codemode_execute(
410        &self,
411        request: CodeModeExecuteRequest,
412        context: ExecutionContext,
413    ) -> Result<OperationResponse, ApplicationError> {
414        let output = self.ports.codemode.execute(request, &context).await?;
415        operation_response(output, &context)
416    }
417
418    /// Execute an OpenAPI-backed request via the OpenAPI port.
419    pub async fn openapi_execute(
420        &self,
421        request: OpenApiExecuteRequest,
422        context: ExecutionContext,
423    ) -> Result<OperationResponse, ApplicationError> {
424        let output = self.ports.openapi.execute(request, &context).await?;
425        operation_response(output, &context)
426    }
427
428    /// Return the upstream service status.
429    pub async fn status(&self) -> Result<Value, ApplicationError> {
430        self.legacy_service
431            .status()
432            .await
433            .map_err(|error| ApplicationError::legacy("status", error))
434    }
435
436    /// Probe upstream readiness; `Ok(())` when the dependency is reachable.
437    pub async fn readiness(&self) -> Result<(), ApplicationError> {
438        self.legacy_service
439            .ready()
440            .await
441            .map_err(|error| ApplicationError::legacy("readiness", error))
442    }
443
444    /// Run readiness and status probes and collect them into a diagnostic report.
445    pub async fn doctor(&self) -> DoctorReport {
446        let mut problems = Vec::new();
447        let ready = match self.readiness().await {
448            Ok(()) => true,
449            Err(error) => {
450                problems.push(error.to_string());
451                false
452            }
453        };
454        let status = match self.status().await {
455            Ok(status) => Some(status),
456            Err(error) => {
457                problems.push(error.to_string());
458                None
459            }
460        };
461        DoctorReport {
462            ready,
463            status,
464            problems,
465        }
466    }
467}
468
469fn catalog_snapshot(snapshot: &crate::RegistrySnapshot) -> CatalogSnapshot {
470    CatalogSnapshot {
471        id: snapshot.id.clone(),
472        fingerprint: snapshot.fingerprint.clone(),
473        catalogs: snapshot.catalogs.clone(),
474    }
475}
476
477fn operation_response(
478    output: Value,
479    context: &ExecutionContext,
480) -> Result<OperationResponse, ApplicationError> {
481    let maximum = context.response_limit.unwrap_or(MAX_RESPONSE_BYTES);
482    let actual = serde_json::to_vec(&output)
483        .map_err(|error| ApplicationError::legacy("response serialization", error))?
484        .len();
485    if actual > maximum {
486        return Err(ApplicationError::new(
487            "response_too_large",
488            format!("response is {actual} bytes; maximum is {maximum}"),
489            false,
490            "Increase the response limit or request a smaller result.",
491        ));
492    }
493    Ok(OperationResponse {
494        output,
495        request_id: context.request_id.as_str().to_owned(),
496    })
497}
498
499fn provider_principal(principal: Option<&Principal>) -> ProviderPrincipal {
500    principal.map_or_else(ProviderPrincipal::anonymous, |principal| {
501        ProviderPrincipal {
502            subject: principal.subject.clone(),
503            scopes: principal.scopes.to_vec(),
504        }
505    })
506}
507
508fn provider_auth_mode(mode: AuthorizationMode) -> ProviderAuthMode {
509    match mode {
510        AuthorizationMode::LoopbackDev => ProviderAuthMode::LoopbackDev,
511        AuthorizationMode::TrustedGateway => ProviderAuthMode::TrustedGateway,
512        AuthorizationMode::Mounted => ProviderAuthMode::Mounted,
513    }
514}
515
516fn provider_surface(surface: Surface) -> ProviderSurface {
517    match surface {
518        Surface::Mcp => ProviderSurface::Mcp,
519        Surface::Rest => ProviderSurface::Rest,
520        Surface::Cli => ProviderSurface::Cli,
521        Surface::Palette => ProviderSurface::Palette,
522    }
523}
524
525fn scope_visible(required: Option<&str>, context: &ExecutionContext) -> bool {
526    if !matches!(context.authorization_mode, AuthorizationMode::Mounted) {
527        return true;
528    }
529    let Some(required) = required else {
530        return true;
531    };
532    context.principal.as_ref().is_some_and(|principal| {
533        principal.scopes.contains(required)
534            || (required == READ_SCOPE && principal.scopes.contains(WRITE_SCOPE))
535    })
536}
537
538fn prompt_is_servable(prompt: &ProviderPrompt) -> bool {
539    prompt.template.is_some()
540        && prompt
541            .mcp
542            .as_ref()
543            .map(|metadata| metadata.enabled)
544            .unwrap_or(true)
545}