1use std::time::Instant;
12
13use rmcp::{
14 ErrorData, RoleServer, ServerHandler,
15 model::{
16 CacheScope, CallToolRequestParams, CallToolResponse, CancelTaskParams,
17 GetPromptRequestParams, GetPromptResponse, GetTaskParams, GetTaskResult, Implementation,
18 ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, ListToolsResult,
19 PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse,
20 ReadResourceResult, ResourceContents, ResourceTemplate, ServerCapabilities, ServerInfo,
21 SubscriptionFilter, UpdateTaskParams,
22 },
23 service::{Peer, RequestContext, SubscriptionContext},
24};
25use serde_json::{Map, Value};
26
27use soma_application::{ApplicationError, GatewayMcpRoundTrip, ReadResourceRequest};
28use soma_mcp_server::{
29 conformance,
30 response_paging::{
31 response_page_request, strip_response_page_params, tool_result_from_cached_page,
32 tool_result_from_json,
33 },
34};
35
36use super::{
37 gateway_proxy, prompts,
38 protocol_errors::{application_error_payload, tool_error_result, unknown_tool_error},
39 rmcp_auth::{protected_route_scope, protected_scope_allows_service, require_auth_context},
40 state::McpState,
41 tools::execute_tool,
42 trace_resolution,
43};
44
45macro_rules! trace_summary_event {
46 ($level:ident, $trace_resolution:expr_2021, $trace_context_conflict:expr_2021, $message:literal, $($field:tt)*) => {
47 tracing::$level!(
48 $($field)*
49 trace_id_prefix = ?$trace_resolution.summary.trace_id_prefix(),
50 span_id_prefix = ?$trace_resolution.summary.span_id_prefix(),
51 trace_sampled = ?$trace_resolution.summary.sampled(),
52 trace_trust = ?$trace_resolution.summary.trust(),
53 has_tracestate = $trace_resolution.summary.has_tracestate(),
54 baggage_member_count = $trace_resolution.summary.baggage_member_count(),
55 sensitive_baggage_member_count = $trace_resolution.summary.sensitive_baggage_member_count(),
56 trace_invalid_count = $trace_resolution.summary.invalid_count(),
57 trace_invalid_reasons = ?$trace_resolution.summary.invalid_reasons(),
58 http_trace_headers_present = $trace_resolution.http_trace_headers_present,
59 trace_context_conflict = $trace_context_conflict,
60 $message
61 );
62 };
63}
64
65mod catalog_subscriptions;
66mod destructive_mrtr;
67mod support;
68
69use support::{
70 SCHEMA_RESOURCE_URI, SERVER_INSTRUCTIONS, empty_action_as_none, execution_context,
71 execution_context_with_trace, private_dynamic_read_response, private_prompts_result,
72 private_resource_templates_result, private_resources_result, private_tools_result,
73 refresh_file_providers, request_meta_value, resource_contents_from_output, resource_read_error,
74 response_paging_options, rmcp_resource_from_catalog_resource, rmcp_tool_definitions,
75 schema_resource, task_application_error, tool_definitions_for_state, trace_resolution_for_call,
76};
77
78#[derive(Clone)]
81pub struct SomaRmcpServer {
82 state: McpState,
83}
84
85pub fn rmcp_server(state: McpState) -> SomaRmcpServer {
86 SomaRmcpServer { state }
87}
88
89impl ServerHandler for SomaRmcpServer {
90 async fn list_tools(
93 &self,
94 _request: Option<PaginatedRequestParams>,
95 context: RequestContext<RoleServer>,
96 ) -> Result<ListToolsResult, ErrorData> {
97 let auth = require_auth_context(&self.state, &context)?;
98 let route_scope = protected_route_scope(&context);
99 let execution_context = execution_context(&self.state, &context, auth);
100 let soma_allowed = protected_scope_allows_service(route_scope, "soma");
101 refresh_file_providers(&self.state).await?;
102 let mut tools = if soma_allowed {
103 rmcp_tool_definitions(&self.state)?
104 } else {
105 Vec::new()
106 };
107 tools.extend(
108 gateway_proxy::list_tools_for_subject_and_scope(
109 self.state.application(),
110 route_scope,
111 &execution_context,
112 )
113 .await?,
114 );
115 if soma_allowed && self.state.config().conformance_fixtures {
116 tools.extend(conformance::tool_definitions());
117 }
118 tracing::debug!(tool_count = tools.len(), "MCP tools listed");
119 Ok(private_tools_result(tools))
120 }
121
122 async fn call_tool(
123 &self,
124 request: CallToolRequestParams,
125 context: RequestContext<RoleServer>,
126 ) -> Result<CallToolResponse, ErrorData> {
127 let tool_name = request.name.to_string();
128
129 let action_opt: Option<String> = request
132 .arguments
133 .as_ref()
134 .and_then(|m| m.get("action"))
135 .and_then(Value::as_str)
136 .map(ToOwned::to_owned);
137
138 let response_page = match response_page_request(request.arguments.as_ref()) {
139 Ok(response_page) => response_page,
140 Err(error) => {
141 tracing::warn!("MCP tool rejected response paging params");
142 return Err(error);
143 }
144 };
145 let auth = match require_auth_context(&self.state, &context) {
146 Ok(auth) => auth,
147 Err(error) => {
148 tracing::warn!("MCP tool rejected auth context");
149 return Err(error);
150 }
151 };
152 let trace_resolution = trace_resolution_for_call(&self.state, &context);
153 let trace_context_conflict = trace_resolution.http_trace_headers_present
154 && trace_resolution::meta_has_any_trace_key(&context.meta);
155 let route_scope = protected_route_scope(&context);
156 let mut execution_context =
157 execution_context_with_trace(&self.state, auth, trace_resolution.trace_context.clone());
158 let soma_allowed = protected_scope_allows_service(route_scope, "soma");
159 if soma_allowed
160 && self.state.config().conformance_fixtures
161 && let Some(result) = conformance::call_tool(&tool_name)
162 {
163 return Ok(result.into());
164 }
165 if tool_name != "soma" {
166 let Some(requires_confirmation) = gateway_proxy::tool_requires_confirmation(
167 self.state.application(),
168 &tool_name,
169 route_scope,
170 &execution_context,
171 )
172 .await?
173 else {
174 return Err(unknown_tool_error(&tool_name));
175 };
176 let confirmation_action = action_opt
177 .as_deref()
178 .filter(|action| !action.is_empty())
179 .unwrap_or(&tool_name);
180 match destructive_mrtr::destructive_confirmation(
181 &request,
182 &context.meta,
183 confirmation_action,
184 requires_confirmation,
185 ) {
186 destructive_mrtr::DestructiveConfirmation::Proceed(confirmation) => {
187 execution_context.destructive_confirmation = confirmation;
188 }
189 destructive_mrtr::DestructiveConfirmation::InputRequired(result) => {
190 return Ok(CallToolResponse::InputRequired(result));
191 }
192 destructive_mrtr::DestructiveConfirmation::Refused => {
193 return tool_error_result(serde_json::json!({
194 "kind": "mcp_tool_error",
195 "schema_version": 1,
196 "code": "confirmation_required",
197 "tool": tool_name,
198 "action": confirmation_action,
199 "message": "destructive gateway tool requires an accepted MCP form confirmation",
200 "retryable": true,
201 "remediation": "Retry with form elicitation support and accept the destructive confirmation, or send legacy confirm=true.",
202 }))
203 .map(Into::into);
204 }
205 }
206
207 if let Some(result) = gateway_proxy::call_tool_for_subject_and_scope(
208 self.state.application(),
209 &tool_name,
210 request.arguments.clone(),
211 GatewayMcpRoundTrip {
212 input_responses: request.input_responses.clone(),
213 request_state: request.request_state.clone(),
214 request_meta: request_meta_value(&context),
215 },
216 route_scope,
217 &execution_context,
218 )
219 .await
220 {
221 match &result {
222 CallToolResponse::Complete(result) if result.is_error == Some(true) => {
223 trace_summary_event!(
224 warn,
225 trace_resolution,
226 trace_context_conflict,
227 "MCP gateway tool execution failed",
228 tool = %tool_name,
229 action = action_opt.as_deref().unwrap_or_default(),
230 );
231 }
232 CallToolResponse::InputRequired(_) => {
233 trace_summary_event!(
234 info,
235 trace_resolution,
236 trace_context_conflict,
237 "MCP gateway tool requires client input",
238 tool = %tool_name,
239 action = action_opt.as_deref().unwrap_or_default(),
240 );
241 }
242 CallToolResponse::Task(_) => {
243 trace_summary_event!(
244 info,
245 trace_resolution,
246 trace_context_conflict,
247 "MCP gateway tool returned a task",
248 tool = %tool_name,
249 action = action_opt.as_deref().unwrap_or_default(),
250 );
251 }
252 _ => {
253 trace_summary_event!(
254 info,
255 trace_resolution,
256 trace_context_conflict,
257 "MCP gateway tool execution completed",
258 tool = %tool_name,
259 action = action_opt.as_deref().unwrap_or_default(),
260 );
261 }
262 }
263 return Ok(result);
264 }
265 trace_summary_event!(
266 warn,
267 trace_resolution,
268 trace_context_conflict,
269 "MCP tool rejected unknown tool",
270 tool = %tool_name,
271 action = action_opt.as_deref().unwrap_or_default(),
272 );
273 return Err(unknown_tool_error(&tool_name));
274 }
275 if !soma_allowed {
276 return Err(unknown_tool_error(&tool_name));
277 }
278 let action: String = action_opt.unwrap_or_default();
279 if let Some(cursor) = response_page.cursor().map(str::to_owned) {
280 trace_summary_event!(
281 info,
282 trace_resolution,
283 trace_context_conflict,
284 "MCP tool returned cached response page",
285 tool = %tool_name,
286 action = empty_action_as_none(&action).unwrap_or_default(),
287 );
288 return tool_result_from_cached_page(
289 self.state.response_pages(),
290 &cursor,
291 response_page,
292 response_paging_options(),
293 &tool_name,
294 empty_action_as_none(&action),
295 )
296 .map(Into::into);
297 }
298
299 let requires_confirmation = self
300 .state
301 .application()
302 .action_requires_confirmation(&action);
303 match destructive_mrtr::destructive_confirmation(
304 &request,
305 &context.meta,
306 &action,
307 requires_confirmation,
308 ) {
309 destructive_mrtr::DestructiveConfirmation::Proceed(confirmation) => {
310 execution_context.destructive_confirmation = confirmation;
311 }
312 destructive_mrtr::DestructiveConfirmation::InputRequired(result) => {
313 return Ok(CallToolResponse::InputRequired(result));
314 }
315 destructive_mrtr::DestructiveConfirmation::Refused => {
316 return tool_error_result(serde_json::json!({
317 "kind": "mcp_tool_error",
318 "schema_version": 1,
319 "code": "confirmation_required",
320 "tool": tool_name,
321 "action": action,
322 "message": "destructive action requires an accepted MCP form confirmation",
323 "retryable": true,
324 "remediation": "Retry with form elicitation support and accept the destructive confirmation, or send legacy confirm=true.",
325 }))
326 .map(Into::into);
327 }
328 }
329
330 let mut arguments = request
331 .arguments
332 .map(Value::Object)
333 .unwrap_or_else(|| Value::Object(Map::new()));
334 strip_response_page_params(&mut arguments);
335 let continuation_args = arguments.as_object().cloned();
336
337 let peer: Peer<RoleServer> = context.peer.clone();
340 let started = Instant::now();
341 trace_summary_event!(
342 info,
343 trace_resolution,
344 trace_context_conflict,
345 "MCP tool execution started",
346 tool = %tool_name,
347 action = %action,
348 );
349
350 match execute_tool(&self.state, &tool_name, arguments, &peer, execution_context).await {
351 Ok(result) => {
352 trace_summary_event!(
353 info,
354 trace_resolution,
355 trace_context_conflict,
356 "MCP tool execution completed",
357 tool = %tool_name,
358 action = %action,
359 elapsed_ms = started.elapsed().as_millis(),
360 );
361 tool_result_from_json(
362 result,
363 self.state.response_pages(),
364 response_page,
365 response_paging_options(),
366 &tool_name,
367 empty_action_as_none(&action),
368 continuation_args.as_ref(),
369 )
370 .map(Into::into)
371 }
372 Err(error) => {
373 let application_error = error.downcast_ref::<ApplicationError>();
374 if application_error.is_some_and(ApplicationError::is_validation) {
375 trace_summary_event!(
376 warn,
377 trace_resolution,
378 trace_context_conflict,
379 "MCP tool rejected invalid params",
380 tool = %tool_name,
381 action = %action,
382 elapsed_ms = started.elapsed().as_millis(),
383 );
384 } else {
385 trace_summary_event!(
386 error,
387 trace_resolution,
388 trace_context_conflict,
389 "MCP tool execution failed",
390 tool = %tool_name,
391 action = %action,
392 elapsed_ms = started.elapsed().as_millis(),
393 service_error_kind = application_error
394 .and_then(ApplicationError::service_error_kind),
395 private_diagnostics = application_error
396 .and_then(ApplicationError::private_diagnostics),
397 error = %error,
398 );
399 }
400 tool_error_result(application_error_payload(
401 &error,
402 &tool_name,
403 empty_action_as_none(&action),
404 ))
405 .map(Into::into)
406 }
407 }
408 }
409
410 async fn list_resources(
413 &self,
414 _request: Option<PaginatedRequestParams>,
415 context: RequestContext<RoleServer>,
416 ) -> Result<ListResourcesResult, ErrorData> {
417 let auth = require_auth_context(&self.state, &context)?;
418 let route_scope = protected_route_scope(&context);
419 let execution_context = execution_context(&self.state, &context, auth);
420 refresh_file_providers(&self.state).await?;
421 let soma_allowed = protected_scope_allows_service(route_scope, "soma");
422 let mut resources = if soma_allowed {
423 let mut resources = vec![schema_resource()];
424 resources.extend(
425 self.state
426 .application()
427 .list_resources()
428 .iter()
429 .map(rmcp_resource_from_catalog_resource),
430 );
431 resources
432 } else {
433 Vec::new()
434 };
435 resources.extend(
436 gateway_proxy::list_resources_for_subject_and_scope(
437 self.state.application(),
438 route_scope,
439 &execution_context,
440 )
441 .await?,
442 );
443 if soma_allowed && self.state.config().conformance_fixtures {
444 resources.extend(conformance::resources());
445 }
446 Ok(private_resources_result(resources))
447 }
448
449 async fn list_resource_templates(
450 &self,
451 _request: Option<PaginatedRequestParams>,
452 context: RequestContext<RoleServer>,
453 ) -> Result<ListResourceTemplatesResult, ErrorData> {
454 require_auth_context(&self.state, &context)?;
455 refresh_file_providers(&self.state).await?;
456 let mut resource_templates: Vec<ResourceTemplate> = self
457 .state
458 .application()
459 .list_resource_templates()
460 .into_iter()
461 .map(|template| {
462 let mut built = ResourceTemplate::new(template.uri_template, template.name)
463 .with_description(template.description.clone());
464 if let Some(mime_type) = template.mime_type {
465 built = built.with_mime_type(mime_type);
466 }
467 built
468 })
469 .collect();
470 if self.state.config().conformance_fixtures {
471 resource_templates.extend(conformance::resource_templates());
472 }
473 Ok(private_resource_templates_result(resource_templates))
474 }
475
476 async fn read_resource(
477 &self,
478 request: ReadResourceRequestParams,
479 context: RequestContext<RoleServer>,
480 ) -> Result<ReadResourceResponse, ErrorData> {
481 let auth = require_auth_context(&self.state, &context)?;
482 let route_scope = protected_route_scope(&context);
483 let execution_context = execution_context(&self.state, &context, auth);
484 let soma_allowed = protected_scope_allows_service(route_scope, "soma");
485 if soma_allowed
486 && self.state.config().conformance_fixtures
487 && let Some(result) = conformance::read_resource(&request.uri)
488 {
489 return Ok(private_dynamic_read_response(result.into()));
490 }
491 if let Some(result) = gateway_proxy::read_resource_for_subject_and_scope(
492 self.state.application(),
493 &request.uri,
494 GatewayMcpRoundTrip {
495 input_responses: request.input_responses.clone(),
496 request_state: request.request_state.clone(),
497 request_meta: request_meta_value(&context),
498 },
499 route_scope,
500 &execution_context,
501 )
502 .await?
503 {
504 return Ok(private_dynamic_read_response(result));
505 }
506 if !soma_allowed {
507 return Err(ErrorData::invalid_params(
508 format!("unknown resource: {}", request.uri),
509 None,
510 ));
511 }
512 refresh_file_providers(&self.state).await?;
513 if request.uri == SCHEMA_RESOURCE_URI {
514 let schema = tool_definitions_for_state(&self.state);
515 let text = serde_json::to_string_pretty(&schema).map_err(|e| {
516 ErrorData::internal_error(format!("serialization error: {e}"), None)
517 })?;
518 return Ok(ReadResourceResult::new(vec![
519 ResourceContents::text(text, SCHEMA_RESOURCE_URI)
520 .with_mime_type("application/json"),
521 ])
522 .with_ttl_ms(0)
523 .with_cache_scope(CacheScope::Private)
524 .into());
525 }
526
527 let output = self
528 .state
529 .application()
530 .read_resource(
531 ReadResourceRequest {
532 uri: request.uri.clone(),
533 },
534 execution_context,
535 )
536 .await
537 .map_err(|error| resource_read_error(&request.uri, &error))?;
538 Ok(
539 ReadResourceResult::new(vec![resource_contents_from_output(&request.uri, output)])
540 .with_ttl_ms(0)
541 .with_cache_scope(CacheScope::Private)
542 .into(),
543 )
544 }
545
546 async fn list_prompts(
549 &self,
550 _request: Option<PaginatedRequestParams>,
551 context: RequestContext<RoleServer>,
552 ) -> Result<ListPromptsResult, ErrorData> {
553 let auth = require_auth_context(&self.state, &context)?;
554 let route_scope = protected_route_scope(&context);
555 let execution_context = execution_context(&self.state, &context, auth);
556 let soma_allowed = protected_scope_allows_service(route_scope, "soma");
557 let mut result = if soma_allowed {
558 prompts::list_prompts()
559 } else {
560 ListPromptsResult::default()
561 };
562 refresh_file_providers(&self.state).await?;
563 if soma_allowed {
564 result.prompts.extend(prompts::provider_prompts(
565 &self.state.application().list_prompts(),
566 ));
567 }
568 result.prompts.extend(
569 gateway_proxy::list_prompts_for_subject_and_scope(
570 self.state.application(),
571 route_scope,
572 &execution_context,
573 )
574 .await?,
575 );
576 if soma_allowed && self.state.config().conformance_fixtures {
577 result.prompts.extend(conformance::prompts());
578 }
579 Ok(private_prompts_result(result))
580 }
581
582 async fn get_prompt(
583 &self,
584 request: GetPromptRequestParams,
585 context: RequestContext<RoleServer>,
586 ) -> Result<GetPromptResponse, ErrorData> {
587 let auth = require_auth_context(&self.state, &context)?;
588 let route_scope = protected_route_scope(&context);
589 let execution_context = execution_context(&self.state, &context, auth);
590 let soma_allowed = protected_scope_allows_service(route_scope, "soma");
591 if soma_allowed
592 && self.state.config().conformance_fixtures
593 && let Some(result) = conformance::get_prompt(request.clone())
594 {
595 return Ok(result.into());
596 }
597 if let Some(result) = gateway_proxy::get_prompt_for_subject_and_scope(
598 self.state.application(),
599 &request.name,
600 request.arguments.clone(),
601 GatewayMcpRoundTrip {
602 input_responses: request.input_responses.clone(),
603 request_state: request.request_state.clone(),
604 request_meta: request_meta_value(&context),
605 },
606 route_scope,
607 &execution_context,
608 )
609 .await?
610 {
611 return Ok(result);
612 }
613 if !soma_allowed {
614 return Err(ErrorData::invalid_params(
615 format!("unknown prompt: {}", request.name),
616 None,
617 ));
618 }
619 refresh_file_providers(&self.state).await?;
620 match self
621 .state
622 .application()
623 .get_prompt(&request.name, &execution_context)
624 {
625 Ok(prompt) => return Ok(prompts::provider_prompt_result(prompt).into()),
626 Err(error) if error.code == "insufficient_scope" => {
627 return Err(ErrorData::invalid_request(
628 format!("forbidden: {}", error.message),
629 None,
630 ));
631 }
632 Err(error) if error.code == "prompt_not_found" => {}
633 Err(error) => {
634 return Err(ErrorData::internal_error(error.message, None));
635 }
636 }
637 prompts::get_prompt(request)
638 .map(Into::into)
639 .map_err(|e| ErrorData::invalid_params(e.to_string(), None))
640 }
641
642 fn accepted_subscription_filter(
645 &self,
646 requested: &SubscriptionFilter,
647 ) -> Option<SubscriptionFilter> {
648 Some(requested.clone())
653 }
654
655 async fn listen(&self, context: SubscriptionContext) -> Result<(), ErrorData> {
656 require_auth_context(&self.state, context.request_context())?;
657 tracing::debug!(
658 accepted = ?context.accepted(),
659 "MCP catalog subscription established"
660 );
661 let result = catalog_subscriptions::listen_for_catalog_changes(self, context).await;
662 tracing::debug!("MCP catalog subscription closed");
663 result
664 }
665
666 async fn get_task(
669 &self,
670 request: GetTaskParams,
671 context: RequestContext<RoleServer>,
672 ) -> Result<GetTaskResult, ErrorData> {
673 let auth = require_auth_context(&self.state, &context)?;
674 let execution_context = execution_context(&self.state, &context, auth);
675 let value = self
676 .state
677 .application()
678 .gateway_get_mcp_task(&request.task_id, &execution_context)
679 .await
680 .map_err(task_application_error)?;
681 serde_json::from_value(value).map_err(|error| {
682 ErrorData::internal_error(
683 "gateway returned an invalid tasks/get result",
684 Some(serde_json::json!({"message": error.to_string()})),
685 )
686 })
687 }
688
689 async fn update_task(
690 &self,
691 request: UpdateTaskParams,
692 context: RequestContext<RoleServer>,
693 ) -> Result<(), ErrorData> {
694 let auth = require_auth_context(&self.state, &context)?;
695 let execution_context = execution_context(&self.state, &context, auth);
696 self.state
697 .application()
698 .gateway_update_mcp_task(
699 &request.task_id,
700 request.input_responses,
701 &execution_context,
702 )
703 .await
704 .map_err(task_application_error)
705 }
706
707 async fn cancel_task(
708 &self,
709 request: CancelTaskParams,
710 context: RequestContext<RoleServer>,
711 ) -> Result<(), ErrorData> {
712 let auth = require_auth_context(&self.state, &context)?;
713 let execution_context = execution_context(&self.state, &context, auth);
714 self.state
715 .application()
716 .gateway_cancel_mcp_task(&request.task_id, &execution_context)
717 .await
718 .map_err(task_application_error)
719 }
720
721 fn get_info(&self) -> ServerInfo {
724 ServerInfo::new(
725 ServerCapabilities::builder()
726 .enable_tools()
727 .enable_tool_list_changed()
728 .enable_resources()
729 .enable_resources_list_changed()
730 .enable_prompts()
731 .enable_prompts_list_changed()
732 .enable_tasks()
733 .build(),
734 )
735 .with_server_info(Implementation::new(
736 self.state.config().server_name.clone(),
737 env!("CARGO_PKG_VERSION"),
738 ))
739 .with_instructions(SERVER_INSTRUCTIONS)
740 }
741}
742
743#[cfg(test)]
744#[path = "rmcp_server_tests.rs"]
745mod tests;