1use std::collections::{BTreeMap, HashMap, HashSet};
4use std::io::{self, Write};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Weak};
7use std::task::Poll;
8use std::time::Duration;
9
10#[cfg(feature = "tasks")]
11use crate::FinalTaskRuntime;
12use crate::Session;
13use crate::auth::AuthRequest;
14use crate::bidirectional::{
15 MrtrCompletedInputs, MrtrExchangeBinding, MrtrExchangeRegistry, MrtrInputKind,
16 MrtrInputRequest, MrtrInputRequests, MrtrInputRequired, MrtrRetry,
17};
18use crate::handler::{
19 BidirectionalSenders, BoxFuture, FinalMethodOutcome, FinalResourceReadCacheHintProvenance,
20 FinalResourceUriUse, FinalToolOutcome, ProgressNotificationSender, ResourceUriUsePolicy,
21 UriParams, empty_final_result_meta, encode_final_complete_result,
22};
23use crate::handler::{
24 BoxedCompletionHandler, BoxedPromptHandler, BoxedResourceHandler, BoxedToolHandler,
25 CompletionHandler, PromptHandler, ResourceHandler, ToolErrorKind, ToolHandler,
26};
27#[cfg(all(feature = "proxy", feature = "tasks"))]
28use crate::proxy::ProxyFinalTaskRelay;
29use crate::session::SessionPrincipalBinding;
30#[cfg(test)]
31use asupersync::time::wall_now;
32use asupersync::types::Time;
33use asupersync::{Budget, Cx, Outcome};
34use base64::Engine as _;
35use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
36use fastmcp_core::logging::{debug, targets, trace};
37use fastmcp_core::{
38 McpContext, McpError, McpErrorCode, McpOutcome, McpResult, PromptCaller, PromptGetResult,
39 PromptMessageItem, PromptMessageRole, SessionState, block_on, sha256_bounded,
40};
41#[cfg(feature = "tasks")]
42use fastmcp_protocol::MissingRequiredClientCapabilityError;
43use fastmcp_protocol::common_types::{
44 AbsoluteUri, Annotations, ContentBlock, EmbeddedResourceContents, OpenMetadata, RawIcon,
45};
46#[cfg(feature = "tasks")]
47use fastmcp_protocol::extensions::OFFICIAL_TASKS_EXTENSION_ID;
48use fastmcp_protocol::methods::COMPLETION_COMPLETE;
49use fastmcp_protocol::protocol_policy::ProtocolEra;
50use fastmcp_protocol::uri_template::{ReversibleResourceTemplate, UriTemplatePart};
51use fastmcp_protocol::{
52 AdmittedSchema, CacheScope, CacheTtl, CallToolParams, CallToolResult, CompleteResult, Content,
53 CoreRequest, CoreResult, FinalCallToolParams, FinalCallToolResult, FinalCompletionParams,
54 FinalCompletionReference, FinalCompletionResult, FinalCoreRequest, FinalCoreResult,
55 FinalGetPromptParams, FinalGetPromptResult, FinalInputResponses, FinalListParams,
56 FinalListPromptsResult, FinalListResourceTemplatesResult, FinalListResourcesResult,
57 FinalListToolsResult, FinalPrompt, FinalPromptArgument, FinalReadResourceParams,
58 FinalReadResourceResult, FinalResource, FinalResourceTemplate, FinalTool, FinalToolAnnotations,
59 GetPromptParams, GetPromptResult, InitializeParams, InitializeResult, InputRequiredResult,
60 JsonRpcRequest, LegacyCompletionParams, LegacyCompletionResult, LegacyContent,
61 LegacyCoreRequest, LegacyPromptMessage, LegacyResourceContent, ListPromptsParams,
62 ListPromptsResult, ListResourceTemplatesParams, ListResourceTemplatesResult,
63 ListResourcesParams, ListResourcesResult, ListToolsParams, ListToolsResult, PROTOCOL_VERSION,
64 ProgressMarker, Prompt, PromptMessage, ReadResourceParams, ReadResourceResult, Resource,
65 ResourceContent, ResourceTemplate, Role, ServerBehavior, ServerBehaviorRegistry, TemplateValue,
66 Tool, admit_final_schema, exact_json_to_serde, validate, validate_strict,
67};
68
69pub type NotificationSender = Arc<dyn Fn(JsonRpcRequest) + Send + Sync>;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum InboundRequestTransport {
83 Stdio,
85 Http,
87 Sse,
89 WebSocket,
91 Memory,
93}
94
95#[derive(Clone, Default)]
101pub(crate) struct TransportAuthorization(Option<String>);
102
103impl TransportAuthorization {
104 #[must_use]
106 pub(crate) fn from_singleton_header(value: Option<&str>) -> Self {
107 Self(value.map(ToOwned::to_owned))
108 }
109
110 pub(crate) fn auth_request<'a>(
111 &'a self,
112 method: &'a str,
113 params: Option<&'a serde_json::Value>,
114 request_id: u64,
115 ) -> AuthRequest<'a> {
116 AuthRequest {
117 method,
118 params,
119 transport_authorization: self.0.as_deref(),
120 request_id,
121 }
122 }
123}
124
125pub struct InboundRequestContext {
134 cx: Cx,
135 request_id: u64,
136 transport: InboundRequestTransport,
137 state: Option<SessionState>,
138 mrtr_continuation_cancellation: Option<fastmcp_core::McpRequestCancellation>,
139 transport_authorization: TransportAuthorization,
140 principal_binding: Option<SessionPrincipalBinding>,
141}
142
143impl InboundRequestContext {
144 #[must_use]
147 pub fn new(cx: Cx, request_id: u64, transport: InboundRequestTransport) -> Self {
148 Self {
149 cx,
150 request_id,
151 transport,
152 state: None,
153 mrtr_continuation_cancellation: None,
154 transport_authorization: TransportAuthorization::default(),
155 principal_binding: None,
156 }
157 }
158
159 #[must_use]
164 pub(crate) fn with_modern_connection(
165 cx: Cx,
166 request_id: u64,
167 transport: InboundRequestTransport,
168 connection: &ModernConnection,
169 ) -> Self {
170 Self::with_modern_connection_and_transport_authorization(
171 cx,
172 request_id,
173 transport,
174 connection,
175 TransportAuthorization::default(),
176 )
177 }
178
179 #[must_use]
182 pub(crate) fn with_modern_connection_and_transport_authorization(
183 cx: Cx,
184 request_id: u64,
185 transport: InboundRequestTransport,
186 connection: &ModernConnection,
187 transport_authorization: TransportAuthorization,
188 ) -> Self {
189 Self::with_modern_connection_context(
190 cx,
191 request_id,
192 transport,
193 &connection.request_context(),
194 transport_authorization,
195 )
196 }
197
198 #[must_use]
199 pub(crate) fn with_modern_connection_context(
200 cx: Cx,
201 request_id: u64,
202 transport: InboundRequestTransport,
203 connection: &ModernConnectionRequestContext,
204 transport_authorization: TransportAuthorization,
205 ) -> Self {
206 Self {
207 cx,
208 request_id,
209 transport,
210 state: Some(connection.state.clone()),
211 mrtr_continuation_cancellation: Some(connection.continuation_cancellation.clone()),
212 transport_authorization,
213 principal_binding: Some(connection.principal_binding.clone()),
214 }
215 }
216
217 #[must_use]
219 pub const fn transport(&self) -> InboundRequestTransport {
220 self.transport
221 }
222
223 #[must_use]
225 pub const fn request_id(&self) -> u64 {
226 self.request_id
227 }
228
229 pub(crate) fn request_context(&self) -> McpContext {
230 self.state.clone().map_or_else(
235 || McpContext::with_state(self.cx.clone(), self.request_id, SessionState::new()),
236 |state| McpContext::with_state(self.cx.clone(), self.request_id, state),
237 )
238 }
239
240 pub(crate) fn mrtr_continuation_cancellation(
241 &self,
242 ) -> Option<fastmcp_core::McpRequestCancellation> {
243 self.mrtr_continuation_cancellation.clone()
244 }
245
246 pub(crate) fn auth_request<'a>(
247 &'a self,
248 method: &'a str,
249 params: Option<&'a serde_json::Value>,
250 ) -> AuthRequest<'a> {
251 self.transport_authorization
252 .auth_request(method, params, self.request_id)
253 }
254
255 pub(crate) fn bind_or_verify_principal(&self, fingerprint: fastmcp_core::Sha256Digest) -> bool {
256 self.principal_binding
257 .as_ref()
258 .is_none_or(|binding| binding.bind_or_verify(fingerprint))
259 }
260
261 pub(crate) fn with_cx(mut self, cx: Cx) -> Self {
262 self.cx = cx;
263 self
264 }
265}
266
267pub(crate) struct ModernConnection {
275 state: SessionState,
276 continuation_cancellation: fastmcp_core::McpRequestCancellation,
277 principal_binding: SessionPrincipalBinding,
278}
279
280#[derive(Clone)]
281pub(crate) struct ModernConnectionRequestContext {
282 state: SessionState,
283 continuation_cancellation: fastmcp_core::McpRequestCancellation,
284 principal_binding: SessionPrincipalBinding,
285}
286
287impl ModernConnection {
288 pub(crate) fn new() -> Self {
289 Self::with_state(SessionState::new())
290 }
291
292 pub(crate) fn new_request_local() -> Self {
296 Self::with_state(SessionState::ephemeral())
297 }
298
299 fn with_state(state: SessionState) -> Self {
300 Self {
301 state,
302 continuation_cancellation: fastmcp_core::McpRequestCancellation::new(),
303 principal_binding: SessionPrincipalBinding::default(),
304 }
305 }
306
307 pub(crate) fn disconnect(&self) {
308 self.continuation_cancellation.cancel();
309 }
310
311 pub(crate) fn request_context(&self) -> ModernConnectionRequestContext {
312 ModernConnectionRequestContext {
313 state: self.state.clone(),
314 continuation_cancellation: self.continuation_cancellation.clone(),
315 principal_binding: self.principal_binding.clone(),
316 }
317 }
318}
319
320impl Drop for ModernConnection {
321 fn drop(&mut self) {
322 self.disconnect();
323 }
324}
325
326#[derive(Debug, Clone, Default)]
328pub struct TagFilters<'a> {
329 pub include: Option<&'a [String]>,
331 pub exclude: Option<&'a [String]>,
333}
334
335impl<'a> TagFilters<'a> {
336 pub fn new(include: Option<&'a Vec<String>>, exclude: Option<&'a Vec<String>>) -> Self {
338 Self {
339 include: include.map(|v| v.as_slice()),
340 exclude: exclude.map(|v| v.as_slice()),
341 }
342 }
343
344 pub fn matches(&self, component_tags: &[String]) -> bool {
350 let component_tags_lower: Vec<String> =
352 component_tags.iter().map(|t| t.to_lowercase()).collect();
353
354 if let Some(include) = self.include {
356 if !include.is_empty() {
358 for tag in include {
359 let tag_lower = tag.to_lowercase();
360 if !component_tags_lower.contains(&tag_lower) {
361 return false;
362 }
363 }
364 }
365 }
366
367 if let Some(exclude) = self.exclude {
369 for tag in exclude {
370 let tag_lower = tag.to_lowercase();
371 if component_tags_lower.contains(&tag_lower) {
372 return false;
373 }
374 }
375 }
376
377 true
378 }
379}
380
381fn decode_cursor_offset(cursor: Option<&str>) -> McpResult<usize> {
382 let Some(cursor) = cursor else {
383 return Ok(0);
384 };
385
386 let decoded = BASE64_STANDARD.decode(cursor).map_err(|_| {
387 McpError::invalid_params("Invalid cursor (base64 decode failed)".to_string())
388 })?;
389 let v: serde_json::Value = serde_json::from_slice(&decoded)
390 .map_err(|_| McpError::invalid_params("Invalid cursor (JSON parse failed)".to_string()))?;
391 let offset = v
392 .get("offset")
393 .and_then(serde_json::Value::as_u64)
394 .ok_or_else(|| McpError::invalid_params("Invalid cursor (missing offset)".to_string()))?;
395
396 usize::try_from(offset)
397 .map_err(|_| McpError::invalid_params("Invalid cursor (offset too large)".to_string()))
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
408#[serde(rename_all = "snake_case")]
409enum FinalCatalogKind {
410 Tools,
411 Resources,
412 ResourceTemplates,
413 Prompts,
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
423#[serde(deny_unknown_fields)]
424struct FinalCatalogQuery {
425 include_tags: Vec<String>,
426 exclude_tags: Vec<String>,
427}
428
429impl FinalCatalogQuery {
430 fn from_final_list_params(params: &FinalListParams) -> Self {
431 Self::from_tag_filters(
432 params.include_tags.as_deref(),
433 params.exclude_tags.as_deref(),
434 )
435 }
436
437 fn from_tag_filters(include_tags: Option<&[String]>, exclude_tags: Option<&[String]>) -> Self {
438 Self {
439 include_tags: canonical_final_catalog_tags(include_tags),
440 exclude_tags: canonical_final_catalog_tags(exclude_tags),
441 }
442 }
443}
444
445fn canonical_final_catalog_tags(tags: Option<&[String]>) -> Vec<String> {
446 let Some(tags) = tags else {
447 return Vec::new();
448 };
449 let mut canonical = tags
450 .iter()
451 .map(|tag| tag.to_lowercase())
452 .collect::<Vec<_>>();
453 canonical.sort_unstable();
454 canonical.dedup();
455 canonical
456}
457
458#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
459#[serde(deny_unknown_fields)]
460struct FinalCatalogCursor {
461 catalog: FinalCatalogKind,
462 revision: u64,
463 query: FinalCatalogQuery,
464 offset: u64,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
471 visibility_digest: Option<String>,
472}
473
474fn decode_final_catalog_cursor_offset(
475 cursor: Option<&str>,
476 expected_catalog: FinalCatalogKind,
477 expected_revision: u64,
478 expected_query: &FinalCatalogQuery,
479 expected_visibility_digest: Option<&str>,
480 catalog_length: usize,
481) -> McpResult<usize> {
482 let Some(cursor) = cursor else {
483 return Ok(0);
484 };
485
486 let decoded = BASE64_STANDARD.decode(cursor).map_err(|_| {
487 McpError::invalid_params("Invalid final catalog cursor (base64 decode failed)")
488 })?;
489 let cursor = serde_json::from_slice::<FinalCatalogCursor>(&decoded).map_err(|_| {
490 McpError::invalid_params("Invalid final catalog cursor (JSON parse failed)")
491 })?;
492 if cursor.catalog != expected_catalog {
493 return Err(McpError::invalid_params(
494 "final catalog cursor belongs to another list method",
495 ));
496 }
497 if cursor.revision != expected_revision {
498 return Err(McpError::invalid_params(
499 "final catalog cursor references a stale catalog revision",
500 ));
501 }
502 if &cursor.query != expected_query {
503 return Err(McpError::invalid_params(
504 "final catalog cursor does not match the requested query filters",
505 ));
506 }
507 if cursor.visibility_digest.as_deref() != expected_visibility_digest {
508 return Err(McpError::invalid_params(
509 "final catalog cursor does not match the requested session visibility",
510 ));
511 }
512 let offset = usize::try_from(cursor.offset)
513 .map_err(|_| McpError::invalid_params("Invalid final catalog cursor (offset too large)"))?;
514 if offset >= catalog_length {
515 return Err(McpError::invalid_params(
516 "final catalog cursor offset is outside the requested catalog page",
517 ));
518 }
519 Ok(offset)
520}
521
522fn parse_stateless_params<T: serde::de::DeserializeOwned>(
523 params: Option<serde_json::Value>,
524) -> McpResult<T> {
525 let value = params.ok_or_else(|| McpError::invalid_params("Missing required parameters"))?;
526 serde_json::from_value(value).map_err(|error| McpError::invalid_params(error.to_string()))
527}
528
529fn parse_stateless_params_or_default<T: serde::de::DeserializeOwned + Default>(
530 params: Option<serde_json::Value>,
531) -> McpResult<T> {
532 match params {
533 Some(value) => serde_json::from_value(value)
534 .map_err(|error| McpError::invalid_params(error.to_string())),
535 None => Ok(T::default()),
536 }
537}
538
539fn encode_stateless_handler_result<T: serde::Serialize>(
542 result: McpResult<T>,
543) -> McpResult<serde_json::Value> {
544 encode_final_complete_result(result?)
545}
546
547fn encode_final_tools_call_result(
551 result: McpResult<CompleteResult<FinalCallToolResult>>,
552) -> McpResult<serde_json::Value> {
553 let result = result?;
554 let encoded = CoreResult::Final(FinalCoreResult::ToolsCall {
555 result,
556 diagnostic: None,
557 })
558 .encode()
559 .map_err(|error| McpError::internal_error(error.to_string()))?;
560 serde_json::from_str(&encoded).map_err(McpError::from)
561}
562
563fn encode_mrtr_input_required_result(result: MrtrInputRequired) -> McpResult<serde_json::Value> {
566 serde_json::to_value(result)
567 .map_err(|_| McpError::internal_error("failed to encode MRTR input_required result"))
568}
569
570const MAX_MRTR_BINDING_BYTES: usize = 64 * 1024;
571const MAX_MRTR_RAW_PARAMS_BYTES: usize = 256 * 1024;
572const MAX_MRTR_RAW_INPUT_RESPONSES_BYTES: usize = 192 * 1024;
573const MAX_MRTR_RAW_JSON_DEPTH: usize = 32;
574const MAX_MRTR_RAW_JSON_VALUES: usize = 4_096;
575const MRTR_REQUIRES_BOUND_MODERN_CONNECTION: &str =
576 "MRTR-capable handlers require a bound modern connection";
577
578struct MrtrRawJsonCounter {
579 max_bytes: usize,
580 bytes: usize,
581}
582
583impl Write for MrtrRawJsonCounter {
584 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
585 let remaining = self.max_bytes.saturating_sub(self.bytes);
586 if buffer.len() > remaining {
587 return Err(io::Error::other("MRTR JSON byte limit exceeded"));
588 }
589 self.bytes += buffer.len();
590 Ok(buffer.len())
591 }
592
593 fn flush(&mut self) -> io::Result<()> {
594 Ok(())
595 }
596}
597
598fn admit_mrtr_raw_json_value(value: &serde_json::Value, max_bytes: usize) -> McpResult<()> {
599 fn count_values(value: &serde_json::Value, depth: usize, values: &mut usize) -> McpResult<()> {
600 if depth > MAX_MRTR_RAW_JSON_DEPTH {
601 return Err(McpError::invalid_params(
602 "MRTR JSON exceeds its nesting limit",
603 ));
604 }
605 *values = values.saturating_add(1);
606 if *values > MAX_MRTR_RAW_JSON_VALUES {
607 return Err(McpError::invalid_params(
608 "MRTR JSON exceeds its value limit",
609 ));
610 }
611 match value {
612 serde_json::Value::Array(items) => {
613 for value in items {
614 count_values(value, depth + 1, values)?;
615 }
616 }
617 serde_json::Value::Object(members) => {
618 for value in members.values() {
619 count_values(value, depth + 1, values)?;
620 }
621 }
622 serde_json::Value::Null
623 | serde_json::Value::Bool(_)
624 | serde_json::Value::Number(_)
625 | serde_json::Value::String(_) => {}
626 }
627 Ok(())
628 }
629
630 let mut values = 0;
631 count_values(value, 0, &mut values)?;
632 let mut counter = MrtrRawJsonCounter {
633 max_bytes,
634 bytes: 0,
635 };
636 serde_json::to_writer(&mut counter, value)
637 .map_err(|_| McpError::invalid_params("MRTR JSON exceeds its byte limit"))
638}
639
640enum FinalMrtrDispatch {
641 Fresh,
642 Resume(MrtrCompletedInputs),
643 InputRequired(serde_json::Value),
644}
645
646fn mrtr_digest(value: &impl serde::Serialize) -> McpResult<[u8; 32]> {
647 let bytes = serde_json::to_vec(value)
648 .map_err(|_| McpError::invalid_params("invalid MRTR operation binding"))?;
649 let digest = sha256_bounded(&bytes, MAX_MRTR_BINDING_BYTES)
650 .map_err(|_| McpError::invalid_params("MRTR operation binding exceeds its limit"))?;
651 Ok(*digest.as_bytes())
652}
653
654fn final_mrtr_binding(
655 request_ctx: &McpContext,
656 method: &'static str,
657 target: String,
658 arguments: &impl serde::Serialize,
659) -> McpResult<Option<MrtrExchangeBinding>> {
660 if target.len() > MAX_MRTR_BINDING_BYTES {
661 return Err(McpError::invalid_params("MRTR target exceeds its limit"));
662 }
663 let Some((session_partition, _revision)) = request_ctx.session_cache_partition() else {
668 return Ok(None);
669 };
670 let principal_digest = request_ctx
671 .auth()
672 .map(|auth| {
673 auth.session_owner()
674 .map(|owner| Ok(*owner.as_bytes()))
675 .unwrap_or_else(|| mrtr_digest(&auth))
676 })
677 .transpose()?;
678 Ok(Some(MrtrExchangeBinding::new(
679 method,
680 target,
681 mrtr_digest(arguments)?,
682 session_partition,
683 principal_digest,
684 )))
685}
686
687fn handler_mrtr_input_requests(
688 request_ctx: &McpContext,
689 result: &InputRequiredResult,
690) -> McpResult<MrtrInputRequests> {
691 let Some(input_requests) = result.input_requests() else {
692 return Ok(MrtrInputRequests::default());
693 };
694 if input_requests.members().is_empty() {
695 return Err(McpError::invalid_params(
696 "MRTR inputRequests must not be empty",
697 ));
698 }
699 MrtrInputRequests::new(
700 input_requests
701 .members()
702 .iter()
703 .map(|member| {
704 let value = exact_json_to_serde(&member.value)
705 .map_err(|_| McpError::invalid_params("invalid MRTR input request"))?;
706 let request = MrtrInputRequest::from_wire(&value)?;
707 if request.kind() == MrtrInputKind::Sampling
708 && !request_ctx.client_supports_sampling()
709 {
710 return Err(McpError::invalid_request(
711 "Final sampling is not advertised by the client",
712 ));
713 }
714 Ok((member.name.clone(), request))
715 })
716 .collect::<McpResult<Vec<_>>>()?,
717 )
718}
719
720#[cfg(feature = "tasks")]
721fn encode_final_task_result(
722 result: fastmcp_protocol::tasks_extension::CreateTaskResult,
723) -> McpResult<serde_json::Value> {
724 let encoded = CoreResult::Final(FinalCoreResult::ToolsCallTask { result })
725 .encode()
726 .map_err(|error| McpError::internal_error(error.to_string()))?;
727 serde_json::from_str(&encoded).map_err(McpError::from)
728}
729
730#[cfg(feature = "tasks")]
731fn require_final_tasks_capability(metadata: &OpenMetadata) -> McpResult<()> {
732 let declared = metadata
733 .client_capabilities()
734 .map_err(|_| McpError::invalid_params("invalid final client capabilities"))?
735 .and_then(|capabilities| capabilities.get("extensions"))
736 .and_then(serde_json::Value::as_object)
737 .and_then(|extensions| extensions.get(OFFICIAL_TASKS_EXTENSION_ID))
738 .and_then(serde_json::Value::as_object)
739 .is_some_and(serde_json::Map::is_empty);
740 if declared {
741 return Ok(());
742 }
743
744 let mut extensions = serde_json::Map::new();
745 extensions.insert(
746 OFFICIAL_TASKS_EXTENSION_ID.to_owned(),
747 serde_json::json!({}),
748 );
749 let missing = MissingRequiredClientCapabilityError::new(serde_json::json!({
750 "extensions": serde_json::Value::Object(extensions)
751 }))
752 .map_err(|_| McpError::internal_error("failed to encode required Tasks capability"))?;
753 Err(McpError::with_data(
754 McpErrorCode::Custom(missing.jsonrpc_error_code()),
755 "Required client capability is missing",
756 missing.canonical_error_data(),
757 ))
758}
759
760#[derive(Clone)]
767struct FinalToolSchemas {
768 input: Option<AdmittedSchema>,
769 output: Option<AdmittedSchema>,
770 errors: Option<FinalToolErrorStructuredContent>,
771}
772
773#[derive(Clone)]
774struct FinalToolErrorStructuredContent {
775 input_validation: serde_json::Value,
776 handler: serde_json::Value,
777}
778
779const MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES: usize = 16 * 1024;
780
781fn admit_final_tool_error_structured_content<H: ToolHandler + ?Sized>(
782 handler: &H,
783 output: &AdmittedSchema,
784 kind: ToolErrorKind,
785) -> McpResult<serde_json::Value> {
786 let mapped = crate::catch_extension_unwind(|| {
787 handler.final_tool_error_structured_content(kind)
788 })
789 .map_err(|_payload| {
790 McpError::internal_error("tool error structured-content mapper panicked during admission")
791 })?
792 .ok_or_else(|| {
793 McpError::internal_error(
794 "tool declares outputSchema without a complete tool-error structured-content mapper",
795 )
796 })?;
797 let encoded = serde_json::to_vec(&mapped).map_err(|_error| {
798 McpError::internal_error("tool error structured-content mapper returned invalid JSON")
799 })?;
800 if encoded.len() > MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES {
801 return Err(McpError::internal_error(
802 "tool error structured-content mapper exceeded the registration limit",
803 ));
804 }
805 if output.validate(&mapped).is_err() {
806 return Err(McpError::internal_error(
807 "tool error structured-content mapper does not satisfy outputSchema",
808 ));
809 }
810 Ok(mapped)
811}
812
813fn admit_final_tool_schemas<H: ToolHandler + ?Sized>(
814 upstream_schema_registered: bool,
815 input_schema: &serde_json::Value,
816 output_schema: Option<&serde_json::Value>,
817 handler: &H,
818) -> McpResult<FinalToolSchemas> {
819 if upstream_schema_registered {
820 return Ok(FinalToolSchemas {
825 input: None,
826 output: None,
827 errors: None,
828 });
829 }
830 if !input_schema.is_object() {
831 return Err(McpError::internal_error(
832 "tool declares a final input schema that is not an object",
833 ));
834 }
835 if input_schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
836 return Err(McpError::internal_error(
837 "tool declares a final input schema without type object",
838 ));
839 }
840 let input = admit_final_schema(input_schema.clone()).map_err(|_error| {
841 McpError::internal_error("tool declares an invalid final input schema")
842 })?;
843 let output = output_schema
844 .cloned()
845 .map(|schema| {
846 if !schema.is_object() {
847 return Err(McpError::internal_error(
848 "tool declares a final output schema that is not an object",
849 ));
850 }
851 admit_final_schema(schema).map_err(|_error| {
852 McpError::internal_error("tool declares an invalid final output schema")
853 })
854 })
855 .transpose()?;
856 let errors = output
857 .as_ref()
858 .map(|output| -> McpResult<FinalToolErrorStructuredContent> {
859 Ok(FinalToolErrorStructuredContent {
860 input_validation: admit_final_tool_error_structured_content(
861 handler,
862 output,
863 ToolErrorKind::InputValidation,
864 )?,
865 handler: admit_final_tool_error_structured_content(
866 handler,
867 output,
868 ToolErrorKind::Handler,
869 )?,
870 })
871 })
872 .transpose()?;
873 Ok(FinalToolSchemas {
874 input: Some(input),
875 output,
876 errors,
877 })
878}
879
880struct AdmittedToolRegistration {
883 handler: BoxedToolHandler,
884 definition: Tool,
885 final_registration: Option<AdmittedFinalToolRegistration>,
886 legacy_enabled: bool,
887}
888
889struct AdmittedFinalToolRegistration {
890 final_definition: FinalTool,
891 schemas: FinalToolSchemas,
892 declares_final_tasks: bool,
893}
894
895struct AdmittedFinalResourceRegistration {
898 definition: FinalResource,
899 tags: Vec<String>,
900 uri_use_policy: ResourceUriUsePolicy,
901}
902
903struct AdmittedFinalPromptRegistration {
906 definition: FinalPrompt,
907 tags: Vec<String>,
908 uri_use_policy: ResourceUriUsePolicy,
909}
910
911impl AdmittedToolRegistration {
912 fn admit<H: ToolHandler + 'static>(
913 handler: H,
914 definition: Tool,
915 legacy_enabled: bool,
916 ) -> McpResult<Self> {
917 let (exact_final_definition, declares_final_tasks, upstream_schema_registered) =
918 crate::catch_extension_unwind(|| {
919 (
920 handler.final_definition(),
921 handler.declares_final_tasks(),
922 handler.upstream_final_tool_schema_registration().is_some(),
923 )
924 })
925 .map_err(|_payload| {
926 McpError::internal_error("tool metadata hook panicked during admission")
927 })?;
928 let final_definition = match exact_final_definition {
929 Some(definition) => definition,
930 None => {
931 let (title, icons, metadata) = crate::catch_extension_unwind(|| {
932 (
933 handler.final_title().map(str::to_owned),
934 handler.final_icons().map(|icons| icons.to_vec()),
935 handler.final_metadata().cloned(),
936 )
937 })
938 .map_err(|_payload| {
939 McpError::internal_error("tool metadata hook panicked during admission")
940 })?;
941 FinalTool {
942 name: definition.name.clone(),
943 title,
944 description: definition.description.clone(),
945 input_schema: definition.input_schema.clone(),
946 output_schema: definition.output_schema.clone(),
947 annotations: definition
948 .annotations
949 .clone()
950 .map(project_final_tool_annotations),
951 icons,
952 meta: metadata,
953 }
954 }
955 };
956 if final_definition.name != definition.name {
957 return Err(McpError::internal_error(
958 "tool's exact final definition name differs from its legacy definition name",
959 ));
960 }
961 let schemas = admit_final_tool_schemas(
962 upstream_schema_registered,
963 &final_definition.input_schema,
964 final_definition.output_schema.as_ref(),
965 &handler,
966 )?;
967 Ok(Self {
968 handler: Box::new(handler),
969 definition,
970 final_registration: Some(AdmittedFinalToolRegistration {
971 final_definition,
972 schemas,
973 declares_final_tasks,
974 }),
975 legacy_enabled,
976 })
977 }
978
979 fn legacy_only<H: ToolHandler + 'static>(handler: H, definition: Tool) -> Self {
980 Self {
981 handler: Box::new(handler),
982 definition,
983 final_registration: None,
984 legacy_enabled: true,
985 }
986 }
987
988 fn with_mounted_name(self, mounted_name: String) -> Self {
989 use crate::handler::MountedToolHandler;
990
991 let Self {
992 handler,
993 mut definition,
994 mut final_registration,
995 legacy_enabled,
996 } = self;
997 definition.name.clone_from(&mounted_name);
998 if let Some(final_registration) = final_registration.as_mut() {
999 final_registration
1000 .final_definition
1001 .name
1002 .clone_from(&mounted_name);
1003 }
1004 Self {
1005 handler: Box::new(MountedToolHandler::new(handler, mounted_name)),
1006 definition,
1007 final_registration,
1008 legacy_enabled,
1009 }
1010 }
1011}
1012
1013fn encode_final_resources_read_result(
1018 result: McpResult<CompleteResult<FinalReadResourceResult>>,
1019) -> McpResult<serde_json::Value> {
1020 let result = result?;
1021 let encoded = CoreResult::Final(FinalCoreResult::ResourcesRead {
1022 result,
1023 diagnostic: None,
1024 })
1025 .encode()
1026 .map_err(|error| McpError::internal_error(error.to_string()))?;
1027 serde_json::from_str(&encoded).map_err(McpError::from)
1028}
1029
1030fn encode_final_prompts_get_result(
1034 result: McpResult<CompleteResult<FinalGetPromptResult>>,
1035) -> McpResult<serde_json::Value> {
1036 let result = result?;
1037 let encoded = CoreResult::Final(FinalCoreResult::PromptsGet {
1038 result,
1039 diagnostic: None,
1040 })
1041 .encode()
1042 .map_err(|error| McpError::internal_error(error.to_string()))?;
1043 serde_json::from_str(&encoded).map_err(McpError::from)
1044}
1045
1046#[derive(Clone, Debug, PartialEq, Eq)]
1047struct FinalCacheHintPolicy {
1048 list_ttl_ms: CacheTtl,
1049 resource_read_ttl_ms: CacheTtl,
1050 scope: CacheScope,
1051}
1052
1053impl Default for FinalCacheHintPolicy {
1054 fn default() -> Self {
1055 Self {
1056 list_ttl_ms: CacheTtl::milliseconds(5 * 60 * 1_000),
1057 resource_read_ttl_ms: CacheTtl::milliseconds(60 * 60 * 1_000),
1058 scope: CacheScope::Private,
1059 }
1060 }
1061}
1062
1063fn encode_final_core_result<T>(
1067 result: McpResult<T>,
1068 select: impl FnOnce(CompleteResult<T>) -> FinalCoreResult,
1069) -> McpResult<serde_json::Value> {
1070 let result = CompleteResult::new(result?, empty_final_result_meta()?);
1071 let encoded = CoreResult::Final(select(result))
1072 .encode()
1073 .map_err(|error| McpError::internal_error(error.to_string()))?;
1074 serde_json::from_str(&encoded).map_err(McpError::from)
1075}
1076
1077fn project_final_tool_annotations(
1078 annotations: fastmcp_protocol::ToolAnnotations,
1079) -> FinalToolAnnotations {
1080 FinalToolAnnotations {
1081 title: None,
1082 destructive: annotations.destructive,
1083 idempotent: annotations.idempotent,
1084 read_only: annotations.read_only,
1085 open_world_hint: annotations.open_world_hint,
1086 }
1087}
1088
1089fn project_final_resource_catalog_entry(
1090 resource: Resource,
1091 title: Option<String>,
1092 icons: Option<Vec<RawIcon>>,
1093 annotations: Option<Annotations>,
1094 meta: Option<OpenMetadata>,
1095) -> McpResult<FinalResource> {
1096 let uri = AbsoluteUri::parse(resource.uri).map_err(|error| {
1097 McpError::internal_error(format!(
1098 "legacy resource URI cannot be projected into the final catalog: {error}",
1099 ))
1100 })?;
1101 Ok(FinalResource {
1102 uri,
1103 name: resource.name,
1104 title,
1105 description: resource.description,
1106 icons,
1107 mime_type: resource.mime_type,
1108 size: None,
1109 annotations,
1110 meta,
1111 })
1112}
1113
1114const FINAL_RESOURCE_URI_USE_REJECTED: &str =
1115 "final resource URI is not admitted for this local use site";
1116const FINAL_RESOURCE_URI_USE_EMISSION_REJECTED: &str =
1117 "handler emitted a final resource URI that is not admitted for this local use site";
1118
1119fn admit_final_resource_uri(
1120 policy: ResourceUriUsePolicy,
1121 uri: &AbsoluteUri,
1122 use_site: FinalResourceUriUse,
1123) -> McpResult<()> {
1124 policy
1125 .admits(uri, use_site)
1126 .then_some(())
1127 .ok_or_else(|| McpError::invalid_params(FINAL_RESOURCE_URI_USE_REJECTED))
1128}
1129
1130fn admit_final_resource_template_uri(
1131 policy: ResourceUriUsePolicy,
1132 uri_template: &str,
1133) -> McpResult<()> {
1134 policy
1135 .admits_template(uri_template)
1136 .then_some(())
1137 .ok_or_else(|| McpError::invalid_params(FINAL_RESOURCE_URI_USE_REJECTED))
1138}
1139
1140fn enforce_final_resource_uri_emission(
1141 policy: ResourceUriUsePolicy,
1142 uri: &AbsoluteUri,
1143 use_site: FinalResourceUriUse,
1144) -> McpResult<()> {
1145 policy
1146 .admits(uri, use_site)
1147 .then_some(())
1148 .ok_or_else(|| McpError::internal_error(FINAL_RESOURCE_URI_USE_EMISSION_REJECTED))
1149}
1150
1151fn embedded_resource_uri(contents: &EmbeddedResourceContents) -> &AbsoluteUri {
1152 match contents {
1153 EmbeddedResourceContents::Text { uri, .. } | EmbeddedResourceContents::Blob { uri, .. } => {
1154 uri
1155 }
1156 }
1157}
1158
1159fn admit_final_resource_read_outcome(
1160 policy: ResourceUriUsePolicy,
1161 outcome: &FinalMethodOutcome<FinalReadResourceResult>,
1162) -> McpResult<()> {
1163 let FinalMethodOutcome::Complete(result) = outcome else {
1164 return Ok(());
1165 };
1166 for contents in &result.payload.contents {
1167 enforce_final_resource_uri_emission(
1168 policy,
1169 embedded_resource_uri(contents),
1170 FinalResourceUriUse::ResourceReadContents,
1171 )?;
1172 }
1173 Ok(())
1174}
1175
1176fn admit_final_prompt_content(
1177 policy: ResourceUriUsePolicy,
1178 content: &fastmcp_protocol::common_types::ContentBlock,
1179) -> McpResult<()> {
1180 match content {
1181 fastmcp_protocol::common_types::ContentBlock::ResourceLink { uri, .. } => {
1182 enforce_final_resource_uri_emission(
1183 policy,
1184 uri,
1185 FinalResourceUriUse::PromptResourceLink,
1186 )
1187 }
1188 fastmcp_protocol::common_types::ContentBlock::Resource { resource, .. } => {
1189 enforce_final_resource_uri_emission(
1190 policy,
1191 embedded_resource_uri(resource),
1192 FinalResourceUriUse::PromptEmbeddedResource,
1193 )
1194 }
1195 fastmcp_protocol::common_types::ContentBlock::Text { .. }
1196 | fastmcp_protocol::common_types::ContentBlock::Image { .. }
1197 | fastmcp_protocol::common_types::ContentBlock::Audio { .. } => Ok(()),
1198 }
1199}
1200
1201fn admit_declared_final_prompt_arguments<S: AsRef<str>>(
1202 declared: &[FinalPromptArgument],
1203 supplied: impl IntoIterator<Item = S>,
1204) -> McpResult<()> {
1205 let supplied: HashSet<String> = supplied
1206 .into_iter()
1207 .map(|name| name.as_ref().to_owned())
1208 .collect();
1209 if declared
1210 .iter()
1211 .any(|argument| argument.required == Some(true) && !supplied.contains(&argument.name))
1212 {
1213 return Err(McpError::invalid_params("Missing required prompt argument"));
1214 }
1215 if supplied
1216 .iter()
1217 .any(|name| !declared.iter().any(|argument| &argument.name == name))
1218 {
1219 return Err(McpError::invalid_params("Unknown prompt argument"));
1220 }
1221 Ok(())
1222}
1223
1224fn admit_final_prompt_outcome(
1225 policy: ResourceUriUsePolicy,
1226 outcome: &FinalMethodOutcome<FinalGetPromptResult>,
1227) -> McpResult<()> {
1228 let FinalMethodOutcome::Complete(result) = outcome else {
1229 return Ok(());
1230 };
1231 for message in &result.payload.messages {
1232 admit_final_prompt_content(policy, &message.content)?;
1233 }
1234 Ok(())
1235}
1236
1237fn admit_final_resource_definition<H: ResourceHandler + ?Sized>(
1242 handler: &H,
1243 resource: &Resource,
1244 uri_use_policy: ResourceUriUsePolicy,
1245) -> McpResult<FinalResource> {
1246 if let Some(definition) =
1247 crate::catch_extension_unwind(|| handler.final_definition()).map_err(|_payload| {
1248 McpError::internal_error("resource final-definition hook panicked during admission")
1249 })?
1250 {
1251 if definition.uri.as_str() != resource.uri {
1252 return Err(McpError::internal_error(
1253 "resource exact final URI differs from its legacy definition URI",
1254 ));
1255 }
1256 admit_final_resource_uri(
1257 uri_use_policy,
1258 &definition.uri,
1259 FinalResourceUriUse::CatalogResource,
1260 )?;
1261 return Ok(definition);
1262 }
1263 let (title, icons, annotations, meta) = crate::catch_extension_unwind(|| {
1264 (
1265 handler.final_title().map(str::to_owned),
1266 handler.final_icons().map(|icons| icons.to_vec()),
1267 handler.final_annotations().cloned(),
1268 handler.final_metadata().cloned(),
1269 )
1270 })
1271 .map_err(|_payload| {
1272 McpError::internal_error("resource metadata hook panicked during admission")
1273 })?;
1274 let definition =
1275 project_final_resource_catalog_entry(resource.clone(), title, icons, annotations, meta)?;
1276 admit_final_resource_uri(
1277 uri_use_policy,
1278 &definition.uri,
1279 FinalResourceUriUse::CatalogResource,
1280 )?;
1281 Ok(definition)
1282}
1283
1284fn admit_final_resource_template_definition<H: ResourceHandler + ?Sized>(
1286 handler: Option<&H>,
1287 template: &ResourceTemplate,
1288 uri_use_policy: ResourceUriUsePolicy,
1289) -> McpResult<FinalResourceTemplate> {
1290 if let Some(handler) = handler {
1291 if let Some(definition) =
1292 crate::catch_extension_unwind(|| handler.final_template_definition()).map_err(
1293 |_payload| {
1294 McpError::internal_error(
1295 "resource final-template-definition hook panicked during admission",
1296 )
1297 },
1298 )?
1299 {
1300 if definition.uri_template != template.uri_template {
1301 return Err(McpError::internal_error(
1302 "resource exact final template differs from its legacy template URI",
1303 ));
1304 }
1305 admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
1306 return Ok(definition);
1307 }
1308 }
1309 let (title, icons, annotations, meta) = match handler {
1310 Some(handler) => crate::catch_extension_unwind(|| {
1311 (
1312 handler.final_template_title().map(str::to_owned),
1313 handler.final_template_icons().map(|icons| icons.to_vec()),
1314 handler.final_template_annotations().cloned(),
1315 handler.final_template_metadata().cloned(),
1316 )
1317 })
1318 .map_err(|_payload| {
1319 McpError::internal_error("resource template metadata hook panicked during admission")
1320 })?,
1321 None => (None, None, None, None),
1322 };
1323 let definition = FinalResourceTemplate {
1324 uri_template: template.uri_template.clone(),
1325 name: template.name.clone(),
1326 title,
1327 description: template.description.clone(),
1328 icons,
1329 mime_type: template.mime_type.clone(),
1330 annotations,
1331 meta,
1332 };
1333 admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
1334 Ok(definition)
1335}
1336
1337fn admit_final_prompt_definition<H: PromptHandler + ?Sized>(
1339 handler: &H,
1340 prompt: &Prompt,
1341) -> McpResult<FinalPrompt> {
1342 if let Some(definition) =
1343 crate::catch_extension_unwind(|| handler.final_definition()).map_err(|_payload| {
1344 McpError::internal_error("prompt final-definition hook panicked during admission")
1345 })?
1346 {
1347 if definition.name != prompt.name {
1348 return Err(McpError::internal_error(
1349 "prompt exact final name differs from its legacy definition name",
1350 ));
1351 }
1352 return Ok(definition);
1353 }
1354 let (title, icons, meta) = crate::catch_extension_unwind(|| {
1355 (
1356 handler.final_title().map(str::to_owned),
1357 handler.final_icons().map(|icons| icons.to_vec()),
1358 handler.final_metadata().cloned(),
1359 )
1360 })
1361 .map_err(|_payload| {
1362 McpError::internal_error("prompt metadata hook panicked during admission")
1363 })?;
1364 let arguments = (!prompt.arguments.is_empty()).then(|| {
1365 prompt
1366 .arguments
1367 .iter()
1368 .map(|argument| FinalPromptArgument {
1369 name: argument.name.clone(),
1370 title: None,
1371 description: argument.description.clone(),
1372 required: Some(argument.required),
1373 })
1374 .collect()
1375 });
1376 Ok(FinalPrompt {
1377 name: prompt.name.clone(),
1378 title,
1379 description: prompt.description.clone(),
1380 icons,
1381 arguments,
1382 meta,
1383 })
1384}
1385
1386fn legacy_content_from_handler(content: Content) -> McpResult<LegacyContent> {
1392 match content {
1393 Content::Text { text } => Ok(LegacyContent::Text {
1394 text,
1395 annotations: None,
1396 additional: BTreeMap::new(),
1397 }),
1398 Content::Image { data, mime_type } => Ok(LegacyContent::Image {
1399 data,
1400 mime_type,
1401 annotations: None,
1402 additional: BTreeMap::new(),
1403 }),
1404 Content::Resource { resource } => Ok(LegacyContent::Resource {
1405 resource: legacy_resource_content_from_handler(resource)?,
1406 annotations: None,
1407 additional: BTreeMap::new(),
1408 }),
1409 Content::Audio { .. } => Err(McpError::internal_error(
1410 "legacy 2024 result content does not support audio",
1411 )),
1412 }
1413}
1414
1415fn legacy_contents_from_handler(content: Vec<Content>) -> McpResult<Vec<LegacyContent>> {
1416 content
1417 .into_iter()
1418 .map(legacy_content_from_handler)
1419 .collect()
1420}
1421
1422fn legacy_resource_content_from_handler(
1424 resource: ResourceContent,
1425) -> McpResult<LegacyResourceContent> {
1426 match (resource.text, resource.blob) {
1427 (Some(text), None) => Ok(LegacyResourceContent::Text {
1428 uri: resource.uri,
1429 text,
1430 mime_type: resource.mime_type,
1431 additional: BTreeMap::new(),
1432 }),
1433 (None, Some(blob)) => Ok(LegacyResourceContent::Blob {
1434 uri: resource.uri,
1435 blob,
1436 mime_type: resource.mime_type,
1437 additional: BTreeMap::new(),
1438 }),
1439 _ => Err(McpError::internal_error(
1440 "legacy 2024 resource content requires exactly one text or blob payload",
1441 )),
1442 }
1443}
1444
1445fn legacy_resource_contents_from_handler(
1446 contents: Vec<ResourceContent>,
1447) -> McpResult<Vec<LegacyResourceContent>> {
1448 contents
1449 .into_iter()
1450 .map(legacy_resource_content_from_handler)
1451 .collect()
1452}
1453
1454fn legacy_prompt_messages_from_handler(
1455 messages: Vec<PromptMessage>,
1456) -> McpResult<Vec<LegacyPromptMessage>> {
1457 messages
1458 .into_iter()
1459 .map(|PromptMessage { role, content }| {
1460 Ok(LegacyPromptMessage {
1461 role,
1462 content: legacy_content_from_handler(content)?,
1463 additional: BTreeMap::new(),
1464 })
1465 })
1466 .collect()
1467}
1468
1469fn promote_legacy_resource_content(
1475 resource: LegacyResourceContent,
1476) -> McpResult<EmbeddedResourceContents> {
1477 let (uri, content, mime_type, additional) = match resource {
1478 LegacyResourceContent::Text {
1479 uri,
1480 text,
1481 mime_type,
1482 additional,
1483 } => (
1484 uri,
1485 LegacyEmbeddedContent::Text(text),
1486 mime_type,
1487 additional,
1488 ),
1489 LegacyResourceContent::Blob {
1490 uri,
1491 blob,
1492 mime_type,
1493 additional,
1494 } => (
1495 uri,
1496 LegacyEmbeddedContent::Blob(blob),
1497 mime_type,
1498 additional,
1499 ),
1500 };
1501 let uri = AbsoluteUri::parse(uri).map_err(|error| {
1502 McpError::internal_error(format!(
1503 "legacy resource content cannot be projected into the final result: {error}",
1504 ))
1505 })?;
1506
1507 match content {
1508 LegacyEmbeddedContent::Text(text) => Ok(EmbeddedResourceContents::Text {
1509 uri,
1510 text,
1511 mime_type,
1512 meta: None,
1513 additional,
1514 }),
1515 LegacyEmbeddedContent::Blob(blob) => Ok(EmbeddedResourceContents::Blob {
1516 uri,
1517 blob,
1518 mime_type,
1519 meta: None,
1520 additional,
1521 }),
1522 }
1523}
1524
1525enum LegacyEmbeddedContent {
1526 Text(String),
1527 Blob(String),
1528}
1529
1530fn legacy_read_resource_params(params: FinalReadResourceParams) -> ReadResourceParams {
1531 ReadResourceParams {
1532 uri: params.uri.as_str().to_owned(),
1533 meta: None,
1534 }
1535}
1536
1537fn encode_cursor_offset(offset: usize) -> String {
1538 let payload = serde_json::json!({ "offset": offset });
1539 let bytes = serde_json::to_vec(&payload).expect("cursor state must serialize");
1540 BASE64_STANDARD.encode(bytes)
1541}
1542
1543fn encode_final_catalog_cursor(
1544 catalog: FinalCatalogKind,
1545 revision: u64,
1546 query: &FinalCatalogQuery,
1547 offset: usize,
1548 visibility_digest: Option<&str>,
1549) -> String {
1550 let offset = u64::try_from(offset).expect("usize cursor offsets always fit u64");
1551 let payload = FinalCatalogCursor {
1552 catalog,
1553 revision,
1554 query: query.clone(),
1555 offset,
1556 visibility_digest: visibility_digest.map(str::to_owned),
1557 };
1558 let bytes = serde_json::to_vec(&payload).expect("final catalog cursor must serialize");
1559 BASE64_STANDARD.encode(bytes)
1560}
1561
1562const MAX_CATALOG_VISIBILITY_DIGEST_BYTES: usize = 256 * 1024;
1563
1564fn catalog_visibility_digest<'a, I>(keys: I) -> McpResult<String>
1565where
1566 I: IntoIterator<Item = &'a str>,
1567{
1568 let mut material = Vec::new();
1569 for key in keys {
1570 let bytes = key.as_bytes();
1571 let len = u64::try_from(bytes.len()).map_err(|_| {
1572 McpError::internal_error("catalog visibility key exceeds digest length encoding")
1573 })?;
1574 material.extend_from_slice(&len.to_le_bytes());
1575 material.extend_from_slice(bytes);
1576 }
1577 let digest = sha256_bounded(&material, MAX_CATALOG_VISIBILITY_DIGEST_BYTES)
1578 .map_err(|_| McpError::internal_error("catalog visibility digest input too large"))?;
1579 Ok(BASE64_STANDARD.encode(digest.as_bytes()))
1580}
1581
1582fn page_final_catalog<T: Clone>(
1590 items: Vec<T>,
1591 cursor: Option<&str>,
1592 page_size: Option<usize>,
1593 catalog: FinalCatalogKind,
1594 revision: u64,
1595 query: &FinalCatalogQuery,
1596 visibility_digest: Option<&str>,
1597) -> McpResult<(Vec<T>, Option<String>)> {
1598 let offset = decode_final_catalog_cursor_offset(
1599 cursor,
1600 catalog,
1601 revision,
1602 query,
1603 visibility_digest,
1604 items.len(),
1605 )?;
1606 let Some(page_size) = page_size else {
1607 return Ok((items, None));
1608 };
1609 let end = offset.saturating_add(page_size).min(items.len());
1610 Ok((
1611 items.get(offset..end).unwrap_or_default().to_vec(),
1612 (end < items.len())
1613 .then(|| encode_final_catalog_cursor(catalog, revision, query, end, visibility_digest)),
1614 ))
1615}
1616
1617fn page_session_filtered_catalog<T: Clone>(
1618 items: Vec<T>,
1619 cursor: Option<&str>,
1620 page_size: Option<usize>,
1621 catalog: FinalCatalogKind,
1622 revision: u64,
1623 query: &FinalCatalogQuery,
1624 bind_session_visibility: bool,
1625 key: impl Fn(&T) -> &str,
1626) -> McpResult<(Vec<T>, Option<String>)> {
1627 let visibility_digest = bind_session_visibility
1628 .then(|| catalog_visibility_digest(items.iter().map(key)))
1629 .transpose()?;
1630 page_final_catalog(
1631 items,
1632 cursor,
1633 page_size,
1634 catalog,
1635 revision,
1636 query,
1637 visibility_digest.as_deref(),
1638 )
1639}
1640
1641const SANITIZED_HANDLER_PANIC_MESSAGE: &str = "Internal server error";
1642static NEXT_HANDLER_INCIDENT_ID: AtomicU64 = AtomicU64::new(1);
1643
1644const LOG_LABEL_HASH_INPUT_LIMIT: usize = 4 * 1024;
1651const LOG_LABEL_DIGEST_PREFIX_BYTES: usize = 8;
1652
1653#[derive(Clone, Copy)]
1654struct SafeLogLabel {
1655 byte_len: usize,
1656 hashed_bytes: usize,
1657 digest_prefix: [u8; LOG_LABEL_DIGEST_PREFIX_BYTES],
1658}
1659
1660impl std::fmt::Display for SafeLogLabel {
1661 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1662 write!(f, "bytes={},sha256_prefix=", self.byte_len)?;
1663 for byte in self.digest_prefix {
1664 write!(f, "{byte:02x}")?;
1665 }
1666 if self.hashed_bytes < self.byte_len {
1667 write!(f, ",hashed_prefix_bytes={}", self.hashed_bytes)?;
1668 }
1669 Ok(())
1670 }
1671}
1672
1673impl std::fmt::Debug for SafeLogLabel {
1674 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1675 std::fmt::Display::fmt(self, f)
1676 }
1677}
1678
1679fn safe_log_label(value: &str) -> SafeLogLabel {
1680 let bytes = value.as_bytes();
1681 let hashed_bytes = bytes.len().min(LOG_LABEL_HASH_INPUT_LIMIT);
1682 let bounded_prefix = &bytes[..hashed_bytes];
1683 let mut digest_prefix = [0_u8; LOG_LABEL_DIGEST_PREFIX_BYTES];
1684 if let Ok(digest) = sha256_bounded(bounded_prefix, LOG_LABEL_HASH_INPUT_LIMIT) {
1685 digest_prefix.copy_from_slice(&digest.as_bytes()[..LOG_LABEL_DIGEST_PREFIX_BYTES]);
1686 }
1687
1688 SafeLogLabel {
1689 byte_len: bytes.len(),
1690 hashed_bytes,
1691 digest_prefix,
1692 }
1693}
1694
1695fn duplicate_registration_error(component: &'static str, key: &str) -> McpError {
1696 McpError::invalid_request(format!(
1697 "{component} already exists; component_key={}",
1698 safe_log_label(key)
1699 ))
1700}
1701
1702pub(crate) fn is_duplicate_registration_error(error: &McpError) -> bool {
1709 error.code == McpErrorCode::InvalidRequest
1710 && error.message.contains(" already exists; component_key=")
1711}
1712
1713fn compose_handler_budget(
1714 ambient: Budget,
1715 server_or_request: Budget,
1716 handler_timeout: Option<Duration>,
1717 now: Time,
1718) -> Budget {
1719 let inherited = ambient.meet(server_or_request);
1720 match handler_timeout {
1721 Some(timeout) if !timeout.is_zero() => inherited.tightened_by_timeout(now, timeout),
1722 Some(_) | None => inherited,
1723 }
1724}
1725
1726fn budget_error(ctx: &McpContext) -> Option<McpError> {
1727 if ctx.ensure_live().is_err() {
1728 return Some(McpError::request_cancelled());
1729 }
1730 None
1731}
1732
1733fn sanitized_handler_panic(_request_lifetime: &Cx, handler_class: &'static str) -> McpError {
1734 let incident_id = NEXT_HANDLER_INCIDENT_ID.fetch_add(1, Ordering::Relaxed);
1735 log::error!(
1736 target: "fastmcp_rust::handler",
1737 "handler terminated unexpectedly; incident_id={incident_id}; class={handler_class}; detail=panic_payload_redacted"
1738 );
1739 McpError::internal_error(SANITIZED_HANDLER_PANIC_MESSAGE)
1740}
1741
1742fn sanitized_handler_internal_error(
1743 _request_lifetime: &Cx,
1744 handler_class: &'static str,
1745) -> McpError {
1746 let incident_id = NEXT_HANDLER_INCIDENT_ID.fetch_add(1, Ordering::Relaxed);
1747 log::error!(
1748 target: "fastmcp_rust::handler",
1749 "handler returned an opaque internal failure; incident_id={incident_id}; class={handler_class}; detail=internal_error_redacted"
1750 );
1751 McpError::internal_error(SANITIZED_HANDLER_PANIC_MESSAGE)
1752}
1753
1754fn sanitize_handler_error(cx: &Cx, handler_class: &'static str, error: McpError) -> McpError {
1755 if error.code == McpErrorCode::InternalError {
1756 sanitized_handler_internal_error(cx, handler_class)
1757 } else {
1758 error
1759 }
1760}
1761
1762const fn is_framework_terminal_tool_error(code: McpErrorCode) -> bool {
1763 matches!(
1764 code,
1765 McpErrorCode::InternalError | McpErrorCode::RequestCancelled
1766 )
1767}
1768
1769fn read_handler_timeout(
1770 cx: &Cx,
1771 handler_class: &'static str,
1772 read: impl FnOnce() -> Option<Duration>,
1773) -> McpResult<Option<Duration>> {
1774 crate::catch_extension_unwind(read)
1775 .map_err(|_payload| sanitized_handler_panic(cx, handler_class))
1776}
1777
1778#[allow(
1779 dead_code,
1780 reason = "retained as the blocking dispatcher if a remaining session entry cannot yet take a request-owned child Cx"
1781)]
1782fn run_handler<'a, T>(
1783 ctx: &McpContext,
1784 budget: Budget,
1785 handler_class: &'static str,
1786 make_future: impl FnOnce() -> BoxFuture<'a, McpOutcome<T>>,
1787) -> McpResult<McpOutcome<T>> {
1788 if let Some(error) = budget_error(ctx) {
1789 return Err(error);
1790 }
1791
1792 let execution = crate::catch_extension_unwind(|| {
1793 let future = make_future();
1794 match budget.deadline {
1795 Some(deadline) => block_on(async move {
1796 asupersync::time::timeout_at(deadline, future)
1797 .await
1798 .map_err(|_elapsed| ())
1799 }),
1800 None => Ok(block_on(future)),
1801 }
1802 });
1803
1804 match execution {
1805 Err(_payload) => Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1806 Ok(Err(())) => Err(McpError::new(
1807 McpErrorCode::RequestCancelled,
1808 "Request timeout exceeded",
1809 )),
1810 Ok(Ok(outcome)) => {
1811 if budget_error(ctx).is_some() {
1812 if budget.is_past_deadline(ctx.cx().now()) {
1816 Err(McpError::new(
1817 McpErrorCode::RequestCancelled,
1818 "Request timeout exceeded",
1819 ))
1820 } else {
1821 Err(McpError::request_cancelled())
1822 }
1823 } else {
1824 Ok(outcome)
1825 }
1826 }
1827 }
1828}
1829
1830async fn run_handler_in_request<'a, T>(
1838 ctx: &'a McpContext,
1839 request_cx: &'a Cx,
1840 budget: Budget,
1841 handler_class: &'static str,
1842 make_future: impl FnOnce(&'a Cx) -> BoxFuture<'a, McpOutcome<T>>,
1843) -> McpResult<McpOutcome<T>> {
1844 if request_cx.is_cancel_requested() || budget_error(ctx).is_some() {
1845 return Err(McpError::request_cancelled());
1846 }
1847
1848 let future = crate::catch_extension_unwind(|| make_future(request_cx))
1849 .map_err(|_payload| sanitized_handler_panic(ctx.cx(), handler_class))?;
1850 let mut future = future;
1851 let poll_handler = std::future::poll_fn(|task_cx| {
1852 match crate::catch_extension_unwind(|| future.as_mut().poll(task_cx)) {
1853 Ok(Poll::Ready(outcome)) => Poll::Ready(Ok(outcome)),
1854 Ok(Poll::Pending) => Poll::Pending,
1855 Err(_payload) => Poll::Ready(Err(())),
1856 }
1857 });
1858
1859 let outcome = match budget.deadline {
1860 Some(deadline) => match asupersync::time::timeout_at(deadline, poll_handler).await {
1861 Ok(Ok(outcome)) => outcome,
1862 Ok(Err(())) => return Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1863 Err(_elapsed) => {
1864 return Err(McpError::new(
1865 McpErrorCode::RequestCancelled,
1866 "Request timeout exceeded",
1867 ));
1868 }
1869 },
1870 None => match poll_handler.await {
1871 Ok(outcome) => outcome,
1872 Err(()) => return Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1873 },
1874 };
1875
1876 if request_cx.is_cancel_requested() || budget_error(ctx).is_some() {
1877 Err(McpError::request_cancelled())
1878 } else {
1879 Ok(outcome)
1880 }
1881}
1882
1883fn derive_handler_context(
1884 request_ctx: &McpContext,
1885 progress_marker: Option<ProgressMarker>,
1886 notification_sender: Option<&NotificationSender>,
1887 bidirectional_senders: Option<&BidirectionalSenders>,
1888 protocol_era: ProtocolEra,
1889) -> McpContext {
1890 trace!(
1891 target: targets::HANDLER,
1892 "Deriving handler context for request {}",
1893 request_ctx.request_id()
1894 );
1895 let mut handler_ctx = request_ctx
1896 .clone()
1897 .with_final_request_surface(matches!(protocol_era, ProtocolEra::Modern2026));
1898
1899 let reuse_installed_final_reporter =
1904 matches!(protocol_era, ProtocolEra::Modern2026) && handler_ctx.has_progress_reporter();
1905
1906 if let (Some(marker), Some(sender)) = (progress_marker, notification_sender)
1907 && !reuse_installed_final_reporter
1908 {
1909 let sender = sender.clone();
1910 let reporter = match protocol_era {
1911 ProtocolEra::Legacy2024 => ProgressNotificationSender::new(marker, move |request| {
1912 sender(request);
1913 })
1914 .into_reporter(),
1915 ProtocolEra::Modern2026 => {
1916 ProgressNotificationSender::new_final(marker, move |request| {
1917 sender(request);
1918 })
1919 .into_reporter()
1920 }
1921 };
1922 handler_ctx = handler_ctx.with_progress_reporter(reporter);
1923 }
1924
1925 if let Some(sender) = notification_sender {
1926 let sender = sender.clone();
1927 handler_ctx = handler_ctx.with_log_sender(Arc::new(
1928 crate::handler::LogNotificationSender::new(move |request| {
1929 sender(request);
1930 }),
1931 ));
1932 }
1933
1934 if let Some(senders) = bidirectional_senders {
1935 if let Some(ref sampling) = senders.sampling {
1936 handler_ctx = handler_ctx.with_sampling(sampling.clone());
1937 }
1938 if let Some(ref elicitation) = senders.elicitation {
1939 handler_ctx = handler_ctx.with_elicitation(elicitation.clone());
1940 }
1941 if let Some(ref roots) = senders.roots {
1942 handler_ctx = handler_ctx.with_roots_provider(roots.clone());
1943 }
1944 }
1945
1946 handler_ctx
1947}
1948
1949fn final_progress_marker(metadata: &OpenMetadata) -> McpResult<Option<ProgressMarker>> {
1950 metadata
1951 .get("progressToken")
1952 .map(|value| {
1953 serde_json::from_value(value.clone()).map_err(|_| {
1954 McpError::invalid_params("final progressToken must be a string or integer")
1955 })
1956 })
1957 .transpose()
1958}
1959
1960pub struct Router {
1962 tools: HashMap<String, AdmittedToolRegistration>,
1963 tool_order: Vec<String>,
1964 completion_handler: Option<BoxedCompletionHandler>,
1965 default_final_completion_enabled: bool,
1967 legacy_prompt_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1969 legacy_resource_template_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1971 final_prompt_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1973 final_resource_template_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1975 resources: HashMap<String, BoxedResourceHandler>,
1976 final_only_resources: HashSet<String>,
1978 final_resources: HashMap<String, AdmittedFinalResourceRegistration>,
1980 resource_order: Vec<String>,
1981 prompts: HashMap<String, BoxedPromptHandler>,
1982 final_only_prompts: HashSet<String>,
1984 final_prompts: HashMap<String, AdmittedFinalPromptRegistration>,
1986 prompt_order: Vec<String>,
1987 resource_templates: HashMap<String, ResourceTemplateEntry>,
1988 resource_template_order: Vec<String>,
1989 sorted_template_keys: Vec<String>,
1992 strict_input_validation: bool,
1994 list_page_size: Option<usize>,
1999 final_catalog_revision: u64,
2004 final_cache_hints: FinalCacheHintPolicy,
2006 #[cfg(feature = "tasks")]
2009 final_task_runtime: Option<FinalTaskRuntime>,
2010 #[cfg(all(feature = "proxy", feature = "tasks"))]
2014 final_task_relay: Option<Arc<ProxyFinalTaskRelay>>,
2015 mrtr_exchanges: Arc<MrtrExchangeRegistry>,
2018}
2019
2020impl Router {
2021 #[must_use]
2023 pub fn new() -> Self {
2024 Self {
2025 tools: HashMap::new(),
2026 tool_order: Vec::new(),
2027 completion_handler: None,
2028 default_final_completion_enabled: false,
2029 legacy_prompt_completion_handlers: HashMap::new(),
2030 legacy_resource_template_completion_handlers: HashMap::new(),
2031 final_prompt_completion_handlers: HashMap::new(),
2032 final_resource_template_completion_handlers: HashMap::new(),
2033 resources: HashMap::new(),
2034 final_only_resources: HashSet::new(),
2035 final_resources: HashMap::new(),
2036 resource_order: Vec::new(),
2037 prompts: HashMap::new(),
2038 final_only_prompts: HashSet::new(),
2039 final_prompts: HashMap::new(),
2040 prompt_order: Vec::new(),
2041 resource_templates: HashMap::new(),
2042 resource_template_order: Vec::new(),
2043 sorted_template_keys: Vec::new(),
2044 strict_input_validation: false,
2045 list_page_size: None,
2046 final_catalog_revision: 0,
2047 final_cache_hints: FinalCacheHintPolicy::default(),
2048 #[cfg(feature = "tasks")]
2049 final_task_runtime: None,
2050 #[cfg(all(feature = "proxy", feature = "tasks"))]
2051 final_task_relay: None,
2052 mrtr_exchanges: Arc::new(MrtrExchangeRegistry::new()),
2053 }
2054 }
2055
2056 #[cfg(test)]
2058 pub(crate) fn test_active_mrtr_exchange_count(&self) -> usize {
2059 self.mrtr_exchanges.active_len()
2060 }
2061
2062 #[cfg(feature = "tasks")]
2063 pub(crate) fn set_final_task_runtime(&mut self, runtime: Option<FinalTaskRuntime>) {
2064 self.final_task_runtime = runtime;
2065 }
2066
2067 #[cfg(all(feature = "proxy", feature = "tasks"))]
2068 pub(crate) fn set_final_task_relay(&mut self, relay: Option<Arc<ProxyFinalTaskRelay>>) {
2069 self.final_task_relay = relay;
2070 }
2071
2072 pub fn set_list_page_size(&mut self, page_size: Option<usize>) {
2078 self.list_page_size = page_size.filter(|n| *n > 0);
2079 }
2080
2081 fn advance_final_catalog_revision(&mut self) {
2082 self.final_catalog_revision = self
2083 .final_catalog_revision
2084 .checked_add(1)
2085 .expect("final catalog revision cannot overflow");
2086 }
2087
2088 pub fn set_final_cache_hint_policy(
2092 &mut self,
2093 list_ttl_ms: CacheTtl,
2094 resource_read_ttl_ms: CacheTtl,
2095 scope: CacheScope,
2096 ) {
2097 self.final_cache_hints = FinalCacheHintPolicy {
2098 list_ttl_ms,
2099 resource_read_ttl_ms,
2100 scope,
2101 };
2102 }
2103
2104 #[must_use]
2107 pub fn final_cache_hint_policy(&self) -> (&CacheTtl, &CacheTtl, CacheScope) {
2108 (
2109 &self.final_cache_hints.list_ttl_ms,
2110 &self.final_cache_hints.resource_read_ttl_ms,
2111 self.final_cache_hints.scope,
2112 )
2113 }
2114
2115 pub fn set_strict_input_validation(&mut self, strict: bool) {
2123 self.strict_input_validation = strict;
2124 }
2125
2126 #[must_use]
2128 pub fn strict_input_validation(&self) -> bool {
2129 self.strict_input_validation
2130 }
2131
2132 fn rebuild_sorted_template_keys(&mut self) {
2135 self.sorted_template_keys = self.resource_templates.keys().cloned().collect();
2136 self.sorted_template_keys.sort_by(|a, b| {
2137 let entry_a = &self.resource_templates[a];
2138 let entry_b = &self.resource_templates[b];
2139 let (a_literals, a_literal_segments, a_segments) = entry_a.specificity;
2140 let (b_literals, b_literal_segments, b_segments) = entry_b.specificity;
2141 b_literals
2142 .cmp(&a_literals)
2143 .then(b_literal_segments.cmp(&a_literal_segments))
2144 .then(b_segments.cmp(&a_segments))
2145 .then_with(|| a.cmp(b))
2146 });
2147 }
2148
2149 fn reject_final_template_collisions(
2153 &self,
2154 candidate: &ReversibleResourceTemplate,
2155 replacing_source: Option<&str>,
2156 ) -> McpResult<()> {
2157 for uri in self.final_resources.keys() {
2158 if candidate
2159 .match_uri(uri)
2160 .map_err(|_| McpError::invalid_params("resource template match admission failed"))?
2161 .is_some()
2162 {
2163 return Err(McpError::invalid_params(
2164 "resource template collides with an exact final resource",
2165 ));
2166 }
2167 }
2168
2169 for (source, entry) in &self.resource_templates {
2170 if replacing_source == Some(source.as_str()) {
2171 continue;
2172 }
2173 let Some(existing) = entry.matcher.as_ref() else {
2174 continue;
2175 };
2176 if reversible_templates_may_overlap(candidate, existing)? {
2177 return Err(McpError::invalid_params(
2178 "resource template collides with an admitted final resource template",
2179 ));
2180 }
2181 }
2182
2183 Ok(())
2184 }
2185
2186 fn reject_final_exact_resource_template_collisions(&self, uri: &str) -> McpResult<()> {
2189 for entry in self.resource_templates.values() {
2190 let Some(matcher) = entry.matcher.as_ref() else {
2191 continue;
2192 };
2193 if matcher
2194 .match_uri(uri)
2195 .map_err(|_| McpError::invalid_params("resource template match admission failed"))?
2196 .is_some()
2197 {
2198 return Err(McpError::invalid_params(
2199 "exact final resource collides with an admitted resource template",
2200 ));
2201 }
2202 }
2203
2204 Ok(())
2205 }
2206
2207 pub fn add_tool<H: ToolHandler + 'static>(&mut self, handler: H) -> McpResult<()> {
2213 self.add_tool_with_behavior(handler, crate::DuplicateBehavior::Replace)
2214 }
2215
2216 pub fn add_legacy_tool<H: ToolHandler + 'static>(&mut self, handler: H) -> McpResult<()> {
2224 self.add_legacy_tool_with_behavior(handler, crate::DuplicateBehavior::Replace)
2225 }
2226
2227 pub fn add_tool_with_behavior<H: ToolHandler + 'static>(
2233 &mut self,
2234 handler: H,
2235 behavior: crate::DuplicateBehavior,
2236 ) -> Result<(), McpError> {
2237 self.add_tool_registration_with_behavior(handler, behavior, true, true, false)
2238 }
2239
2240 pub(crate) fn add_final_tool_with_behavior<H: ToolHandler + 'static>(
2242 &mut self,
2243 handler: H,
2244 behavior: crate::DuplicateBehavior,
2245 ) -> Result<(), McpError> {
2246 self.add_tool_registration_with_behavior(handler, behavior, true, false, false)
2247 }
2248
2249 pub(crate) fn add_mcp_apps_tool_with_behavior<H: ToolHandler + 'static>(
2255 &mut self,
2256 handler: H,
2257 behavior: crate::DuplicateBehavior,
2258 ) -> Result<(), McpError> {
2259 self.add_tool_registration_with_behavior(handler, behavior, true, false, true)
2260 }
2261
2262 pub fn add_legacy_tool_with_behavior<H: ToolHandler + 'static>(
2269 &mut self,
2270 handler: H,
2271 behavior: crate::DuplicateBehavior,
2272 ) -> Result<(), McpError> {
2273 self.add_tool_registration_with_behavior(handler, behavior, false, true, false)
2274 }
2275
2276 fn add_tool_registration_with_behavior<H: ToolHandler + 'static>(
2277 &mut self,
2278 handler: H,
2279 behavior: crate::DuplicateBehavior,
2280 admit_final: bool,
2281 legacy_enabled: bool,
2282 allow_mcp_apps: bool,
2283 ) -> Result<(), McpError> {
2284 let def = crate::catch_extension_unwind(|| handler.definition()).map_err(|_payload| {
2285 McpError::internal_error("tool definition hook panicked during admission")
2286 })?;
2287 let name = &def.name;
2288
2289 let existed = self.tools.contains_key(name);
2290 if existed {
2291 match behavior {
2292 crate::DuplicateBehavior::Error => {
2293 return Err(duplicate_registration_error("Tool", name));
2294 }
2295 crate::DuplicateBehavior::Warn => {
2296 log::warn!(
2297 target: "fastmcp_rust::router",
2298 "tool already exists, keeping original; tool_key={}",
2299 safe_log_label(name)
2300 );
2301 return Ok(());
2302 }
2303 crate::DuplicateBehavior::Replace => {
2304 log::debug!(
2305 target: "fastmcp_rust::router",
2306 "replacing tool; tool_key={}",
2307 safe_log_label(name)
2308 );
2309 }
2311 crate::DuplicateBehavior::Ignore => {
2312 return Ok(());
2313 }
2314 }
2315 }
2316
2317 let name = def.name.clone();
2321 let admitted = if admit_final {
2322 AdmittedToolRegistration::admit(handler, def, legacy_enabled)?
2323 } else {
2324 AdmittedToolRegistration::legacy_only(handler, def)
2325 };
2326 if let Some(final_registration) = admitted.final_registration.as_ref() {
2327 self.validate_mcp_apps_tool_admission(
2328 &final_registration.final_definition,
2329 allow_mcp_apps,
2330 )?;
2331 }
2332 self.tools.insert(name.clone(), admitted);
2333 if !existed {
2334 self.tool_order.push(name);
2335 }
2336 self.advance_final_catalog_revision();
2337 Ok(())
2338 }
2339
2340 fn validate_mcp_apps_tool_admission(
2342 &self,
2343 tool: &FinalTool,
2344 allow_mcp_apps: bool,
2345 ) -> McpResult<()> {
2346 let metadata = tool.mcp_apps_metadata().map_err(|error| {
2347 McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2348 })?;
2349 if metadata.is_some() && !allow_mcp_apps {
2350 return Err(McpError::invalid_request(
2351 "MCP Apps tool metadata requires ServerBuilder::mcp_apps_tool",
2352 ));
2353 }
2354 self.validate_mcp_apps_tool_resource_binding(tool)
2355 }
2356
2357 fn validate_mcp_apps_tool_resource_binding(&self, tool: &FinalTool) -> McpResult<()> {
2360 let binding = tool.mcp_apps_resource_binding().map_err(|error| {
2361 McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2362 })?;
2363 let Some(binding) = binding else {
2364 return Ok(());
2365 };
2366 let resource = self
2367 .final_resources
2368 .get(binding.resource_uri.as_str())
2369 .ok_or_else(|| {
2370 McpError::invalid_request(format!(
2371 "MCP Apps UI resource is not registered: {}",
2372 binding.resource_uri.as_str()
2373 ))
2374 })?;
2375 binding
2376 .validate_resource(&resource.definition)
2377 .map_err(|error| {
2378 McpError::invalid_request(format!("invalid MCP Apps UI resource binding: {error}"))
2379 })
2380 }
2381
2382 fn validate_mcp_apps_resource_bindings(&self, resource: &FinalResource) -> McpResult<()> {
2385 for tool in self.tools.values() {
2386 let Some(final_registration) = tool.final_registration.as_ref() else {
2387 continue;
2388 };
2389 let binding = final_registration
2390 .final_definition
2391 .mcp_apps_resource_binding()
2392 .map_err(|error| {
2393 McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2394 })?;
2395 if let Some(binding) = binding
2396 && binding.resource_uri == resource.uri
2397 {
2398 binding.validate_resource(resource).map_err(|error| {
2399 McpError::invalid_request(format!(
2400 "invalid MCP Apps UI resource replacement: {error}"
2401 ))
2402 })?;
2403 }
2404 }
2405 Ok(())
2406 }
2407
2408 fn validate_mcp_apps_ui_resource_admission(
2412 &self,
2413 resource: &FinalResource,
2414 allow_mcp_apps: bool,
2415 ) -> McpResult<()> {
2416 let requires_mcp_apps = Self::mcp_apps_ui_resource_requires_special_admission(resource)?;
2417 if !requires_mcp_apps {
2418 return Ok(());
2419 }
2420 if !allow_mcp_apps {
2421 return Err(McpError::invalid_request(
2422 "MCP Apps UI resources require ServerBuilder::mcp_apps_ui_resource",
2423 ));
2424 }
2425 let is_apps_html = resource.uri.as_str().starts_with("ui://")
2426 && resource.mime_type.as_deref() == Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE);
2427 if !is_apps_html {
2428 return Err(McpError::invalid_request(
2429 "MCP Apps UI resources must use a ui:// URI and the MCP Apps HTML MIME type",
2430 ));
2431 }
2432 Ok(())
2433 }
2434
2435 fn mcp_apps_ui_resource_requires_special_admission(
2436 resource: &FinalResource,
2437 ) -> McpResult<bool> {
2438 let metadata = resource.mcp_apps_metadata().map_err(|error| {
2439 McpError::invalid_request(format!("invalid MCP Apps resource metadata: {error}"))
2440 })?;
2441 let is_apps_html = resource.uri.as_str().starts_with("ui://")
2442 && resource.mime_type.as_deref() == Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE);
2443 Ok(is_apps_html || metadata.is_some())
2444 }
2445
2446 #[must_use]
2452 pub(crate) fn has_mcp_apps_bound_components(&self) -> bool {
2453 self.final_resources.values().any(|registration| {
2454 Self::mcp_apps_ui_resource_requires_special_admission(®istration.definition)
2455 .unwrap_or(true)
2456 }) || self.tools.values().any(|registration| {
2457 registration
2458 .final_registration
2459 .as_ref()
2460 .is_some_and(|final_registration| {
2461 final_registration
2462 .final_definition
2463 .mcp_apps_metadata()
2464 .map_or(true, |metadata| metadata.is_some())
2465 })
2466 })
2467 }
2468
2469 pub fn add_completion_handler<H: CompletionHandler + 'static>(&mut self, handler: H) {
2477 self.completion_handler = Some(Box::new(handler));
2478 self.default_final_completion_enabled = true;
2479 }
2480
2481 pub fn add_legacy_completion_handler<H: CompletionHandler + 'static>(&mut self, handler: H) {
2483 self.completion_handler = Some(Box::new(handler));
2484 self.default_final_completion_enabled = false;
2485 }
2486
2487 pub(crate) fn add_legacy_prompt_completion_handler<H: CompletionHandler + 'static>(
2492 &mut self,
2493 prompt_name: impl Into<String>,
2494 handler: H,
2495 ) {
2496 self.legacy_prompt_completion_handlers
2497 .insert(prompt_name.into(), Box::new(handler));
2498 }
2499
2500 pub(crate) fn add_legacy_resource_template_completion_handler<
2505 H: CompletionHandler + 'static,
2506 >(
2507 &mut self,
2508 uri_template: impl Into<String>,
2509 handler: H,
2510 ) {
2511 self.legacy_resource_template_completion_handlers
2512 .insert(uri_template.into(), Box::new(handler));
2513 }
2514
2515 pub fn add_prompt_completion_handler<H: CompletionHandler + 'static>(
2520 &mut self,
2521 prompt_name: impl Into<String>,
2522 handler: H,
2523 ) {
2524 self.final_prompt_completion_handlers
2525 .insert(prompt_name.into(), Box::new(handler));
2526 }
2527
2528 pub fn add_resource_template_completion_handler<H: CompletionHandler + 'static>(
2533 &mut self,
2534 uri_template: impl Into<String>,
2535 handler: H,
2536 ) {
2537 self.final_resource_template_completion_handlers
2538 .insert(uri_template.into(), Box::new(handler));
2539 }
2540
2541 #[must_use]
2543 pub fn has_completion_handler(&self) -> bool {
2544 self.completion_handler.is_some()
2545 || !self.legacy_prompt_completion_handlers.is_empty()
2546 || !self.legacy_resource_template_completion_handlers.is_empty()
2547 || !self.final_prompt_completion_handlers.is_empty()
2548 || !self.final_resource_template_completion_handlers.is_empty()
2549 }
2550
2551 fn has_final_completion_handler(&self) -> bool {
2552 self.default_final_completion_enabled || self.has_admitted_final_completion_provider()
2553 }
2554
2555 fn has_admitted_final_completion_provider(&self) -> bool {
2556 self.final_prompt_completion_handlers
2557 .keys()
2558 .any(|name| self.final_prompts.contains_key(name))
2559 || self
2560 .final_resource_template_completion_handlers
2561 .keys()
2562 .any(|uri| {
2563 self.resource_templates
2564 .get(uri)
2565 .is_some_and(|entry| entry.final_definition.is_some())
2566 })
2567 }
2568
2569 pub fn add_resource<H: ResourceHandler + 'static>(&mut self, handler: H) {
2575 if let Err(error) = self.add_resource_registration_with_behavior(
2576 handler,
2577 crate::DuplicateBehavior::Replace,
2578 true,
2579 true,
2580 false,
2581 ) {
2582 log::warn!(
2583 target: "fastmcp_rust::router",
2584 "rejected resource registration; code={:?}",
2585 error.code
2586 );
2587 }
2588 }
2589
2590 pub fn add_legacy_resource<H: ResourceHandler + 'static>(&mut self, handler: H) {
2592 if let Err(error) = self.add_resource_registration_with_behavior(
2593 handler,
2594 crate::DuplicateBehavior::Replace,
2595 false,
2596 true,
2597 false,
2598 ) {
2599 log::warn!(
2600 target: "fastmcp_rust::router",
2601 "rejected exact-2024-only resource registration; code={:?}",
2602 error.code
2603 );
2604 }
2605 }
2606
2607 pub fn add_resource_with_behavior<H: ResourceHandler + 'static>(
2612 &mut self,
2613 handler: H,
2614 behavior: crate::DuplicateBehavior,
2615 ) -> Result<(), McpError> {
2616 self.add_resource_registration_with_behavior(handler, behavior, true, true, false)
2617 }
2618
2619 pub(crate) fn add_final_resource_with_behavior<H: ResourceHandler + 'static>(
2621 &mut self,
2622 handler: H,
2623 behavior: crate::DuplicateBehavior,
2624 ) -> Result<(), McpError> {
2625 self.add_resource_registration_with_behavior(handler, behavior, true, false, false)
2626 }
2627
2628 pub(crate) fn add_mcp_apps_ui_resource_with_behavior<H: ResourceHandler + 'static>(
2630 &mut self,
2631 handler: H,
2632 behavior: crate::DuplicateBehavior,
2633 ) -> Result<(), McpError> {
2634 self.add_resource_registration_with_behavior(handler, behavior, true, false, true)
2635 }
2636
2637 pub fn add_legacy_resource_with_behavior<H: ResourceHandler + 'static>(
2639 &mut self,
2640 handler: H,
2641 behavior: crate::DuplicateBehavior,
2642 ) -> Result<(), McpError> {
2643 self.add_resource_registration_with_behavior(handler, behavior, false, true, false)
2644 }
2645
2646 fn add_resource_registration_with_behavior<H: ResourceHandler + 'static>(
2647 &mut self,
2648 handler: H,
2649 behavior: crate::DuplicateBehavior,
2650 admit_final: bool,
2651 legacy_enabled: bool,
2652 allow_mcp_apps: bool,
2653 ) -> Result<(), McpError> {
2654 let (template, def) =
2655 crate::catch_extension_unwind(|| (handler.template(), handler.definition())).map_err(
2656 |_payload| {
2657 McpError::internal_error("resource definition hook panicked during admission")
2658 },
2659 )?;
2660
2661 let key = match template.as_ref() {
2663 Some(template) => template.uri_template.clone(),
2664 None => def.uri.clone(),
2665 };
2666
2667 let exists = if template.is_some() {
2668 self.resource_templates.contains_key(&key)
2669 } else {
2670 self.resources.contains_key(&key)
2671 };
2672
2673 if exists {
2674 match behavior {
2675 crate::DuplicateBehavior::Error => {
2676 return Err(duplicate_registration_error("Resource", &key));
2677 }
2678 crate::DuplicateBehavior::Warn => {
2679 log::warn!(
2680 target: "fastmcp_rust::router",
2681 "resource already exists, keeping original; resource_key={}",
2682 safe_log_label(&key)
2683 );
2684 return Ok(());
2685 }
2686 crate::DuplicateBehavior::Replace => {
2687 log::debug!(
2688 target: "fastmcp_rust::router",
2689 "replacing resource; resource_key={}",
2690 safe_log_label(&key)
2691 );
2692 }
2694 crate::DuplicateBehavior::Ignore => {
2695 return Ok(());
2696 }
2697 }
2698 }
2699
2700 if let Some(template) = template {
2701 let legacy_matcher = if legacy_enabled {
2702 match admit_legacy_resource_template(&template.uri_template) {
2703 Ok(matcher) => Some(matcher),
2704 Err(error) if !admit_final => return Err(error),
2705 Err(_) => None,
2706 }
2707 } else {
2708 None
2709 };
2710 let (matcher, specificity) = if admit_final {
2711 let (matcher, specificity) = admit_resource_template(&template.uri_template)?;
2712 self.reject_final_template_collisions(
2713 &matcher,
2714 Some(template.uri_template.as_str()),
2715 )?;
2716 (Some(matcher), specificity)
2717 } else {
2718 let matcher = legacy_matcher.as_ref().ok_or_else(|| {
2719 McpError::internal_error(
2720 "exact-2024 resource template admission lost its matcher",
2721 )
2722 })?;
2723 (None, matcher.specificity())
2724 };
2725 let uri_use_policy = if admit_final {
2726 ResourceUriUsePolicy::from_client_direct_https(
2727 crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
2728 |_payload| {
2729 McpError::internal_error(
2730 "resource URI-use policy hook panicked during admission",
2731 )
2732 },
2733 )?,
2734 )
2735 } else {
2736 ResourceUriUsePolicy::server_mediated()
2737 };
2738 let final_definition = admit_final
2739 .then(|| {
2740 admit_final_resource_template_definition(
2741 Some(&handler),
2742 &template,
2743 uri_use_policy,
2744 )
2745 })
2746 .transpose()?;
2747 let boxed: BoxedResourceHandler = Box::new(handler);
2748 let is_new = !self.resource_templates.contains_key(&template.uri_template);
2749 if !is_new {
2750 self.legacy_resource_template_completion_handlers
2751 .remove(&template.uri_template);
2752 self.final_resource_template_completion_handlers
2753 .remove(&template.uri_template);
2754 }
2755 let entry = ResourceTemplateEntry {
2756 matcher,
2757 specificity,
2758 template: template.clone(),
2759 handler: Some(boxed),
2760 final_definition,
2761 uri_use_policy,
2762 legacy_enabled: legacy_matcher.is_some(),
2763 legacy_matcher,
2764 };
2765 self.resource_templates
2766 .insert(template.uri_template.clone(), entry);
2767 if is_new {
2768 self.resource_template_order.push(template.uri_template);
2769 }
2770 self.rebuild_sorted_template_keys();
2771 } else {
2772 let uri_use_policy = if admit_final {
2773 ResourceUriUsePolicy::from_client_direct_https(
2774 crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
2775 |_payload| {
2776 McpError::internal_error(
2777 "resource URI-use policy hook panicked during admission",
2778 )
2779 },
2780 )?,
2781 )
2782 } else {
2783 ResourceUriUsePolicy::server_mediated()
2784 };
2785 let final_definition = admit_final
2786 .then(|| admit_final_resource_definition(&handler, &def, uri_use_policy))
2787 .transpose()?;
2788 if let Some(final_definition) = final_definition.as_ref() {
2789 self.validate_mcp_apps_ui_resource_admission(final_definition, allow_mcp_apps)?;
2790 self.validate_mcp_apps_resource_bindings(final_definition)?;
2791 self.reject_final_exact_resource_template_collisions(&def.uri)?;
2792 }
2793 let boxed: BoxedResourceHandler = Box::new(handler);
2794 let is_new = !self.resources.contains_key(&def.uri);
2795 self.resources.insert(def.uri.clone(), boxed);
2796 if legacy_enabled {
2797 self.final_only_resources.remove(&def.uri);
2798 } else {
2799 self.final_only_resources.insert(def.uri.clone());
2800 }
2801 match final_definition {
2802 Some(final_definition) => {
2803 self.final_resources.insert(
2804 def.uri.clone(),
2805 AdmittedFinalResourceRegistration {
2806 definition: final_definition,
2807 tags: def.tags.clone(),
2808 uri_use_policy,
2809 },
2810 );
2811 }
2812 None => {
2813 self.final_resources.remove(&def.uri);
2814 }
2815 }
2816 if is_new {
2817 self.resource_order.push(def.uri);
2818 }
2819 }
2820
2821 self.advance_final_catalog_revision();
2822 Ok(())
2823 }
2824
2825 pub fn add_resource_template(&mut self, template: ResourceTemplate) {
2832 let key = template.uri_template.clone();
2833 if let Err(error) =
2834 self.add_resource_template_with_behavior(template, crate::DuplicateBehavior::Replace)
2835 {
2836 log::warn!(
2837 target: "fastmcp_rust::router",
2838 "rejected resource template definition; template_key={}; code={:?}",
2839 safe_log_label(&key),
2840 error.code
2841 );
2842 }
2843 }
2844
2845 pub fn add_legacy_resource_template(&mut self, template: ResourceTemplate) {
2847 let key = template.uri_template.clone();
2848 if let Err(error) = self.add_resource_template_registration_with_behavior(
2849 template,
2850 crate::DuplicateBehavior::Replace,
2851 false,
2852 ) {
2853 log::warn!(
2854 target: "fastmcp_rust::router",
2855 "rejected exact-2024-only resource template; template_key={}; code={:?}",
2856 safe_log_label(&key),
2857 error.code
2858 );
2859 }
2860 }
2861
2862 pub fn add_resource_template_with_behavior(
2868 &mut self,
2869 template: ResourceTemplate,
2870 behavior: crate::DuplicateBehavior,
2871 ) -> Result<(), McpError> {
2872 self.add_resource_template_registration_with_behavior(template, behavior, true)
2873 }
2874
2875 pub fn add_legacy_resource_template_with_behavior(
2877 &mut self,
2878 template: ResourceTemplate,
2879 behavior: crate::DuplicateBehavior,
2880 ) -> Result<(), McpError> {
2881 self.add_resource_template_registration_with_behavior(template, behavior, false)
2882 }
2883
2884 fn add_resource_template_registration_with_behavior(
2885 &mut self,
2886 template: ResourceTemplate,
2887 behavior: crate::DuplicateBehavior,
2888 admit_final: bool,
2889 ) -> Result<(), McpError> {
2890 let key = template.uri_template.clone();
2891 let existed = self.resource_templates.contains_key(&key);
2892
2893 if existed {
2894 match behavior {
2895 crate::DuplicateBehavior::Error => {
2896 return Err(duplicate_registration_error("Resource template", &key));
2897 }
2898 crate::DuplicateBehavior::Warn => {
2899 log::warn!(
2900 target: "fastmcp_rust::router",
2901 "resource template already exists, keeping original; template_key={}",
2902 safe_log_label(&key)
2903 );
2904 return Ok(());
2905 }
2906 crate::DuplicateBehavior::Replace => {
2907 log::debug!(
2908 target: "fastmcp_rust::router",
2909 "replacing resource template definition; template_key={}",
2910 safe_log_label(&key)
2911 );
2912 }
2913 crate::DuplicateBehavior::Ignore => return Ok(()),
2914 }
2915 }
2916
2917 let legacy_matcher = match admit_legacy_resource_template(&key) {
2918 Ok(matcher) => Some(matcher),
2919 Err(error) if !admit_final => return Err(error),
2920 Err(_) => None,
2921 };
2922 let (matcher, specificity) = if admit_final {
2923 let (matcher, specificity) = admit_resource_template(&key)?;
2924 self.reject_final_template_collisions(&matcher, Some(key.as_str()))?;
2925 (Some(matcher), specificity)
2926 } else {
2927 let matcher = legacy_matcher.as_ref().ok_or_else(|| {
2928 McpError::internal_error("exact-2024 resource template admission lost its matcher")
2929 })?;
2930 (None, matcher.specificity())
2931 };
2932 let uri_use_policy = ResourceUriUsePolicy::server_mediated();
2933 let final_definition = admit_final
2934 .then(|| {
2935 admit_final_resource_template_definition::<dyn ResourceHandler>(
2936 None,
2937 &template,
2938 uri_use_policy,
2939 )
2940 })
2941 .transpose()?;
2942 if existed {
2943 self.legacy_resource_template_completion_handlers
2944 .remove(&key);
2945 self.final_resource_template_completion_handlers
2946 .remove(&key);
2947 }
2948 let needs_rebuild = match self.resource_templates.get_mut(&key) {
2949 Some(existing) => {
2950 existing.template = template;
2951 existing.matcher = matcher;
2952 existing.specificity = specificity;
2953 existing.final_definition = final_definition;
2954 existing.uri_use_policy = uri_use_policy;
2955 existing.legacy_enabled = legacy_matcher.is_some();
2956 existing.legacy_matcher = legacy_matcher;
2957 false }
2959 None => {
2960 self.resource_templates.insert(
2961 key.clone(),
2962 ResourceTemplateEntry {
2963 matcher,
2964 specificity,
2965 template,
2966 handler: None,
2967 final_definition,
2968 uri_use_policy,
2969 legacy_enabled: legacy_matcher.is_some(),
2970 legacy_matcher,
2971 },
2972 );
2973 true }
2975 };
2976 if needs_rebuild {
2977 self.resource_template_order.push(key);
2978 self.rebuild_sorted_template_keys();
2979 }
2980 self.advance_final_catalog_revision();
2981 Ok(())
2982 }
2983
2984 pub fn add_prompt<H: PromptHandler + 'static>(&mut self, handler: H) {
2990 if let Err(error) = self.add_prompt_registration_with_behavior(
2991 handler,
2992 crate::DuplicateBehavior::Replace,
2993 true,
2994 true,
2995 ) {
2996 log::warn!(
2997 target: "fastmcp_rust::router",
2998 "rejected prompt registration; code={:?}",
2999 error.code
3000 );
3001 }
3002 }
3003
3004 pub fn add_legacy_prompt<H: PromptHandler + 'static>(&mut self, handler: H) {
3006 if let Err(error) = self.add_prompt_registration_with_behavior(
3007 handler,
3008 crate::DuplicateBehavior::Replace,
3009 false,
3010 true,
3011 ) {
3012 log::warn!(
3013 target: "fastmcp_rust::router",
3014 "rejected exact-2024-only prompt registration; code={:?}",
3015 error.code
3016 );
3017 }
3018 }
3019
3020 pub fn add_prompt_with_behavior<H: PromptHandler + 'static>(
3025 &mut self,
3026 handler: H,
3027 behavior: crate::DuplicateBehavior,
3028 ) -> Result<(), McpError> {
3029 self.add_prompt_registration_with_behavior(handler, behavior, true, true)
3030 }
3031
3032 pub(crate) fn add_final_prompt_with_behavior<H: PromptHandler + 'static>(
3034 &mut self,
3035 handler: H,
3036 behavior: crate::DuplicateBehavior,
3037 ) -> Result<(), McpError> {
3038 self.add_prompt_registration_with_behavior(handler, behavior, true, false)
3039 }
3040
3041 pub fn add_legacy_prompt_with_behavior<H: PromptHandler + 'static>(
3043 &mut self,
3044 handler: H,
3045 behavior: crate::DuplicateBehavior,
3046 ) -> Result<(), McpError> {
3047 self.add_prompt_registration_with_behavior(handler, behavior, false, true)
3048 }
3049
3050 fn add_prompt_registration_with_behavior<H: PromptHandler + 'static>(
3051 &mut self,
3052 handler: H,
3053 behavior: crate::DuplicateBehavior,
3054 admit_final: bool,
3055 legacy_enabled: bool,
3056 ) -> Result<(), McpError> {
3057 let def = crate::catch_extension_unwind(|| handler.definition()).map_err(|_payload| {
3058 McpError::internal_error("prompt definition hook panicked during admission")
3059 })?;
3060 let name = &def.name;
3061
3062 let existed = self.prompts.contains_key(name);
3063 if existed {
3064 match behavior {
3065 crate::DuplicateBehavior::Error => {
3066 return Err(duplicate_registration_error("Prompt", name));
3067 }
3068 crate::DuplicateBehavior::Warn => {
3069 log::warn!(
3070 target: "fastmcp_rust::router",
3071 "prompt already exists, keeping original; prompt_key={}",
3072 safe_log_label(name)
3073 );
3074 return Ok(());
3075 }
3076 crate::DuplicateBehavior::Replace => {
3077 log::debug!(
3078 target: "fastmcp_rust::router",
3079 "replacing prompt; prompt_key={}",
3080 safe_log_label(name)
3081 );
3082 }
3084 crate::DuplicateBehavior::Ignore => {
3085 return Ok(());
3086 }
3087 }
3088 }
3089
3090 let uri_use_policy = if admit_final {
3091 ResourceUriUsePolicy::from_client_direct_https(
3092 crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
3093 |_payload| {
3094 McpError::internal_error(
3095 "prompt URI-use policy hook panicked during admission",
3096 )
3097 },
3098 )?,
3099 )
3100 } else {
3101 ResourceUriUsePolicy::server_mediated()
3102 };
3103 let final_definition = admit_final
3104 .then(|| admit_final_prompt_definition(&handler, &def))
3105 .transpose()?;
3106 if existed {
3107 self.legacy_prompt_completion_handlers.remove(&def.name);
3108 self.final_prompt_completion_handlers.remove(&def.name);
3109 }
3110 self.prompts.insert(def.name.clone(), Box::new(handler));
3111 if legacy_enabled {
3112 self.final_only_prompts.remove(&def.name);
3113 } else {
3114 self.final_only_prompts.insert(def.name.clone());
3115 }
3116 match final_definition {
3117 Some(final_definition) => {
3118 self.final_prompts.insert(
3119 def.name.clone(),
3120 AdmittedFinalPromptRegistration {
3121 definition: final_definition,
3122 tags: def.tags.clone(),
3123 uri_use_policy,
3124 },
3125 );
3126 }
3127 None => {
3128 self.final_prompts.remove(&def.name);
3129 }
3130 }
3131 if !existed {
3132 self.prompt_order.push(def.name);
3133 }
3134 self.advance_final_catalog_revision();
3135 Ok(())
3136 }
3137
3138 #[must_use]
3140 pub fn tools(&self) -> Vec<Tool> {
3141 self.tool_order
3142 .iter()
3143 .filter_map(|name| self.tools.get(name))
3144 .filter(|entry| entry.legacy_enabled)
3145 .map(|entry| entry.definition.clone())
3146 .collect()
3147 }
3148
3149 #[must_use]
3154 pub fn tools_filtered(
3155 &self,
3156 session_state: Option<&SessionState>,
3157 tag_filters: Option<&TagFilters<'_>>,
3158 ) -> Vec<Tool> {
3159 self.tool_order
3160 .iter()
3161 .filter_map(|name| self.tools.get(name))
3162 .filter_map(|entry| {
3163 if !entry.legacy_enabled {
3164 return None;
3165 }
3166 let def = &entry.definition;
3167 if let Some(state) = session_state {
3169 if !state.is_tool_enabled(&def.name) {
3170 return None;
3171 }
3172 }
3173 if let Some(filters) = tag_filters {
3175 if !filters.matches(&def.tags) {
3176 return None;
3177 }
3178 }
3179 Some(def.clone())
3180 })
3181 .collect()
3182 }
3183
3184 #[must_use]
3186 pub fn resources(&self) -> Vec<Resource> {
3187 self.resource_order
3188 .iter()
3189 .filter_map(|uri| self.resources.get(uri))
3190 .filter(|handler| {
3191 let uri = handler.definition().uri;
3192 !self.final_only_resources.contains(&uri)
3193 })
3194 .map(|h| h.definition())
3195 .collect()
3196 }
3197
3198 #[must_use]
3203 pub fn resources_filtered(
3204 &self,
3205 session_state: Option<&SessionState>,
3206 tag_filters: Option<&TagFilters<'_>>,
3207 ) -> Vec<Resource> {
3208 self.resource_order
3209 .iter()
3210 .filter_map(|uri| self.resources.get(uri))
3211 .filter_map(|h| {
3212 let def = h.definition();
3213 if self.final_only_resources.contains(&def.uri) {
3214 return None;
3215 }
3216 if let Some(state) = session_state {
3218 if !state.is_resource_enabled(&def.uri) {
3219 return None;
3220 }
3221 }
3222 if let Some(filters) = tag_filters {
3224 if !filters.matches(&def.tags) {
3225 return None;
3226 }
3227 }
3228 Some(def)
3229 })
3230 .collect()
3231 }
3232
3233 #[must_use]
3235 pub fn resource_templates(&self) -> Vec<ResourceTemplate> {
3236 self.resource_template_order
3237 .iter()
3238 .filter_map(|t| self.resource_templates.get(t))
3239 .filter(|entry| entry.legacy_enabled)
3240 .map(|entry| entry.template.clone())
3241 .collect()
3242 }
3243
3244 #[must_use]
3249 pub fn resource_templates_filtered(
3250 &self,
3251 session_state: Option<&SessionState>,
3252 tag_filters: Option<&TagFilters<'_>>,
3253 ) -> Vec<ResourceTemplate> {
3254 self.resource_template_order
3255 .iter()
3256 .filter_map(|t| self.resource_templates.get(t))
3257 .filter_map(|entry| {
3258 if !entry.legacy_enabled {
3259 return None;
3260 }
3261 if let Some(state) = session_state {
3263 if !state.is_resource_enabled(&entry.template.uri_template) {
3264 return None;
3265 }
3266 }
3267 if let Some(filters) = tag_filters {
3269 if !filters.matches(&entry.template.tags) {
3270 return None;
3271 }
3272 }
3273 Some(entry.template.clone())
3274 })
3275 .collect()
3276 }
3277
3278 #[must_use]
3280 pub fn prompts(&self) -> Vec<Prompt> {
3281 self.prompt_order
3282 .iter()
3283 .filter_map(|name| self.prompts.get(name))
3284 .filter(|handler| {
3285 let name = handler.definition().name;
3286 !self.final_only_prompts.contains(&name)
3287 })
3288 .map(|h| h.definition())
3289 .collect()
3290 }
3291
3292 #[must_use]
3297 pub fn prompts_filtered(
3298 &self,
3299 session_state: Option<&SessionState>,
3300 tag_filters: Option<&TagFilters<'_>>,
3301 ) -> Vec<Prompt> {
3302 self.prompt_order
3303 .iter()
3304 .filter_map(|name| self.prompts.get(name))
3305 .filter_map(|h| {
3306 let def = h.definition();
3307 if self.final_only_prompts.contains(&def.name) {
3308 return None;
3309 }
3310 if let Some(state) = session_state {
3312 if !state.is_prompt_enabled(&def.name) {
3313 return None;
3314 }
3315 }
3316 if let Some(filters) = tag_filters {
3318 if !filters.matches(&def.tags) {
3319 return None;
3320 }
3321 }
3322 Some(def)
3323 })
3324 .collect()
3325 }
3326
3327 #[must_use]
3329 pub fn tools_count(&self) -> usize {
3330 self.tools.len()
3331 }
3332
3333 #[must_use]
3335 pub fn resources_count(&self) -> usize {
3336 self.resources.len()
3337 }
3338
3339 #[must_use]
3341 pub fn resource_templates_count(&self) -> usize {
3342 self.resource_templates.len()
3343 }
3344
3345 #[must_use]
3347 pub fn prompts_count(&self) -> usize {
3348 self.prompts.len()
3349 }
3350
3351 #[must_use]
3360 pub(crate) fn server_discovery_behavior_registry(&self) -> ServerBehaviorRegistry {
3361 let mut behaviors = Vec::with_capacity(11);
3362 behaviors.push(ServerBehavior::SubscriptionsListen);
3363 if self.has_final_completion_handler() {
3364 behaviors.push(ServerBehavior::CompletionComplete);
3365 }
3366 if self
3367 .tool_order
3368 .iter()
3369 .filter_map(|name| self.tools.get(name))
3370 .any(|entry| entry.final_registration.is_some())
3371 {
3372 behaviors.push(ServerBehavior::ToolsList);
3373 behaviors.push(ServerBehavior::ToolsListChangedNotification);
3374 }
3375 if !self.final_resources.is_empty()
3376 || self
3377 .resource_template_order
3378 .iter()
3379 .filter_map(|key| self.resource_templates.get(key))
3380 .any(|entry| entry.final_definition.is_some())
3381 {
3382 behaviors.push(ServerBehavior::ResourcesList);
3383 behaviors.push(ServerBehavior::ResourcesListChangedNotification);
3384 }
3385 if !self.final_resources.is_empty() {
3386 behaviors.push(ServerBehavior::ResourceUpdateDelivery);
3387 }
3388 if !self.final_prompts.is_empty() {
3389 behaviors.push(ServerBehavior::PromptsList);
3390 behaviors.push(ServerBehavior::PromptsListChangedNotification);
3391 }
3392 ServerBehaviorRegistry::from_behaviors(behaviors)
3393 }
3394
3395 #[must_use]
3397 pub fn get_tool(&self, name: &str) -> Option<&BoxedToolHandler> {
3398 self.tools.get(name).map(|entry| &entry.handler)
3399 }
3400
3401 #[must_use]
3403 pub fn get_resource(&self, uri: &str) -> Option<&BoxedResourceHandler> {
3404 self.resources.get(uri)
3405 }
3406
3407 #[must_use]
3409 pub fn get_resource_template(&self, uri_template: &str) -> Option<&ResourceTemplate> {
3410 self.resource_templates
3411 .get(uri_template)
3412 .map(|entry| &entry.template)
3413 }
3414
3415 #[must_use]
3417 pub fn resource_exists(&self, uri: &str) -> bool {
3418 self.resolve_resource(uri).is_some()
3419 }
3420
3421 pub(crate) fn notify_resource_subscribed(&self, ctx: &McpContext, uri: &str) -> McpResult<()> {
3423 if let Some(resolved) = self.resolve_resource(uri) {
3424 resolved.handler.on_subscribe(ctx, uri)?;
3425 }
3426 Ok(())
3427 }
3428
3429 pub(crate) fn notify_resource_unsubscribed(
3431 &self,
3432 ctx: &McpContext,
3433 uri: &str,
3434 ) -> McpResult<()> {
3435 if let Some(resolved) = self.resolve_resource(uri) {
3436 resolved.handler.on_unsubscribe(ctx, uri)?;
3437 }
3438 Ok(())
3439 }
3440
3441 fn resolve_resource(&self, uri: &str) -> Option<ResolvedResource<'_>> {
3442 self.resolve_resource_for_era(uri, None)
3443 }
3444
3445 #[cfg(test)]
3450 fn resolve_tool_for_era(
3451 &self,
3452 name: &str,
3453 era: Option<ProtocolEra>,
3454 ) -> Option<&AdmittedToolRegistration> {
3455 let registration = self.tools.get(name)?;
3456 let visible = era.is_none_or(|era| match era {
3457 ProtocolEra::Modern2026 => registration.final_registration.is_some(),
3458 ProtocolEra::Legacy2024 => registration.legacy_enabled,
3459 });
3460 visible.then_some(registration)
3461 }
3462
3463 fn resolve_resource_for_era(
3470 &self,
3471 uri: &str,
3472 era: Option<ProtocolEra>,
3473 ) -> Option<ResolvedResource<'_>> {
3474 if let Some(handler) = self.resources.get(uri) {
3475 let resolved = ResolvedResource {
3476 handler,
3477 params: UriParams::new(),
3478 final_enabled: self.final_resources.contains_key(uri),
3479 legacy_enabled: !self.final_only_resources.contains(uri),
3480 uri_use_policy: self
3481 .final_resources
3482 .get(uri)
3483 .map_or_else(ResourceUriUsePolicy::server_mediated, |entry| {
3484 entry.uri_use_policy
3485 }),
3486 };
3487 if era.is_none_or(|era| resolved.is_enabled_in(era)) {
3488 return Some(resolved);
3489 }
3490 }
3491
3492 'templates: for key in &self.sorted_template_keys {
3494 let entry = &self.resource_templates[key];
3495 let Some(handler) = entry.handler.as_ref() else {
3496 continue;
3497 };
3498 let legacy_params = || {
3499 entry
3500 .legacy_matcher
3501 .as_ref()
3502 .and_then(|matcher| matcher.matches(uri))
3503 };
3504 let final_params = || {
3505 let values = entry.matcher.as_ref()?.match_uri(uri).ok()??;
3506 let mut params = UriParams::with_capacity(values.len());
3507 for (name, value) in values {
3508 let TemplateValue::Scalar(value) = value else {
3509 return None;
3510 };
3511 params.insert(name, value);
3512 }
3513 Some(params)
3514 };
3515 let Some(params) = (match era {
3516 Some(ProtocolEra::Legacy2024) => legacy_params(),
3517 Some(ProtocolEra::Modern2026) => final_params(),
3518 None => final_params().or_else(legacy_params),
3519 }) else {
3520 continue 'templates;
3521 };
3522 let resolved = ResolvedResource {
3523 handler,
3524 params,
3525 final_enabled: entry.final_definition.is_some(),
3526 legacy_enabled: entry.legacy_enabled,
3527 uri_use_policy: entry.uri_use_policy,
3528 };
3529 if era.is_none_or(|era| resolved.is_enabled_in(era)) {
3530 return Some(resolved);
3531 }
3532 }
3533
3534 None
3535 }
3536
3537 #[must_use]
3539 pub fn get_prompt(&self, name: &str) -> Option<&BoxedPromptHandler> {
3540 self.prompts.get(name)
3541 }
3542
3543 pub fn handle_initialize(
3549 &self,
3550 request_ctx: &McpContext,
3551 session: &mut Session,
3552 params: InitializeParams,
3553 instructions: Option<&str>,
3554 ) -> McpResult<InitializeResult> {
3555 if let Some(error) = budget_error(request_ctx) {
3556 return Err(error);
3557 }
3558
3559 debug!(
3560 target: targets::SESSION,
3561 "preparing session initialization; client_key={}",
3562 safe_log_label(¶ms.client_info.name)
3563 );
3564
3565 session.initialize(
3567 params.client_info,
3568 params.capabilities,
3569 PROTOCOL_VERSION.to_string(),
3570 );
3571
3572 Ok(InitializeResult {
3573 protocol_version: PROTOCOL_VERSION.to_string(),
3574 capabilities: session.server_capabilities().clone(),
3575 server_info: session.server_info().clone(),
3576 instructions: instructions.map(String::from),
3577 })
3578 }
3579
3580 pub(crate) fn dispatch_legacy_completion(
3587 &self,
3588 request_ctx: &McpContext,
3589 request: &JsonRpcRequest,
3590 ) -> McpResult<serde_json::Value> {
3591 if request.method != COMPLETION_COMPLETE {
3592 return Err(McpError::method_not_found(&request.method));
3593 }
3594
3595 let request = CoreRequest::decode(
3596 ProtocolEra::Legacy2024,
3597 COMPLETION_COMPLETE,
3598 request.params.as_ref(),
3599 )
3600 .map_err(|error| McpError::invalid_params(error.to_string()))?;
3601 let CoreRequest::Legacy(LegacyCoreRequest::Completion(params)) = request else {
3602 return Err(McpError::internal_error(
3603 "legacy completion dispatch selected another core request",
3604 ));
3605 };
3606
3607 serde_json::to_value(self.handle_completion_legacy(request_ctx, params)?)
3608 .map_err(McpError::from)
3609 }
3610
3611 pub(crate) async fn dispatch_legacy_completion_in_request(
3612 &self,
3613 request_ctx: &McpContext,
3614 request_cx: &Cx,
3615 request: &JsonRpcRequest,
3616 ) -> McpResult<serde_json::Value> {
3617 if request.method != COMPLETION_COMPLETE {
3618 return Err(McpError::method_not_found(&request.method));
3619 }
3620
3621 let request = CoreRequest::decode(
3622 ProtocolEra::Legacy2024,
3623 COMPLETION_COMPLETE,
3624 request.params.as_ref(),
3625 )
3626 .map_err(|error| McpError::invalid_params(error.to_string()))?;
3627 let CoreRequest::Legacy(LegacyCoreRequest::Completion(params)) = request else {
3628 return Err(McpError::internal_error(
3629 "legacy completion dispatch selected another core request",
3630 ));
3631 };
3632
3633 serde_json::to_value(
3634 self.handle_completion_legacy_in_request(request_ctx, request_cx, params)
3635 .await?,
3636 )
3637 .map_err(McpError::from)
3638 }
3639
3640 pub fn handle_completion_legacy(
3642 &self,
3643 request_ctx: &McpContext,
3644 params: LegacyCompletionParams,
3645 ) -> McpResult<LegacyCompletionResult> {
3646 block_on(self.handle_completion_legacy_in_request(request_ctx, request_ctx.cx(), params))
3647 }
3648
3649 pub(crate) async fn handle_completion_legacy_in_request(
3650 &self,
3651 request_ctx: &McpContext,
3652 request_cx: &Cx,
3653 params: LegacyCompletionParams,
3654 ) -> McpResult<LegacyCompletionResult> {
3655 let dispatch_started_at = request_ctx.cx().now();
3656 if let Some(error) = budget_error(request_ctx) {
3657 return Err(error);
3658 }
3659
3660 let target_handler = match ¶ms.reference {
3661 fastmcp_protocol::LegacyCompletionReference::Prompt { name } => {
3662 self.legacy_prompt_completion_handlers.get(name)
3663 }
3664 fastmcp_protocol::LegacyCompletionReference::Resource { uri } => {
3665 self.legacy_resource_template_completion_handlers.get(uri)
3666 }
3667 };
3668 let handler = target_handler
3669 .or(self.completion_handler.as_ref())
3670 .ok_or_else(|| McpError::method_not_found(COMPLETION_COMPLETE))?;
3671 let handler_ctx =
3672 derive_handler_context(request_ctx, None, None, None, ProtocolEra::Legacy2024);
3673 let handler_timeout =
3674 read_handler_timeout(request_ctx.cx(), "completion_timeout", || handler.timeout())?;
3675 let effective_budget = compose_handler_budget(
3676 request_ctx.cx().budget(),
3677 request_ctx.budget(),
3678 handler_timeout,
3679 dispatch_started_at,
3680 );
3681 let handler_ctx = handler_ctx.with_operation_deadline(effective_budget.deadline);
3682 let outcome = run_handler_in_request(
3683 &handler_ctx,
3684 request_cx,
3685 effective_budget,
3686 "completion",
3687 |child_cx| handler.complete_legacy_async_in_request(&handler_ctx, child_cx, params),
3688 )
3689 .await?;
3690
3691 let completion = match outcome {
3692 Outcome::Ok(completion) => completion,
3693 Outcome::Err(error) => {
3694 return Err(sanitize_handler_error(
3695 request_ctx.cx(),
3696 "completion",
3697 error,
3698 ));
3699 }
3700 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
3701 Outcome::Panicked(_payload) => {
3702 return Err(sanitized_handler_panic(request_ctx.cx(), "completion"));
3703 }
3704 };
3705
3706 Ok(LegacyCompletionResult {
3707 completion,
3708 meta: None,
3709 })
3710 }
3711
3712 async fn handle_completion_final_in_request(
3713 &self,
3714 request_ctx: &McpContext,
3715 request_cx: &Cx,
3716 params: FinalCompletionParams,
3717 ) -> McpResult<FinalCompletionResult> {
3718 let dispatch_started_at = request_ctx.cx().now();
3719 if request_cx.is_cancel_requested() {
3720 return Err(McpError::request_cancelled());
3721 }
3722 if let Some(error) = budget_error(request_ctx) {
3723 return Err(error);
3724 }
3725 if !self.has_final_completion_handler() {
3726 return Err(McpError::method_not_found(COMPLETION_COMPLETE));
3727 }
3728
3729 let provider_handler = match ¶ms.reference {
3730 FinalCompletionReference::Prompt { name }
3731 | FinalCompletionReference::PromptWithTitle { name, .. } => {
3732 if !request_ctx.is_prompt_enabled(name) {
3737 return Err(McpError::invalid_params(
3738 "completion prompt reference is not registered",
3739 ));
3740 }
3741 let prompt = self.final_prompts.get(name).ok_or_else(|| {
3742 McpError::invalid_params("completion prompt reference is not registered")
3743 })?;
3744 if !prompt
3745 .definition
3746 .arguments
3747 .as_deref()
3748 .unwrap_or_default()
3749 .iter()
3750 .any(|argument| argument.name == params.argument.name)
3751 {
3752 return Err(McpError::invalid_params(
3753 "completion argument is not declared by the referenced target",
3754 ));
3755 }
3756 self.final_prompt_completion_handlers.get(name)
3757 }
3758 FinalCompletionReference::Resource { uri } => {
3759 if !request_ctx.is_resource_enabled(uri) {
3764 return Err(McpError::invalid_params(
3765 "completion resource reference is not registered",
3766 ));
3767 }
3768 let template = self
3769 .resource_templates
3770 .get(uri)
3771 .filter(|entry| entry.final_definition.is_some())
3772 .ok_or_else(|| {
3773 McpError::invalid_params("completion resource reference is not registered")
3774 })?;
3775 let template = fastmcp_protocol::UriTemplate::parse(
3776 &template.template.uri_template,
3777 )
3778 .map_err(|_| {
3779 McpError::internal_error(
3780 "admitted completion resource template is no longer valid",
3781 )
3782 })?;
3783 if !template.parts().iter().any(|part| {
3784 matches!(part, UriTemplatePart::Expression(expression)
3785 if expression
3786 .variables()
3787 .iter()
3788 .any(|variable| variable.name() == params.argument.name))
3789 }) {
3790 return Err(McpError::invalid_params(
3791 "completion argument is not declared by the referenced target",
3792 ));
3793 }
3794 self.final_resource_template_completion_handlers.get(uri)
3795 }
3796 };
3797
3798 let handler = match provider_handler {
3799 Some(handler) => handler,
3800 None if self.default_final_completion_enabled => self
3801 .completion_handler
3802 .as_ref()
3803 .ok_or_else(|| McpError::method_not_found(COMPLETION_COMPLETE))?,
3804 None => {
3805 return Err(McpError::invalid_params(
3806 "no final completion provider is registered for the referenced target",
3807 ));
3808 }
3809 };
3810 let handler_ctx =
3811 derive_handler_context(request_ctx, None, None, None, ProtocolEra::Modern2026);
3812 let handler_timeout =
3813 read_handler_timeout(request_ctx.cx(), "completion_timeout", || handler.timeout())?;
3814 let effective_budget = compose_handler_budget(
3815 request_ctx.cx().budget(),
3816 request_ctx.budget(),
3817 handler_timeout,
3818 dispatch_started_at,
3819 );
3820 let handler_ctx = handler_ctx.with_operation_deadline(effective_budget.deadline);
3821 let outcome = run_handler_in_request(
3822 &handler_ctx,
3823 request_cx,
3824 effective_budget,
3825 "completion",
3826 |child_cx| handler.complete_final_async_in_request(&handler_ctx, child_cx, params),
3827 )
3828 .await?;
3829
3830 let completion = match outcome {
3831 Outcome::Ok(completion) => {
3832 if completion.values.len() > fastmcp_protocol::MAX_COMPLETION_VALUES {
3833 return Err(McpError::internal_error(
3834 "completion handler returned more than 100 values",
3835 ));
3836 }
3837 completion.validate().map_err(McpError::internal_error)?;
3838 completion
3839 }
3840 Outcome::Err(error) => {
3841 return Err(sanitize_handler_error(
3842 request_ctx.cx(),
3843 "completion",
3844 error,
3845 ));
3846 }
3847 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
3848 Outcome::Panicked(_payload) => {
3849 return Err(sanitized_handler_panic(request_ctx.cx(), "completion"));
3850 }
3851 };
3852
3853 Ok(FinalCompletionResult { completion })
3854 }
3855
3856 pub(crate) fn dispatch_stateless(
3868 &self,
3869 request_ctx: &McpContext,
3870 request: &JsonRpcRequest,
3871 ) -> McpResult<serde_json::Value> {
3872 let continuation_cancellation = fastmcp_core::McpRequestCancellation::new();
3877 self.dispatch_stateless_with_continuation_cancellation(
3878 request_ctx,
3879 request,
3880 &continuation_cancellation,
3881 )
3882 }
3883
3884 pub(crate) fn dispatch_stateless_with_raw_params(
3890 &self,
3891 request_ctx: &McpContext,
3892 request: &JsonRpcRequest,
3893 raw_params: Option<&str>,
3894 ) -> McpResult<serde_json::Value> {
3895 let continuation_cancellation = fastmcp_core::McpRequestCancellation::new();
3896 self.dispatch_stateless_with_continuation_cancellation_and_raw_params(
3897 request_ctx,
3898 request,
3899 raw_params,
3900 &continuation_cancellation,
3901 )
3902 }
3903
3904 pub(crate) fn dispatch_stateless_with_continuation_cancellation(
3905 &self,
3906 request_ctx: &McpContext,
3907 request: &JsonRpcRequest,
3908 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
3909 ) -> McpResult<serde_json::Value> {
3910 self.dispatch_stateless_with_continuation_cancellation_and_raw_params(
3911 request_ctx,
3912 request,
3913 None,
3914 continuation_cancellation,
3915 )
3916 }
3917
3918 pub(crate) fn dispatch_stateless_with_continuation_cancellation_and_raw_params(
3921 &self,
3922 request_ctx: &McpContext,
3923 request: &JsonRpcRequest,
3924 raw_params: Option<&str>,
3925 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
3926 ) -> McpResult<serde_json::Value> {
3927 block_on(self.dispatch_stateless_in_request(
3928 request_ctx,
3929 request_ctx.cx(),
3930 request,
3931 raw_params,
3932 continuation_cancellation,
3933 ))
3934 }
3935
3936 pub(crate) async fn dispatch_stateless_owned(
3945 self: Arc<Self>,
3946 request_ctx: McpContext,
3947 request: JsonRpcRequest,
3948 ) -> McpResult<serde_json::Value> {
3949 self.dispatch_stateless_owned_with_continuation_cancellation(
3950 request_ctx,
3951 request,
3952 fastmcp_core::McpRequestCancellation::new(),
3953 )
3954 .await
3955 }
3956
3957 pub(crate) async fn dispatch_stateless_owned_with_continuation_cancellation(
3963 self: Arc<Self>,
3964 request_ctx: McpContext,
3965 request: JsonRpcRequest,
3966 continuation_cancellation: fastmcp_core::McpRequestCancellation,
3967 ) -> McpResult<serde_json::Value> {
3968 self.dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
3969 request_ctx,
3970 request,
3971 None,
3972 continuation_cancellation,
3973 )
3974 .await
3975 }
3976
3977 pub(crate) async fn dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
3981 self: Arc<Self>,
3982 request_ctx: McpContext,
3983 request: JsonRpcRequest,
3984 raw_params: Option<Arc<str>>,
3985 continuation_cancellation: fastmcp_core::McpRequestCancellation,
3986 ) -> McpResult<serde_json::Value> {
3987 if let Some(error) = budget_error(&request_ctx) {
3988 return Err(error);
3989 }
3990
3991 let join_cx = request_ctx.cx().clone();
3992 let dispatch_ctx = request_ctx.clone();
3993 let spawn_self = Arc::clone(&self);
3994 let spawn_request = request.clone();
3995 let spawn_raw_params = raw_params.clone();
3996 let spawn_continuation_cancellation = continuation_cancellation.clone();
3997 let mut task = match request_ctx.cx().spawn(move |child_cx| async move {
3998 spawn_self
3999 .dispatch_stateless_in_request(
4000 &dispatch_ctx,
4001 &child_cx,
4002 &spawn_request,
4003 spawn_raw_params.as_deref(),
4004 &spawn_continuation_cancellation,
4005 )
4006 .await
4007 }) {
4008 Ok(task) => task,
4009 Err(asupersync::runtime::state::SpawnError::RuntimeUnavailable) => {
4015 return self
4016 .dispatch_stateless_in_request(
4017 &request_ctx,
4018 request_ctx.cx(),
4019 &request,
4020 raw_params.as_deref(),
4021 &continuation_cancellation,
4022 )
4023 .await;
4024 }
4025 Err(_error) => {
4026 return Err(McpError::internal_error(
4027 "request-owned modern dispatch could not be scheduled",
4028 ));
4029 }
4030 };
4031
4032 match task.join(&join_cx).await {
4033 Ok(result) => result,
4034 Err(asupersync::runtime::JoinError::Panicked(_payload)) => {
4035 Err(sanitized_handler_panic(&join_cx, "modern_dispatch"))
4036 }
4037 Err(_error) => Err(McpError::request_cancelled()),
4038 }
4039 }
4040
4041 async fn dispatch_stateless_in_request(
4042 &self,
4043 request_ctx: &McpContext,
4044 request_cx: &Cx,
4045 request: &JsonRpcRequest,
4046 raw_params: Option<&str>,
4047 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4048 ) -> McpResult<serde_json::Value> {
4049 if request_cx.is_cancel_requested() {
4050 return Err(McpError::request_cancelled());
4051 }
4052 if let Some(error) = budget_error(request_ctx) {
4053 return Err(error);
4054 }
4055
4056 let params = request.params.as_ref();
4057 let result = match request.method.as_str() {
4058 "ping" => serde_json::json!({}),
4062 COMPLETION_COMPLETE => {
4063 let request =
4064 CoreRequest::decode(ProtocolEra::Modern2026, COMPLETION_COMPLETE, params)
4065 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4066 let CoreRequest::Final(FinalCoreRequest::Completion(params)) = request else {
4067 return Err(McpError::internal_error(
4068 "modern completion dispatch selected another core request",
4069 ));
4070 };
4071 encode_stateless_handler_result(
4072 self.handle_completion_final_in_request(request_ctx, request_cx, params)
4073 .await,
4074 )?
4075 }
4076 "tools/list" => {
4077 let request = CoreRequest::decode(ProtocolEra::Modern2026, "tools/list", params)
4078 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4079 let CoreRequest::Final(FinalCoreRequest::ToolsList(params)) = request else {
4080 return Err(McpError::internal_error(
4081 "modern tools/list dispatch selected another core request",
4082 ));
4083 };
4084 encode_final_core_result(
4085 self.handle_final_tools_list(request_ctx, params),
4086 |result| FinalCoreResult::ToolsList {
4087 result,
4088 diagnostic: None,
4089 },
4090 )?
4091 }
4092 "tools/call" => {
4093 self.admit_final_mrtr_response_map(params)?;
4094 let request = CoreRequest::decode_with_raw_params(
4095 ProtocolEra::Modern2026,
4096 "tools/call",
4097 params,
4098 raw_params,
4099 )
4100 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4101 let CoreRequest::Final(FinalCoreRequest::ToolsCall(params)) = request else {
4102 return Err(McpError::internal_error(
4103 "modern tools/call dispatch selected another core request",
4104 ));
4105 };
4106 let binding = final_mrtr_binding(
4107 request_ctx,
4108 "tools/call",
4109 params.name.clone(),
4110 ¶ms.arguments,
4111 )?;
4112 match self.resolve_final_mrtr_retry(
4113 params.request_state.as_deref(),
4114 params.input_responses.as_ref(),
4115 binding.as_ref(),
4116 )? {
4117 FinalMrtrDispatch::InputRequired(result) => result,
4118 FinalMrtrDispatch::Fresh => {
4119 self.dispatch_final_tools_call(
4120 request_ctx,
4121 request_cx,
4122 params,
4123 binding,
4124 None,
4125 continuation_cancellation,
4126 )
4127 .await?
4128 }
4129 FinalMrtrDispatch::Resume(resume_inputs) => {
4130 self.dispatch_final_tools_call(
4131 request_ctx,
4132 request_cx,
4133 params,
4134 binding,
4135 Some(resume_inputs),
4136 continuation_cancellation,
4137 )
4138 .await?
4139 }
4140 }
4141 }
4142 "resources/list" => {
4143 let request =
4144 CoreRequest::decode(ProtocolEra::Modern2026, "resources/list", params)
4145 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4146 let CoreRequest::Final(FinalCoreRequest::ResourcesList(params)) = &request else {
4147 return Err(McpError::internal_error(
4148 "modern resources/list dispatch selected another core request",
4149 ));
4150 };
4151 encode_final_core_result(
4152 self.handle_final_resources_list(request_ctx, params.clone()),
4153 |result| FinalCoreResult::ResourcesList {
4154 result,
4155 diagnostic: None,
4156 },
4157 )?
4158 }
4159 "resources/templates/list" => {
4160 let request = CoreRequest::decode(
4161 ProtocolEra::Modern2026,
4162 "resources/templates/list",
4163 params,
4164 )
4165 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4166 let CoreRequest::Final(FinalCoreRequest::ResourceTemplatesList(params)) = &request
4167 else {
4168 return Err(McpError::internal_error(
4169 "modern resources/templates/list dispatch selected another core request",
4170 ));
4171 };
4172 encode_final_core_result(
4173 self.handle_final_resource_templates_list(request_ctx, params.clone()),
4174 |result| FinalCoreResult::ResourceTemplatesList {
4175 result,
4176 diagnostic: None,
4177 },
4178 )?
4179 }
4180 "resources/read" => {
4181 self.admit_final_mrtr_response_map(params)?;
4182 let request = CoreRequest::decode_with_raw_params(
4183 ProtocolEra::Modern2026,
4184 "resources/read",
4185 params,
4186 raw_params,
4187 )
4188 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4189 let CoreRequest::Final(FinalCoreRequest::ResourcesRead(params)) = &request else {
4190 return Err(McpError::internal_error(
4191 "modern resources/read dispatch selected another core request",
4192 ));
4193 };
4194 let binding = final_mrtr_binding(
4195 request_ctx,
4196 "resources/read",
4197 params.uri.as_str().to_owned(),
4198 &(),
4199 )?;
4200 match self.resolve_final_mrtr_retry(
4201 params.request_state.as_deref(),
4202 params.input_responses.as_ref(),
4203 binding.as_ref(),
4204 )? {
4205 FinalMrtrDispatch::InputRequired(result) => result,
4206 FinalMrtrDispatch::Fresh => {
4207 self.dispatch_final_resources_read(
4208 request_ctx,
4209 request_cx,
4210 params.clone(),
4211 binding,
4212 None,
4213 continuation_cancellation,
4214 )
4215 .await?
4216 }
4217 FinalMrtrDispatch::Resume(resume_inputs) => {
4218 self.dispatch_final_resources_read(
4219 request_ctx,
4220 request_cx,
4221 params.clone(),
4222 binding,
4223 Some(resume_inputs),
4224 continuation_cancellation,
4225 )
4226 .await?
4227 }
4228 }
4229 }
4230 "prompts/list" => {
4231 let request = CoreRequest::decode(ProtocolEra::Modern2026, "prompts/list", params)
4232 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4233 let CoreRequest::Final(FinalCoreRequest::PromptsList(params)) = &request else {
4234 return Err(McpError::internal_error(
4235 "modern prompts/list dispatch selected another core request",
4236 ));
4237 };
4238 encode_final_core_result(
4239 self.handle_final_prompts_list(request_ctx, params.clone()),
4240 |result| FinalCoreResult::PromptsList {
4241 result,
4242 diagnostic: None,
4243 },
4244 )?
4245 }
4246 "prompts/get" => {
4247 self.admit_final_mrtr_response_map(params)?;
4248 let request = CoreRequest::decode_with_raw_params(
4249 ProtocolEra::Modern2026,
4250 "prompts/get",
4251 params,
4252 raw_params,
4253 )
4254 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4255 let CoreRequest::Final(FinalCoreRequest::PromptsGet(params)) = request else {
4256 return Err(McpError::internal_error(
4257 "modern prompts/get dispatch selected another core request",
4258 ));
4259 };
4260 let binding = final_mrtr_binding(
4261 request_ctx,
4262 "prompts/get",
4263 params.name.clone(),
4264 ¶ms.arguments,
4265 )?;
4266 match self.resolve_final_mrtr_retry(
4267 params.request_state.as_deref(),
4268 params.input_responses.as_ref(),
4269 binding.as_ref(),
4270 )? {
4271 FinalMrtrDispatch::InputRequired(result) => result,
4272 FinalMrtrDispatch::Fresh => {
4273 self.dispatch_final_prompts_get(
4274 request_ctx,
4275 request_cx,
4276 params,
4277 binding,
4278 None,
4279 continuation_cancellation,
4280 )
4281 .await?
4282 }
4283 FinalMrtrDispatch::Resume(resume_inputs) => {
4284 self.dispatch_final_prompts_get(
4285 request_ctx,
4286 request_cx,
4287 params,
4288 binding,
4289 Some(resume_inputs),
4290 continuation_cancellation,
4291 )
4292 .await?
4293 }
4294 }
4295 }
4296 _ => return Err(McpError::method_not_found(&request.method)),
4297 };
4298
4299 if request_cx.is_cancel_requested() {
4300 return Err(McpError::request_cancelled());
4301 }
4302 if let Some(error) = budget_error(request_ctx) {
4303 return Err(error);
4304 }
4305 Ok(result)
4306 }
4307
4308 fn admit_final_mrtr_response_map(&self, params: Option<&serde_json::Value>) -> McpResult<()> {
4311 let Some(params) = params else {
4312 return Ok(());
4313 };
4314 admit_mrtr_raw_json_value(params, MAX_MRTR_RAW_PARAMS_BYTES)?;
4315 let Some(input_responses) = params
4316 .as_object()
4317 .and_then(|members| members.get("inputResponses"))
4318 else {
4319 return Ok(());
4320 };
4321 admit_mrtr_raw_json_value(input_responses, MAX_MRTR_RAW_INPUT_RESPONSES_BYTES)?;
4322 let Some(input_responses) = input_responses.as_object() else {
4323 return Ok(());
4324 };
4325 if input_responses.len() > self.mrtr_exchanges.max_inputs_per_round() {
4326 return Err(McpError::invalid_params(
4327 "MRTR inputResponses exceeds the configured bound",
4328 ));
4329 }
4330 Ok(())
4331 }
4332
4333 #[cfg(feature = "tasks")]
4334 fn admit_final_task_tool(&self, metadata: &OpenMetadata) -> McpResult<&FinalTaskRuntime> {
4335 require_final_tasks_capability(metadata)?;
4336 let runtime = self.final_task_runtime.as_ref().ok_or_else(|| {
4337 McpError::internal_error("task-capable tool requires an installed final Tasks runtime")
4338 })?;
4339 runtime.ensure_task_service_ready()?;
4340 Ok(runtime)
4341 }
4342
4343 fn issue_final_mrtr_input_required(
4344 &self,
4345 request_ctx: &McpContext,
4346 continuation_cancellation: fastmcp_core::McpRequestCancellation,
4347 binding: MrtrExchangeBinding,
4348 handler_result: InputRequiredResult,
4349 ) -> McpResult<serde_json::Value> {
4350 let input_requests = handler_mrtr_input_requests(request_ctx, &handler_result)?;
4354 let required =
4355 self.mrtr_exchanges
4356 .issue_bound(continuation_cancellation, binding, input_requests)?;
4357 encode_mrtr_input_required_result(required)
4358 }
4359
4360 fn resolve_final_mrtr_retry(
4366 &self,
4367 request_state: Option<&str>,
4368 input_responses: Option<&FinalInputResponses>,
4369 binding: Option<&MrtrExchangeBinding>,
4370 ) -> McpResult<FinalMrtrDispatch> {
4371 match (request_state, input_responses) {
4372 (None, None) => Ok(FinalMrtrDispatch::Fresh),
4373 (Some(request_state), None) => {
4374 let binding = binding.ok_or_else(|| {
4375 McpError::invalid_params("MRTR retries require session state")
4376 })?;
4377 match self
4378 .mrtr_exchanges
4379 .accept_state_only_bound(request_state, binding)?
4380 {
4381 MrtrRetry::Complete(inputs) => Ok(FinalMrtrDispatch::Resume(inputs)),
4382 MrtrRetry::InputRequired(_) => Err(McpError::internal_error(
4383 "state-only MRTR retry cannot issue further input requests",
4384 )),
4385 }
4386 }
4387 (Some(request_state), Some(input_responses)) => {
4388 let binding = binding.ok_or_else(|| {
4389 McpError::invalid_params("MRTR retries require session state")
4390 })?;
4391 match self.mrtr_exchanges.accept_final_input_responses_bound(
4392 request_state,
4393 binding,
4394 input_responses,
4395 )? {
4396 MrtrRetry::Complete(inputs) => Ok(FinalMrtrDispatch::Resume(inputs)),
4397 MrtrRetry::InputRequired(result) => encode_mrtr_input_required_result(result)
4398 .map(FinalMrtrDispatch::InputRequired),
4399 }
4400 }
4401 _ => Err(McpError::invalid_params(
4402 "final MRTR inputResponses require requestState",
4403 )),
4404 }
4405 }
4406
4407 async fn dispatch_final_tools_call(
4408 &self,
4409 request_ctx: &McpContext,
4410 request_cx: &Cx,
4411 params: FinalCallToolParams,
4412 binding: Option<MrtrExchangeBinding>,
4413 resume_inputs: Option<MrtrCompletedInputs>,
4414 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4415 ) -> McpResult<serde_json::Value> {
4416 #[cfg(feature = "tasks")]
4417 let request_metadata = params.meta.clone();
4418 let session_state = request_ctx
4419 .session_state()
4420 .cloned()
4421 .unwrap_or_else(SessionState::new);
4422 let outcome = self
4423 .handle_tools_call_final_in_request(
4424 request_ctx,
4425 request_cx,
4426 params,
4427 session_state,
4428 None,
4429 None,
4430 resume_inputs.as_ref(),
4431 )
4432 .await?;
4433 match outcome {
4434 FinalToolOutcome::Complete(result) => encode_final_tools_call_result(Ok(result)),
4435 FinalToolOutcome::InputRequired(result) => {
4436 let binding = binding.ok_or_else(|| {
4437 McpError::internal_error(
4438 "MRTR input_required requires session state to bind retries",
4439 )
4440 })?;
4441 self.issue_final_mrtr_input_required(
4442 request_ctx,
4443 continuation_cancellation.clone(),
4444 binding,
4445 result,
4446 )
4447 }
4448 #[cfg(feature = "tasks")]
4449 FinalToolOutcome::CreateTask {
4450 work_descriptor,
4451 status_message,
4452 } => {
4453 #[cfg(all(feature = "proxy", feature = "tasks"))]
4454 if let Some(relay) = self.final_task_relay.as_ref() {
4455 require_final_tasks_capability(&request_metadata)?;
4460 if let Some(result) = relay.admit_carried_task(&work_descriptor)? {
4461 return encode_final_task_result(result);
4462 }
4463 return Err(McpError::internal_error(
4464 "a proxy final Tasks relay received a non-relayed CreateTask outcome",
4465 ));
4466 }
4467 let runtime = self.admit_final_task_tool(&request_metadata)?;
4472 encode_final_task_result(
4473 runtime.create_task_with_work(work_descriptor, status_message)?,
4474 )
4475 }
4476 }
4477 }
4478
4479 async fn dispatch_final_resources_read(
4480 &self,
4481 request_ctx: &McpContext,
4482 request_cx: &Cx,
4483 params: FinalReadResourceParams,
4484 binding: Option<MrtrExchangeBinding>,
4485 resume_inputs: Option<MrtrCompletedInputs>,
4486 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4487 ) -> McpResult<serde_json::Value> {
4488 let session_state = request_ctx
4489 .session_state()
4490 .cloned()
4491 .unwrap_or_else(SessionState::new);
4492 match self
4493 .handle_resources_read_final_in_request(
4494 request_ctx,
4495 request_cx,
4496 params,
4497 session_state,
4498 None,
4499 None,
4500 resume_inputs.as_ref(),
4501 )
4502 .await?
4503 {
4504 FinalMethodOutcome::Complete(result) => encode_final_resources_read_result(Ok(result)),
4505 FinalMethodOutcome::InputRequired(result) => {
4506 let binding = binding.ok_or_else(|| {
4507 McpError::internal_error(
4508 "MRTR input_required requires session state to bind retries",
4509 )
4510 })?;
4511 self.issue_final_mrtr_input_required(
4512 request_ctx,
4513 continuation_cancellation.clone(),
4514 binding,
4515 result,
4516 )
4517 }
4518 }
4519 }
4520
4521 async fn dispatch_final_prompts_get(
4522 &self,
4523 request_ctx: &McpContext,
4524 request_cx: &Cx,
4525 params: FinalGetPromptParams,
4526 binding: Option<MrtrExchangeBinding>,
4527 resume_inputs: Option<MrtrCompletedInputs>,
4528 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4529 ) -> McpResult<serde_json::Value> {
4530 let session_state = request_ctx
4531 .session_state()
4532 .cloned()
4533 .unwrap_or_else(SessionState::new);
4534 match self
4535 .handle_prompts_get_final_in_request(
4536 request_ctx,
4537 request_cx,
4538 params,
4539 session_state,
4540 None,
4541 None,
4542 resume_inputs.as_ref(),
4543 )
4544 .await?
4545 {
4546 FinalMethodOutcome::Complete(result) => encode_final_prompts_get_result(Ok(result)),
4547 FinalMethodOutcome::InputRequired(result) => {
4548 let binding = binding.ok_or_else(|| {
4549 McpError::internal_error(
4550 "MRTR input_required requires session state to bind retries",
4551 )
4552 })?;
4553 self.issue_final_mrtr_input_required(
4554 request_ctx,
4555 continuation_cancellation.clone(),
4556 binding,
4557 result,
4558 )
4559 }
4560 }
4561 }
4562
4563 pub fn handle_tools_list(
4568 &self,
4569 request_ctx: &McpContext,
4570 params: ListToolsParams,
4571 session_state: Option<&SessionState>,
4572 ) -> McpResult<ListToolsResult> {
4573 if let Some(error) = budget_error(request_ctx) {
4574 return Err(error);
4575 }
4576
4577 let tag_filters =
4578 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4579 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
4580 Some(&tag_filters)
4581 } else {
4582 None
4583 };
4584 let tools =
4585 crate::catch_extension_unwind(|| self.tools_filtered(session_state, tag_filters))
4586 .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "tool_definition"))?;
4587 let Some(page_size) = self.list_page_size else {
4588 return Ok(ListToolsResult {
4589 tools,
4590 next_cursor: None,
4591 });
4592 };
4593 let query = FinalCatalogQuery::from_tag_filters(
4594 params.include_tags.as_deref(),
4595 params.exclude_tags.as_deref(),
4596 );
4597 let (tools, next_cursor) = page_session_filtered_catalog(
4598 tools,
4599 params.cursor.as_deref(),
4600 Some(page_size),
4601 FinalCatalogKind::Tools,
4602 self.final_catalog_revision,
4603 &query,
4604 session_state.is_some(),
4605 |tool| tool.name.as_str(),
4606 )?;
4607 Ok(ListToolsResult { tools, next_cursor })
4608 }
4609
4610 fn handle_final_tools_list(
4614 &self,
4615 request_ctx: &McpContext,
4616 params: FinalListParams,
4617 ) -> McpResult<FinalListToolsResult> {
4618 if let Some(error) = budget_error(request_ctx) {
4619 return Err(error);
4620 }
4621
4622 let query = FinalCatalogQuery::from_final_list_params(¶ms);
4623 let tag_filters =
4624 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4625 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
4626 Some(&tag_filters)
4627 } else {
4628 None
4629 };
4630 let tools = crate::catch_extension_unwind(|| {
4631 self.tool_order
4632 .iter()
4633 .filter_map(|name| self.tools.get(name))
4634 .filter(|entry| entry.final_registration.is_some())
4635 .map(|entry| entry.definition.clone())
4636 .filter(|tool| tag_filters.is_none_or(|filters| filters.matches(&tool.tags)))
4637 .collect::<Vec<_>>()
4638 })
4639 .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "tool_definition"))?;
4640
4641 let (tools, next_cursor) = page_final_catalog(
4642 tools,
4643 params.cursor.as_deref(),
4644 self.list_page_size,
4645 FinalCatalogKind::Tools,
4646 self.final_catalog_revision,
4647 &query,
4648 None,
4649 )?;
4650 let result = ListToolsResult { tools, next_cursor };
4651 self.project_final_tools_list(request_ctx, result, self.final_cache_hints.clone())
4652 }
4653
4654 fn project_final_tools_list(
4655 &self,
4656 _request_ctx: &McpContext,
4657 result: ListToolsResult,
4658 cache_hints: FinalCacheHintPolicy,
4659 ) -> McpResult<FinalListToolsResult> {
4660 let tools = result
4661 .tools
4662 .into_iter()
4663 .map(|tool| {
4664 let entry = self.tools.get(&tool.name).ok_or_else(|| {
4665 McpError::internal_error("listed tool is absent from the router catalog")
4666 })?;
4667 let final_registration = entry.final_registration.as_ref().ok_or_else(|| {
4668 McpError::internal_error(
4669 "legacy-only tool reached the final catalog projection",
4670 )
4671 })?;
4672
4673 Ok(final_registration.final_definition.clone())
4674 })
4675 .collect::<McpResult<Vec<_>>>()?;
4676 Ok(FinalListToolsResult {
4677 tools,
4678 next_cursor: result.next_cursor,
4679 ttl_ms: cache_hints.list_ttl_ms,
4680 cache_scope: cache_hints.scope,
4681 })
4682 }
4683
4684 fn handle_final_resources_list(
4685 &self,
4686 request_ctx: &McpContext,
4687 params: FinalListParams,
4688 ) -> McpResult<FinalListResourcesResult> {
4689 if let Some(error) = budget_error(request_ctx) {
4690 return Err(error);
4691 }
4692 let query = FinalCatalogQuery::from_final_list_params(¶ms);
4693 let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4694 let filters =
4695 (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4696 let resources = self
4697 .resource_order
4698 .iter()
4699 .filter_map(|uri| self.final_resources.get(uri).map(|entry| (uri, entry)))
4700 .filter(|(_, entry)| {
4701 filters
4702 .as_ref()
4703 .is_none_or(|filters| filters.matches(&entry.tags))
4704 })
4705 .map(|(_, entry)| {
4706 admit_final_resource_uri(
4707 entry.uri_use_policy,
4708 &entry.definition.uri,
4709 FinalResourceUriUse::CatalogResource,
4710 )?;
4711 Ok(entry.definition.clone())
4712 })
4713 .collect::<McpResult<Vec<_>>>()?;
4714 let (resources, next_cursor) = page_final_catalog(
4715 resources,
4716 params.cursor.as_deref(),
4717 self.list_page_size,
4718 FinalCatalogKind::Resources,
4719 self.final_catalog_revision,
4720 &query,
4721 None,
4722 )?;
4723 Ok(FinalListResourcesResult {
4724 resources,
4725 next_cursor,
4726 ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4727 cache_scope: self.final_cache_hints.scope,
4728 })
4729 }
4730
4731 fn handle_final_resource_templates_list(
4732 &self,
4733 request_ctx: &McpContext,
4734 params: FinalListParams,
4735 ) -> McpResult<FinalListResourceTemplatesResult> {
4736 if let Some(error) = budget_error(request_ctx) {
4737 return Err(error);
4738 }
4739 let query = FinalCatalogQuery::from_final_list_params(¶ms);
4740 let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4741 let filters =
4742 (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4743 let resource_templates = self
4744 .resource_template_order
4745 .iter()
4746 .filter_map(|key| self.resource_templates.get(key).map(|entry| (key, entry)))
4747 .filter_map(|(_, entry)| {
4748 entry
4749 .final_definition
4750 .as_ref()
4751 .map(|definition| (definition, &entry.template.tags, entry.uri_use_policy))
4752 })
4753 .filter(|(_, tags, _)| filters.as_ref().is_none_or(|filters| filters.matches(tags)))
4754 .map(|(definition, _, uri_use_policy)| {
4755 admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
4756 Ok(definition.clone())
4757 })
4758 .collect::<McpResult<Vec<_>>>()?;
4759 let (resource_templates, next_cursor) = page_final_catalog(
4760 resource_templates,
4761 params.cursor.as_deref(),
4762 self.list_page_size,
4763 FinalCatalogKind::ResourceTemplates,
4764 self.final_catalog_revision,
4765 &query,
4766 None,
4767 )?;
4768 Ok(FinalListResourceTemplatesResult {
4769 resource_templates,
4770 next_cursor,
4771 ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4772 cache_scope: self.final_cache_hints.scope,
4773 })
4774 }
4775
4776 fn handle_final_prompts_list(
4777 &self,
4778 request_ctx: &McpContext,
4779 params: FinalListParams,
4780 ) -> McpResult<FinalListPromptsResult> {
4781 if let Some(error) = budget_error(request_ctx) {
4782 return Err(error);
4783 }
4784 let query = FinalCatalogQuery::from_final_list_params(¶ms);
4785 let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4786 let filters =
4787 (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4788 let prompts = self
4789 .prompt_order
4790 .iter()
4791 .filter_map(|name| self.final_prompts.get(name).map(|entry| (name, entry)))
4792 .filter(|(_, entry)| {
4793 filters
4794 .as_ref()
4795 .is_none_or(|filters| filters.matches(&entry.tags))
4796 })
4797 .map(|(_, entry)| entry.definition.clone())
4798 .collect();
4799 let (prompts, next_cursor) = page_final_catalog(
4800 prompts,
4801 params.cursor.as_deref(),
4802 self.list_page_size,
4803 FinalCatalogKind::Prompts,
4804 self.final_catalog_revision,
4805 &query,
4806 None,
4807 )?;
4808 Ok(FinalListPromptsResult {
4809 prompts,
4810 next_cursor,
4811 ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4812 cache_scope: self.final_cache_hints.scope,
4813 })
4814 }
4815
4816 pub fn handle_tools_call(
4826 &self,
4827 request_ctx: &McpContext,
4828 params: CallToolParams,
4829 session_state: SessionState,
4830 notification_sender: Option<&NotificationSender>,
4831 bidirectional_senders: Option<&BidirectionalSenders>,
4832 ) -> McpResult<CallToolResult> {
4833 block_on(self.handle_tools_call_in_request(
4834 request_ctx,
4835 request_ctx.cx(),
4836 params,
4837 session_state,
4838 notification_sender,
4839 bidirectional_senders,
4840 ))
4841 }
4842
4843 pub(crate) async fn handle_tools_call_in_request(
4844 &self,
4845 request_ctx: &McpContext,
4846 request_cx: &Cx,
4847 params: CallToolParams,
4848 session_state: SessionState,
4849 notification_sender: Option<&NotificationSender>,
4850 bidirectional_senders: Option<&BidirectionalSenders>,
4851 ) -> McpResult<CallToolResult> {
4852 debug!(
4853 target: targets::HANDLER,
4854 "calling modern tool; tool_key={}; arguments_present={}",
4855 safe_log_label(¶ms.name),
4856 params.arguments.is_some()
4857 );
4858
4859 let dispatch_started_at = request_ctx.cx().now();
4860 if let Some(error) = budget_error(request_ctx) {
4861 return Err(error);
4862 }
4863 if !session_state.is_tool_enabled(¶ms.name) {
4864 return Err(McpError::new(
4865 McpErrorCode::MethodNotFound,
4866 format!("Tool '{}' is disabled for this session", params.name),
4867 ));
4868 }
4869
4870 let entry = self
4871 .tools
4872 .get(¶ms.name)
4873 .ok_or_else(|| McpError::method_not_found(&format!("tool: {}", params.name)))?;
4874 if !entry.legacy_enabled {
4875 return Err(McpError::method_not_found(&format!(
4876 "tool: {}",
4877 params.name
4878 )));
4879 }
4880 let handler = &entry.handler;
4881 let arguments = params.arguments.unwrap_or_else(|| serde_json::json!({}));
4882 let validation_result = if self.strict_input_validation {
4883 validate_strict(&entry.definition.input_schema, &arguments)
4884 } else {
4885 validate(&entry.definition.input_schema, &arguments)
4886 };
4887 if let Err(validation_errors) = validation_result {
4888 let error_messages: Vec<String> = validation_errors
4889 .iter()
4890 .map(|error| format!("{}: {}", error.path, error.message))
4891 .collect();
4892 return Err(McpError::invalid_params(format!(
4893 "Input validation failed: {}",
4894 error_messages.join("; ")
4895 )));
4896 }
4897
4898 let progress_marker = params
4899 .meta
4900 .as_ref()
4901 .and_then(|meta| meta.progress_marker.clone());
4902 let ctx = derive_handler_context(
4903 request_ctx,
4904 progress_marker,
4905 notification_sender,
4906 bidirectional_senders,
4907 ProtocolEra::Legacy2024,
4908 );
4909 let handler_timeout =
4910 read_handler_timeout(request_ctx.cx(), "tool_timeout", || handler.timeout())?;
4911 let effective_budget = compose_handler_budget(
4912 request_ctx.cx().budget(),
4913 request_ctx.budget(),
4914 handler_timeout,
4915 dispatch_started_at,
4916 );
4917 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
4918 let outcome =
4919 run_handler_in_request(&ctx, request_cx, effective_budget, "tool", |child_cx| {
4920 handler.call_async_in_request(&ctx, child_cx, arguments)
4921 })
4922 .await?;
4923
4924 match outcome {
4925 Outcome::Ok(content) => Ok(CallToolResult {
4926 content: legacy_contents_from_handler(content)?,
4927 is_error: false,
4928 meta: None,
4929 additional: BTreeMap::new(),
4930 }),
4931 Outcome::Err(error) => {
4932 let error = sanitize_handler_error(request_ctx.cx(), "tool", error);
4933 if is_framework_terminal_tool_error(error.code) {
4934 return Err(error);
4935 }
4936 Ok(CallToolResult {
4937 content: vec![LegacyContent::Text {
4938 text: error.message,
4939 annotations: None,
4940 additional: BTreeMap::new(),
4941 }],
4942 is_error: true,
4943 meta: None,
4944 additional: BTreeMap::new(),
4945 })
4946 }
4947 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
4948 Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "tool")),
4949 }
4950 }
4951
4952 async fn handle_tools_call_final_in_request(
4959 &self,
4960 request_ctx: &McpContext,
4961 request_cx: &Cx,
4962 params: FinalCallToolParams,
4963 session_state: SessionState,
4964 notification_sender: Option<&NotificationSender>,
4965 bidirectional_senders: Option<&BidirectionalSenders>,
4966 resume_inputs: Option<&MrtrCompletedInputs>,
4967 ) -> McpResult<FinalToolOutcome> {
4968 debug!(
4969 target: targets::HANDLER,
4970 "calling final tool; tool_key={}; arguments_present={}",
4971 safe_log_label(¶ms.name),
4972 !params.arguments.is_absent()
4973 );
4974
4975 let dispatch_started_at = request_ctx.cx().now();
4976 if request_cx.is_cancel_requested() {
4977 return Err(McpError::request_cancelled());
4978 }
4979 if let Some(error) = budget_error(request_ctx) {
4980 return Err(error);
4981 }
4982 let progress_marker = final_progress_marker(¶ms.meta)?;
4983 if !session_state.is_tool_enabled(¶ms.name)
4988 || !request_ctx.is_tool_enabled(¶ms.name)
4989 {
4990 return Err(McpError::new(
4991 McpErrorCode::MethodNotFound,
4992 format!("Tool '{}' is disabled for this session", params.name),
4993 ));
4994 }
4995
4996 let entry = self
4997 .tools
4998 .get(¶ms.name)
4999 .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {}", params.name)))?;
5000 let final_registration = entry
5001 .final_registration
5002 .as_ref()
5003 .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {}", params.name)))?;
5004 let handler = &entry.handler;
5005 if handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none() {
5006 return Err(McpError::invalid_params(
5007 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
5008 ));
5009 }
5010 let input_schema = final_registration.schemas.input.as_ref();
5011 let output_schema = final_registration.schemas.output.as_ref();
5012 #[cfg(feature = "tasks")]
5015 let declares_final_tasks = final_registration.declares_final_tasks;
5016 let arguments = params
5017 .arguments
5018 .into_value()
5019 .unwrap_or_else(|| serde_json::json!({}));
5020 let input_validation_failed = match input_schema {
5021 Some(schema) => {
5022 let validation = if self.strict_input_validation {
5023 validate_strict(schema.schema(), &arguments)
5024 } else {
5025 schema.validate(&arguments)
5026 };
5027 validation.is_err()
5028 }
5029 None if self.strict_input_validation => {
5030 validate_strict(
5035 &final_registration.final_definition.input_schema,
5036 &arguments,
5037 )
5038 .is_err()
5039 }
5040 None => false,
5041 };
5042 if input_validation_failed {
5043 let mut result = crate::handler::promote_legacy_tool_content(vec![Content::text(
5044 "Tool arguments do not match the declared input schema.",
5045 )])?;
5046 result.payload.is_error = true;
5047 result.payload.structured_content = final_registration
5048 .schemas
5049 .errors
5050 .as_ref()
5051 .map(|errors| errors.input_validation.clone());
5052 return Ok(FinalToolOutcome::Complete(result));
5053 }
5054
5055 #[cfg(all(feature = "proxy", feature = "tasks"))]
5061 if declares_final_tasks && self.final_task_relay.is_some() {
5062 require_final_tasks_capability(¶ms.meta)?;
5063 }
5064
5065 let ctx = derive_handler_context(
5066 request_ctx,
5067 progress_marker,
5068 notification_sender,
5069 bidirectional_senders,
5070 ProtocolEra::Modern2026,
5071 );
5072 let handler_timeout =
5073 read_handler_timeout(request_ctx.cx(), "tool_timeout", || handler.timeout())?;
5074 let effective_budget = compose_handler_budget(
5075 request_ctx.cx().budget(),
5076 request_ctx.budget(),
5077 handler_timeout,
5078 dispatch_started_at,
5079 );
5080 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5081 let outcome =
5082 run_handler_in_request(&ctx, request_cx, effective_budget, "tool", |child_cx| {
5083 handler.call_final_outcome_async_resuming_in_request(
5088 &ctx,
5089 child_cx,
5090 arguments,
5091 resume_inputs,
5092 )
5093 })
5094 .await?;
5095
5096 match outcome {
5097 Outcome::Ok(result) => {
5098 match &result {
5099 FinalToolOutcome::Complete(result) => {
5100 if let Some(output_schema) = output_schema {
5101 let structured_content = result
5102 .payload
5103 .structured_content
5104 .as_ref()
5105 .ok_or_else(|| {
5106 McpError::internal_error(
5107 "tool output is missing structuredContent required by the declared output schema",
5108 )
5109 })?;
5110 if output_schema.validate(structured_content).is_err() {
5111 return Err(McpError::internal_error(
5112 "tool output does not match the declared output schema",
5113 ));
5114 }
5115 }
5116 }
5117 #[cfg(feature = "tasks")]
5118 FinalToolOutcome::CreateTask { .. } if !declares_final_tasks => {
5119 return Err(McpError::invalid_request(
5120 "tool returned CreateTask without declaring final Tasks capability",
5121 ));
5122 }
5123 FinalToolOutcome::InputRequired(_) => {}
5124 #[cfg(feature = "tasks")]
5125 FinalToolOutcome::CreateTask { .. } => {}
5126 }
5127 Ok(result)
5128 }
5129 Outcome::Err(error) => {
5130 let error = sanitize_handler_error(request_ctx.cx(), "tool", error);
5131 if is_framework_terminal_tool_error(error.code) {
5132 return Err(error);
5133 }
5134 let mut result =
5135 crate::handler::promote_legacy_tool_content(vec![Content::Text {
5136 text: error.message,
5137 }])?;
5138 result.payload.is_error = true;
5139 result.payload.structured_content = final_registration
5140 .schemas
5141 .errors
5142 .as_ref()
5143 .map(|errors| errors.handler.clone());
5144 Ok(FinalToolOutcome::Complete(result))
5145 }
5146 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5147 Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "tool")),
5148 }
5149 }
5150
5151 pub fn handle_resources_list(
5156 &self,
5157 request_ctx: &McpContext,
5158 params: ListResourcesParams,
5159 session_state: Option<&SessionState>,
5160 ) -> McpResult<ListResourcesResult> {
5161 if let Some(error) = budget_error(request_ctx) {
5162 return Err(error);
5163 }
5164
5165 let tag_filters =
5166 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5167 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5168 Some(&tag_filters)
5169 } else {
5170 None
5171 };
5172 let resources = crate::catch_extension_unwind(|| {
5173 self.resources_filtered(session_state, tag_filters)
5174 })
5175 .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "resource_definition"))?;
5176 let Some(page_size) = self.list_page_size else {
5177 return Ok(ListResourcesResult {
5178 resources,
5179 next_cursor: None,
5180 });
5181 };
5182 let query = FinalCatalogQuery::from_tag_filters(
5183 params.include_tags.as_deref(),
5184 params.exclude_tags.as_deref(),
5185 );
5186 let (resources, next_cursor) = page_session_filtered_catalog(
5187 resources,
5188 params.cursor.as_deref(),
5189 Some(page_size),
5190 FinalCatalogKind::Resources,
5191 self.final_catalog_revision,
5192 &query,
5193 session_state.is_some(),
5194 |resource| resource.uri.as_str(),
5195 )?;
5196 Ok(ListResourcesResult {
5197 resources,
5198 next_cursor,
5199 })
5200 }
5201
5202 pub fn handle_resource_templates_list(
5207 &self,
5208 request_ctx: &McpContext,
5209 params: ListResourceTemplatesParams,
5210 session_state: Option<&SessionState>,
5211 ) -> McpResult<ListResourceTemplatesResult> {
5212 if let Some(error) = budget_error(request_ctx) {
5213 return Err(error);
5214 }
5215
5216 let tag_filters =
5217 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5218 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5219 Some(&tag_filters)
5220 } else {
5221 None
5222 };
5223 let templates = self.resource_templates_filtered(session_state, tag_filters);
5224 let Some(page_size) = self.list_page_size else {
5225 return Ok(ListResourceTemplatesResult {
5226 resource_templates: templates,
5227 next_cursor: None,
5228 });
5229 };
5230 let query = FinalCatalogQuery::from_tag_filters(
5231 params.include_tags.as_deref(),
5232 params.exclude_tags.as_deref(),
5233 );
5234 let (resource_templates, next_cursor) = page_session_filtered_catalog(
5235 templates,
5236 params.cursor.as_deref(),
5237 Some(page_size),
5238 FinalCatalogKind::ResourceTemplates,
5239 self.final_catalog_revision,
5240 &query,
5241 session_state.is_some(),
5242 |template| template.uri_template.as_str(),
5243 )?;
5244 Ok(ListResourceTemplatesResult {
5245 resource_templates,
5246 next_cursor,
5247 })
5248 }
5249
5250 pub fn handle_resources_read(
5260 &self,
5261 request_ctx: &McpContext,
5262 params: &ReadResourceParams,
5263 session_state: SessionState,
5264 notification_sender: Option<&NotificationSender>,
5265 bidirectional_senders: Option<&BidirectionalSenders>,
5266 ) -> McpResult<ReadResourceResult> {
5267 block_on(self.handle_resources_read_in_request(
5268 request_ctx,
5269 request_ctx.cx(),
5270 params,
5271 session_state,
5272 notification_sender,
5273 bidirectional_senders,
5274 ))
5275 }
5276
5277 pub(crate) async fn handle_resources_read_in_request(
5278 &self,
5279 request_ctx: &McpContext,
5280 request_cx: &Cx,
5281 params: &ReadResourceParams,
5282 session_state: SessionState,
5283 notification_sender: Option<&NotificationSender>,
5284 bidirectional_senders: Option<&BidirectionalSenders>,
5285 ) -> McpResult<ReadResourceResult> {
5286 debug!(
5287 target: targets::HANDLER,
5288 "reading modern resource; resource_key={}",
5289 safe_log_label(¶ms.uri)
5290 );
5291
5292 let dispatch_started_at = request_ctx.cx().now();
5293 if let Some(error) = budget_error(request_ctx) {
5294 return Err(error);
5295 }
5296 if !session_state.is_resource_enabled(¶ms.uri) {
5297 return Err(McpError::new(
5298 McpErrorCode::ResourceNotFound,
5299 format!("Resource '{}' is disabled for this session", params.uri),
5300 ));
5301 }
5302
5303 let resolved = self
5304 .resolve_resource_for_era(¶ms.uri, Some(ProtocolEra::Legacy2024))
5305 .ok_or_else(|| McpError::resource_not_found(¶ms.uri))?;
5306 if !resolved.legacy_enabled {
5307 return Err(McpError::resource_not_found(¶ms.uri));
5308 }
5309 let progress_marker = params
5310 .meta
5311 .as_ref()
5312 .and_then(|meta| meta.progress_marker.clone());
5313 let ctx = derive_handler_context(
5314 request_ctx,
5315 progress_marker,
5316 notification_sender,
5317 bidirectional_senders,
5318 ProtocolEra::Legacy2024,
5319 );
5320 let handler_timeout = read_handler_timeout(request_ctx.cx(), "resource_timeout", || {
5321 resolved.handler.timeout()
5322 })?;
5323 let effective_budget = compose_handler_budget(
5324 request_ctx.cx().budget(),
5325 request_ctx.budget(),
5326 handler_timeout,
5327 dispatch_started_at,
5328 );
5329 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5330 let outcome =
5331 run_handler_in_request(&ctx, request_cx, effective_budget, "resource", |child_cx| {
5332 resolved.handler.read_async_with_uri_in_request(
5333 &ctx,
5334 child_cx,
5335 ¶ms.uri,
5336 &resolved.params,
5337 )
5338 })
5339 .await?;
5340
5341 let contents = match outcome {
5342 Outcome::Ok(contents) => contents,
5343 Outcome::Err(error) => {
5344 return Err(sanitize_handler_error(request_ctx.cx(), "resource", error));
5345 }
5346 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
5347 Outcome::Panicked(_payload) => {
5348 return Err(sanitized_handler_panic(request_ctx.cx(), "resource"));
5349 }
5350 };
5351
5352 Ok(ReadResourceResult {
5353 contents: legacy_resource_contents_from_handler(contents)?,
5354 meta: None,
5355 additional: BTreeMap::new(),
5356 })
5357 }
5358
5359 async fn handle_resources_read_final_in_request(
5366 &self,
5367 request_ctx: &McpContext,
5368 request_cx: &Cx,
5369 params: FinalReadResourceParams,
5370 session_state: SessionState,
5371 notification_sender: Option<&NotificationSender>,
5372 bidirectional_senders: Option<&BidirectionalSenders>,
5373 resume_inputs: Option<&MrtrCompletedInputs>,
5374 ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
5375 let progress_marker = final_progress_marker(¶ms.meta)?;
5376 let uri = params.uri.as_str();
5377 debug!(
5378 target: targets::HANDLER,
5379 "reading final resource; resource_key={}",
5380 safe_log_label(uri)
5381 );
5382
5383 let dispatch_started_at = request_ctx.cx().now();
5384 if request_cx.is_cancel_requested() {
5385 return Err(McpError::request_cancelled());
5386 }
5387 if let Some(error) = budget_error(request_ctx) {
5388 return Err(error);
5389 }
5390 if !session_state.is_resource_enabled(uri) || !request_ctx.is_resource_enabled(uri) {
5393 return Err(McpError::new(
5394 McpErrorCode::ResourceNotFound,
5395 format!("Resource '{uri}' is disabled for this session"),
5396 ));
5397 }
5398
5399 let resolved = self
5400 .resolve_resource_for_era(uri, Some(ProtocolEra::Modern2026))
5401 .ok_or_else(|| {
5402 McpError::with_data(
5403 McpErrorCode::InvalidParams,
5404 "Resource not found",
5405 serde_json::json!({"uri": uri}),
5406 )
5407 })?;
5408 if !resolved.final_enabled {
5409 return Err(McpError::invalid_params(
5410 "resource is registered only for exact MCP 2024-11-05 dispatch",
5411 ));
5412 }
5413 if resolved.handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none()
5414 {
5415 return Err(McpError::invalid_params(
5416 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
5417 ));
5418 }
5419 admit_final_resource_uri(
5420 resolved.uri_use_policy,
5421 ¶ms.uri,
5422 FinalResourceUriUse::ResourceReadTarget,
5423 )?;
5424 let cache_hint_provenance = crate::catch_extension_unwind(|| {
5425 resolved.handler.final_resource_read_cache_hint_provenance()
5426 })
5427 .map_err(|_payload| {
5428 McpError::internal_error("resource cache-hint provenance hook panicked during dispatch")
5429 })?;
5430 let ctx = derive_handler_context(
5431 request_ctx,
5432 progress_marker,
5433 notification_sender,
5434 bidirectional_senders,
5435 ProtocolEra::Modern2026,
5436 );
5437 let handler_timeout = read_handler_timeout(request_ctx.cx(), "resource_timeout", || {
5438 resolved.handler.timeout()
5439 })?;
5440 let effective_budget = compose_handler_budget(
5441 request_ctx.cx().budget(),
5442 request_ctx.budget(),
5443 handler_timeout,
5444 dispatch_started_at,
5445 );
5446 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5447 let outcome =
5448 run_handler_in_request(&ctx, request_cx, effective_budget, "resource", |child_cx| {
5449 if let Some(resume_inputs) = resume_inputs {
5450 resolved
5451 .handler
5452 .read_final_outcome_async_with_uri_resuming_in_request(
5453 &ctx,
5454 child_cx,
5455 uri,
5456 &resolved.params,
5457 Some(resume_inputs),
5458 )
5459 } else {
5460 resolved
5461 .handler
5462 .read_final_outcome_async_with_uri_in_request(
5463 &ctx,
5464 child_cx,
5465 uri,
5466 &resolved.params,
5467 )
5468 }
5469 })
5470 .await?;
5471
5472 match outcome {
5473 Outcome::Ok(mut result) => {
5474 admit_final_resource_read_outcome(resolved.uri_use_policy, &result)?;
5475 if let FinalMethodOutcome::Complete(complete) = &mut result
5480 && cache_hint_provenance == FinalResourceReadCacheHintProvenance::RouterPolicy
5481 {
5482 complete.payload.ttl_ms = self.final_cache_hints.resource_read_ttl_ms.clone();
5483 complete.payload.cache_scope = self.final_cache_hints.scope;
5484 }
5485 Ok(result)
5486 }
5487 Outcome::Err(error) => Err(sanitize_handler_error(request_ctx.cx(), "resource", error)),
5488 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5489 Outcome::Panicked(_payload) => {
5490 Err(sanitized_handler_panic(request_ctx.cx(), "resource"))
5491 }
5492 }
5493 }
5494
5495 pub fn handle_prompts_list(
5500 &self,
5501 request_ctx: &McpContext,
5502 params: ListPromptsParams,
5503 session_state: Option<&SessionState>,
5504 ) -> McpResult<ListPromptsResult> {
5505 if let Some(error) = budget_error(request_ctx) {
5506 return Err(error);
5507 }
5508
5509 let tag_filters =
5510 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5511 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5512 Some(&tag_filters)
5513 } else {
5514 None
5515 };
5516 let prompts =
5517 crate::catch_extension_unwind(|| self.prompts_filtered(session_state, tag_filters))
5518 .map_err(|_payload| {
5519 sanitized_handler_panic(request_ctx.cx(), "prompt_definition")
5520 })?;
5521 let Some(page_size) = self.list_page_size else {
5522 return Ok(ListPromptsResult {
5523 prompts,
5524 next_cursor: None,
5525 });
5526 };
5527 let query = FinalCatalogQuery::from_tag_filters(
5528 params.include_tags.as_deref(),
5529 params.exclude_tags.as_deref(),
5530 );
5531 let (prompts, next_cursor) = page_session_filtered_catalog(
5532 prompts,
5533 params.cursor.as_deref(),
5534 Some(page_size),
5535 FinalCatalogKind::Prompts,
5536 self.final_catalog_revision,
5537 &query,
5538 session_state.is_some(),
5539 |prompt| prompt.name.as_str(),
5540 )?;
5541 Ok(ListPromptsResult {
5542 prompts,
5543 next_cursor,
5544 })
5545 }
5546
5547 pub fn handle_prompts_get(
5557 &self,
5558 request_ctx: &McpContext,
5559 params: GetPromptParams,
5560 session_state: SessionState,
5561 notification_sender: Option<&NotificationSender>,
5562 bidirectional_senders: Option<&BidirectionalSenders>,
5563 ) -> McpResult<GetPromptResult> {
5564 block_on(self.handle_prompts_get_in_request(
5565 request_ctx,
5566 request_ctx.cx(),
5567 params,
5568 session_state,
5569 notification_sender,
5570 bidirectional_senders,
5571 ))
5572 }
5573
5574 pub(crate) async fn handle_prompts_get_in_request(
5575 &self,
5576 request_ctx: &McpContext,
5577 request_cx: &Cx,
5578 params: GetPromptParams,
5579 session_state: SessionState,
5580 notification_sender: Option<&NotificationSender>,
5581 bidirectional_senders: Option<&BidirectionalSenders>,
5582 ) -> McpResult<GetPromptResult> {
5583 debug!(
5584 target: targets::HANDLER,
5585 "getting modern prompt; prompt_key={}; arguments_present={}",
5586 safe_log_label(¶ms.name),
5587 params.arguments.is_some()
5588 );
5589
5590 let dispatch_started_at = request_ctx.cx().now();
5591 if let Some(error) = budget_error(request_ctx) {
5592 return Err(error);
5593 }
5594 if !session_state.is_prompt_enabled(¶ms.name) {
5595 return Err(McpError::new(
5596 McpErrorCode::PromptNotFound,
5597 format!("Prompt '{}' is disabled for this session", params.name),
5598 ));
5599 }
5600
5601 let handler = self.prompts.get(¶ms.name).ok_or_else(|| {
5602 McpError::new(
5603 McpErrorCode::PromptNotFound,
5604 format!("Prompt not found: {}", params.name),
5605 )
5606 })?;
5607 if self.final_only_prompts.contains(¶ms.name) {
5608 return Err(McpError::new(
5609 McpErrorCode::PromptNotFound,
5610 format!("Prompt not found: {}", params.name),
5611 ));
5612 }
5613 let description = crate::catch_extension_unwind(|| handler.definition().description)
5614 .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "prompt_definition"))?;
5615 let progress_marker = params
5616 .meta
5617 .as_ref()
5618 .and_then(|meta| meta.progress_marker.clone());
5619 let ctx = derive_handler_context(
5620 request_ctx,
5621 progress_marker,
5622 notification_sender,
5623 bidirectional_senders,
5624 ProtocolEra::Legacy2024,
5625 );
5626 let handler_timeout =
5627 read_handler_timeout(request_ctx.cx(), "prompt_timeout", || handler.timeout())?;
5628 let effective_budget = compose_handler_budget(
5629 request_ctx.cx().budget(),
5630 request_ctx.budget(),
5631 handler_timeout,
5632 dispatch_started_at,
5633 );
5634 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5635 let arguments = params.arguments.unwrap_or_default();
5636 let outcome =
5637 run_handler_in_request(&ctx, request_cx, effective_budget, "prompt", |child_cx| {
5638 handler.get_async_in_request(&ctx, child_cx, arguments)
5639 })
5640 .await?;
5641
5642 let messages = match outcome {
5643 Outcome::Ok(messages) => messages,
5644 Outcome::Err(error) => {
5645 return Err(sanitize_handler_error(request_ctx.cx(), "prompt", error));
5646 }
5647 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
5648 Outcome::Panicked(_payload) => {
5649 return Err(sanitized_handler_panic(request_ctx.cx(), "prompt"));
5650 }
5651 };
5652
5653 Ok(GetPromptResult {
5654 description,
5655 messages: legacy_prompt_messages_from_handler(messages)?,
5656 meta: None,
5657 additional: BTreeMap::new(),
5658 })
5659 }
5660
5661 async fn handle_prompts_get_final_in_request(
5668 &self,
5669 request_ctx: &McpContext,
5670 request_cx: &Cx,
5671 params: FinalGetPromptParams,
5672 session_state: SessionState,
5673 notification_sender: Option<&NotificationSender>,
5674 bidirectional_senders: Option<&BidirectionalSenders>,
5675 resume_inputs: Option<&MrtrCompletedInputs>,
5676 ) -> McpResult<FinalMethodOutcome<FinalGetPromptResult>> {
5677 debug!(
5678 target: targets::HANDLER,
5679 "getting final prompt; prompt_key={}; arguments_present={}",
5680 safe_log_label(¶ms.name),
5681 !params.arguments.is_absent()
5682 );
5683
5684 let dispatch_started_at = request_ctx.cx().now();
5685 if request_cx.is_cancel_requested() {
5686 return Err(McpError::request_cancelled());
5687 }
5688 if let Some(error) = budget_error(request_ctx) {
5689 return Err(error);
5690 }
5691 let progress_marker = final_progress_marker(¶ms.meta)?;
5692 if !session_state.is_prompt_enabled(¶ms.name)
5695 || !request_ctx.is_prompt_enabled(¶ms.name)
5696 {
5697 return Err(McpError::new(
5698 McpErrorCode::PromptNotFound,
5699 format!("Prompt '{}' is disabled for this session", params.name),
5700 ));
5701 }
5702
5703 let handler = self
5704 .prompts
5705 .get(¶ms.name)
5706 .ok_or_else(|| McpError::invalid_params(format!("Unknown prompt: {}", params.name)))?;
5707 let final_registration = self.final_prompts.get(¶ms.name).ok_or_else(|| {
5708 McpError::invalid_params("prompt is registered only for exact MCP 2024-11-05 dispatch")
5709 })?;
5710 if handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none() {
5711 return Err(McpError::invalid_params(
5712 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
5713 ));
5714 }
5715 let arguments = params.arguments.into_value().unwrap_or_default();
5718 admit_declared_final_prompt_arguments(
5719 final_registration
5720 .definition
5721 .arguments
5722 .as_deref()
5723 .unwrap_or_default(),
5724 arguments.keys(),
5725 )?;
5726 let ctx = derive_handler_context(
5727 request_ctx,
5728 progress_marker,
5729 notification_sender,
5730 bidirectional_senders,
5731 ProtocolEra::Modern2026,
5732 );
5733 let handler_timeout =
5734 read_handler_timeout(request_ctx.cx(), "prompt_timeout", || handler.timeout())?;
5735 let effective_budget = compose_handler_budget(
5736 request_ctx.cx().budget(),
5737 request_ctx.budget(),
5738 handler_timeout,
5739 dispatch_started_at,
5740 );
5741 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5742 let arguments = arguments.into_iter().collect();
5743 let outcome =
5744 run_handler_in_request(&ctx, request_cx, effective_budget, "prompt", |child_cx| {
5745 if let Some(resume_inputs) = resume_inputs {
5746 handler.get_final_outcome_async_resuming_in_request(
5747 &ctx,
5748 child_cx,
5749 arguments,
5750 Some(resume_inputs),
5751 )
5752 } else {
5753 handler.get_final_outcome_async_in_request(&ctx, child_cx, arguments)
5754 }
5755 })
5756 .await?;
5757
5758 match outcome {
5759 Outcome::Ok(result) => {
5760 admit_final_prompt_outcome(final_registration.uri_use_policy, &result)?;
5761 Ok(result)
5762 }
5763 Outcome::Err(error) => Err(sanitize_handler_error(request_ctx.cx(), "prompt", error)),
5764 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5765 Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "prompt")),
5766 }
5767 }
5768}
5769
5770impl Default for Router {
5771 fn default() -> Self {
5772 Self::new()
5773 }
5774}
5775
5776#[derive(Debug, Default)]
5782pub struct MountResult {
5783 pub tools: usize,
5785 pub resources: usize,
5787 pub resource_templates: usize,
5789 pub prompts: usize,
5791 pub warnings: Vec<String>,
5793 pub errors: Vec<String>,
5797}
5798
5799impl MountResult {
5800 #[must_use]
5802 pub fn has_components(&self) -> bool {
5803 self.tools > 0 || self.resources > 0 || self.resource_templates > 0 || self.prompts > 0
5804 }
5805
5806 #[must_use]
5808 pub fn is_success(&self) -> bool {
5809 self.errors.is_empty()
5810 }
5811
5812 fn merge(&mut self, other: Self) {
5813 self.tools += other.tools;
5814 self.resources += other.resources;
5815 self.resource_templates += other.resource_templates;
5816 self.prompts += other.prompts;
5817 self.warnings.extend(other.warnings);
5818 self.errors.extend(other.errors);
5819 }
5820}
5821
5822#[derive(Clone, Copy)]
5823enum MountSelection {
5824 All,
5825 Tools,
5826 Resources,
5827 Prompts,
5828}
5829
5830impl MountSelection {
5831 const fn includes_tools(self) -> bool {
5832 matches!(self, Self::All | Self::Tools)
5833 }
5834
5835 const fn includes_resources(self) -> bool {
5836 matches!(self, Self::All | Self::Resources)
5837 }
5838
5839 const fn includes_prompts(self) -> bool {
5840 matches!(self, Self::All | Self::Prompts)
5841 }
5842}
5843
5844impl Router {
5845 fn apply_prefix(name: &str, prefix: Option<&str>) -> String {
5847 match prefix {
5848 Some(p) if !p.is_empty() => format!("{}/{}", p, name),
5849 _ => name.to_string(),
5850 }
5851 }
5852
5853 fn prefix_preserves_keys(prefix: Option<&str>) -> bool {
5860 prefix.is_none_or(str::is_empty)
5861 }
5862
5863 fn validate_prefix(prefix: &str) -> Result<(), String> {
5868 if prefix.is_empty() {
5869 return Ok(());
5870 }
5871 if prefix.contains('/') {
5872 return Err("Invalid mount prefix: slashes are not permitted".to_string());
5873 }
5874 for ch in prefix.chars() {
5876 if !ch.is_alphanumeric() && ch != '_' && ch != '-' {
5877 return Err(
5878 "Invalid mount prefix: invalid characters are not permitted".to_string()
5879 );
5880 }
5881 }
5882 Ok(())
5883 }
5884
5885 fn mount_preflight(
5886 &self,
5887 other: &Self,
5888 prefix: Option<&str>,
5889 behavior: crate::DuplicateBehavior,
5890 selection: MountSelection,
5891 ) -> MountResult {
5892 let mut result = MountResult::default();
5893
5894 if let Some(prefix) = prefix {
5895 if let Err(error) = Self::validate_prefix(prefix) {
5896 result.warnings.push(error.clone());
5899 result.errors.push(error);
5900 return result;
5901 }
5902 }
5903
5904 if behavior == crate::DuplicateBehavior::Error {
5905 let mut conflicts = Vec::new();
5906 if selection.includes_tools() {
5907 for name in other.tools.keys() {
5908 let mounted_name = Self::apply_prefix(name, prefix);
5909 if self.tools.contains_key(&mounted_name) {
5910 conflicts.push(("Tool", mounted_name));
5911 }
5912 }
5913 }
5914 if selection.includes_resources() {
5915 for uri in other.resources.keys() {
5916 let mounted_uri = Self::apply_prefix(uri, prefix);
5917 if self.resources.contains_key(&mounted_uri) {
5918 conflicts.push(("Resource", mounted_uri));
5919 }
5920 }
5921 for uri_template in other.resource_templates.keys() {
5922 let mounted_uri_template = Self::apply_prefix(uri_template, prefix);
5923 if self.resource_templates.contains_key(&mounted_uri_template) {
5924 conflicts.push(("Resource template", mounted_uri_template));
5925 }
5926 }
5927 }
5928 if selection.includes_prompts() {
5929 for name in other.prompts.keys() {
5930 let mounted_name = Self::apply_prefix(name, prefix);
5931 if self.prompts.contains_key(&mounted_name) {
5932 conflicts.push(("Prompt", mounted_name));
5933 }
5934 }
5935 }
5936
5937 conflicts.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(&b.1)));
5938 result
5939 .errors
5940 .extend(conflicts.into_iter().map(|(kind, key)| {
5941 format!(
5942 "Mount rejected because {kind} already exists; component_key={}",
5943 safe_log_label(&key)
5944 )
5945 }));
5946 }
5947
5948 self.preflight_final_resource_route_collisions(
5949 other,
5950 prefix,
5951 behavior,
5952 selection,
5953 &mut result,
5954 );
5955 self.preflight_mcp_apps_mount_bindings(other, prefix, behavior, selection, &mut result);
5956 result
5957 }
5958
5959 fn preflight_final_resource_route_collisions(
5965 &self,
5966 other: &Self,
5967 prefix: Option<&str>,
5968 behavior: crate::DuplicateBehavior,
5969 selection: MountSelection,
5970 result: &mut MountResult,
5971 ) {
5972 if !selection.includes_resources() {
5973 return;
5974 }
5975
5976 let mut final_resources: HashSet<String> = self.final_resources.keys().cloned().collect();
5977 let mut final_templates: HashMap<String, ReversibleResourceTemplate> = self
5978 .resource_templates
5979 .iter()
5980 .filter_map(|(uri_template, entry)| {
5981 entry
5982 .final_definition
5983 .as_ref()
5984 .zip(entry.matcher.as_ref())
5985 .map(|(_, matcher)| (uri_template.clone(), matcher.clone()))
5986 })
5987 .collect();
5988
5989 for uri in other.resources.keys() {
5990 let mounted_uri = Self::apply_prefix(uri, prefix);
5991 let replaces_destination = !self.resources.contains_key(&mounted_uri)
5992 || behavior == crate::DuplicateBehavior::Replace;
5993 if !replaces_destination {
5994 continue;
5995 }
5996 if Self::prefix_preserves_keys(prefix) && other.final_resources.contains_key(uri) {
5997 final_resources.insert(mounted_uri);
5998 } else {
5999 final_resources.remove(&mounted_uri);
6000 }
6001 }
6002
6003 for (uri_template, entry) in &other.resource_templates {
6004 let mounted_uri_template = Self::apply_prefix(uri_template, prefix);
6005 let replaces_destination = !self.resource_templates.contains_key(&mounted_uri_template)
6006 || behavior == crate::DuplicateBehavior::Replace;
6007 if !replaces_destination {
6008 continue;
6009 }
6010 if Self::prefix_preserves_keys(prefix)
6011 && let Some(matcher) = entry
6012 .final_definition
6013 .as_ref()
6014 .zip(entry.matcher.as_ref())
6015 .map(|(_, matcher)| matcher.clone())
6016 {
6017 final_templates.insert(mounted_uri_template, matcher);
6018 } else {
6019 final_templates.remove(&mounted_uri_template);
6020 }
6021 }
6022
6023 let mut template_routes: Vec<_> = final_templates.iter().collect();
6024 template_routes.sort_unstable_by_key(|(uri, _)| *uri);
6025 let mut exact_routes: Vec<_> = final_resources.iter().collect();
6026 exact_routes.sort_unstable();
6027 let mut collisions = Vec::new();
6028
6029 for (template_uri, matcher) in &template_routes {
6030 for exact_uri in &exact_routes {
6031 match matcher.match_uri(exact_uri) {
6032 Ok(Some(_)) => collisions.push(format!(
6033 "Mount rejected because a final resource template collides with an exact final resource; template_key={}; resource_key={}",
6034 safe_log_label(template_uri),
6035 safe_log_label(exact_uri)
6036 )),
6037 Ok(None) => {}
6038 Err(_) => collisions.push(format!(
6039 "Mount rejected because a final resource template lost match admission; template_key={}",
6040 safe_log_label(template_uri)
6041 )),
6042 }
6043 }
6044 }
6045
6046 for (index, (left_uri, left)) in template_routes.iter().enumerate() {
6047 for (right_uri, right) in template_routes.iter().skip(index + 1) {
6048 match reversible_templates_may_overlap(left, right) {
6049 Ok(true) => collisions.push(format!(
6050 "Mount rejected because final resource template languages collide; left_template_key={}; right_template_key={}",
6051 safe_log_label(left_uri),
6052 safe_log_label(right_uri)
6053 )),
6054 Ok(false) => {}
6055 Err(_) => collisions.push(format!(
6056 "Mount rejected because a final resource template lost match admission; template_key={}",
6057 safe_log_label(left_uri)
6058 )),
6059 }
6060 }
6061 }
6062
6063 collisions.sort_unstable();
6064 collisions.dedup();
6065 result.errors.extend(collisions);
6066 }
6067
6068 fn preflight_mcp_apps_mount_bindings(
6069 &self,
6070 other: &Self,
6071 prefix: Option<&str>,
6072 behavior: crate::DuplicateBehavior,
6073 selection: MountSelection,
6074 result: &mut MountResult,
6075 ) {
6076 let mut final_resources: HashMap<String, FinalResource> = self
6077 .final_resources
6078 .iter()
6079 .map(|(uri, registration)| (uri.clone(), registration.definition.clone()))
6080 .collect();
6081 let mut final_tools: HashMap<String, FinalTool> = self
6082 .tools
6083 .iter()
6084 .filter_map(|(name, registration)| {
6085 registration
6086 .final_registration
6087 .as_ref()
6088 .map(|final_registration| {
6089 (name.clone(), final_registration.final_definition.clone())
6090 })
6091 })
6092 .collect();
6093
6094 if selection.includes_resources() {
6095 for uri in other.resources.keys() {
6096 let mounted_uri = Self::apply_prefix(uri, prefix);
6097 let replacing = !self.resources.contains_key(&mounted_uri)
6098 || matches!(behavior, crate::DuplicateBehavior::Replace);
6099 if !replacing {
6100 continue;
6101 }
6102 let source_final = other.final_resources.get(uri);
6103 if !Self::prefix_preserves_keys(prefix)
6104 && source_final.is_some_and(|registration| {
6105 Self::mcp_apps_ui_resource_requires_special_admission(
6106 ®istration.definition,
6107 )
6108 .unwrap_or(true)
6109 })
6110 {
6111 result.errors.push(format!(
6112 "Mount rejected because a final-only MCP Apps UI resource cannot be prefixed; resource_key={}",
6113 safe_log_label(uri)
6114 ));
6115 continue;
6116 }
6117 if Self::prefix_preserves_keys(prefix) {
6118 if let Some(registration) = source_final {
6119 final_resources.insert(mounted_uri, registration.definition.clone());
6120 } else {
6121 final_resources.remove(&mounted_uri);
6122 }
6123 } else {
6124 final_resources.remove(&mounted_uri);
6125 }
6126 }
6127 }
6128
6129 if selection.includes_tools() {
6130 for (name, registration) in &other.tools {
6131 let mounted_name = Self::apply_prefix(name, prefix);
6132 let replacing = !self.tools.contains_key(&mounted_name)
6133 || matches!(behavior, crate::DuplicateBehavior::Replace);
6134 if !replacing {
6135 continue;
6136 }
6137 if let Some(final_registration) = registration.final_registration.as_ref() {
6138 let mut definition = final_registration.final_definition.clone();
6139 definition.name.clone_from(&mounted_name);
6140 final_tools.insert(mounted_name, definition);
6141 } else {
6142 final_tools.remove(&mounted_name);
6143 }
6144 }
6145 }
6146
6147 for (name, tool) in final_tools {
6148 let binding = match tool.mcp_apps_resource_binding() {
6149 Ok(binding) => binding,
6150 Err(error) => {
6151 result.errors.push(format!(
6152 "Mount rejected because tool has invalid MCP Apps metadata; tool_key={}; error={error}",
6153 safe_log_label(&name)
6154 ));
6155 continue;
6156 }
6157 };
6158 let Some(binding) = binding else {
6159 continue;
6160 };
6161 let Some(resource) = final_resources.get(binding.resource_uri.as_str()) else {
6162 result.errors.push(format!(
6163 "Mount rejected because an MCP Apps tool binding has no final HTML resource; tool_key={}; resource_uri={}",
6164 safe_log_label(&name),
6165 binding.resource_uri.as_str()
6166 ));
6167 continue;
6168 };
6169 if let Err(error) = binding.validate_resource(resource) {
6170 result.errors.push(format!(
6171 "Mount rejected because an MCP Apps tool binding is invalid; tool_key={}; error={error}",
6172 safe_log_label(&name)
6173 ));
6174 }
6175 }
6176 }
6177
6178 fn should_mount_duplicate(
6179 behavior: crate::DuplicateBehavior,
6180 kind: &'static str,
6181 key: &str,
6182 result: &mut MountResult,
6183 ) -> bool {
6184 match behavior {
6185 crate::DuplicateBehavior::Error => {
6186 result.errors.push(format!(
6187 "Mount rejected because {kind} already exists; component_key={}",
6188 safe_log_label(key)
6189 ));
6190 false
6191 }
6192 crate::DuplicateBehavior::Warn => {
6193 result.warnings.push(format!(
6194 "{kind} already exists, keeping original; component_key={}",
6195 safe_log_label(key)
6196 ));
6197 false
6198 }
6199 crate::DuplicateBehavior::Replace => {
6200 result.warnings.push(format!(
6201 "{kind} already exists, replacing original; component_key={}",
6202 safe_log_label(key)
6203 ));
6204 true
6205 }
6206 crate::DuplicateBehavior::Ignore => false,
6207 }
6208 }
6209
6210 fn completion_prompt_keys(&self) -> HashSet<String> {
6211 self.prompts
6212 .keys()
6213 .chain(self.final_prompts.keys())
6214 .cloned()
6215 .collect()
6216 }
6217
6218 fn remap_mounted_completion_handler(
6219 handler: crate::handler::BoxedCompletionHandler,
6220 original: String,
6221 mounted: String,
6222 for_prompt: bool,
6223 ) -> crate::handler::BoxedCompletionHandler {
6224 if original == mounted {
6225 return handler;
6226 }
6227 if for_prompt {
6228 Box::new(crate::handler::MountedCompletionHandler::for_prompt(
6229 handler, mounted, original,
6230 ))
6231 } else {
6232 Box::new(
6233 crate::handler::MountedCompletionHandler::for_resource_template(
6234 handler, mounted, original,
6235 ),
6236 )
6237 }
6238 }
6239
6240 fn remap_mounted_fallback_completion_handler(
6241 handler: crate::handler::BoxedCompletionHandler,
6242 prompt_prefix: Option<&str>,
6243 template_prefix: Option<&str>,
6244 ) -> crate::handler::BoxedCompletionHandler {
6245 if Self::prefix_preserves_keys(prompt_prefix)
6246 && Self::prefix_preserves_keys(template_prefix)
6247 {
6248 return handler;
6249 }
6250 Box::new(crate::handler::PrefixedFallbackCompletionHandler::new(
6251 handler,
6252 prompt_prefix,
6253 template_prefix,
6254 ))
6255 }
6256
6257 fn mount_completion_providers_from(
6258 &mut self,
6259 prompt_prefix: Option<&str>,
6260 template_prefix: Option<&str>,
6261 legacy_prompt_completion_handlers: HashMap<String, crate::handler::BoxedCompletionHandler>,
6262 legacy_resource_template_completion_handlers: HashMap<
6263 String,
6264 crate::handler::BoxedCompletionHandler,
6265 >,
6266 final_prompt_completion_handlers: HashMap<String, crate::handler::BoxedCompletionHandler>,
6267 final_resource_template_completion_handlers: HashMap<
6268 String,
6269 crate::handler::BoxedCompletionHandler,
6270 >,
6271 completion_handler: Option<crate::handler::BoxedCompletionHandler>,
6272 default_final_completion_enabled: bool,
6273 preexisting_prompts: &HashSet<String>,
6274 preexisting_templates: &HashSet<String>,
6275 behavior: crate::DuplicateBehavior,
6276 ) {
6277 let replace = behavior == crate::DuplicateBehavior::Replace;
6278 for (name, handler) in legacy_prompt_completion_handlers {
6279 let mounted = Self::apply_prefix(&name, prompt_prefix);
6280 if !self.prompts.contains_key(&mounted) {
6281 continue;
6282 }
6283 if preexisting_prompts.contains(&mounted) && !replace {
6284 continue;
6285 }
6286 self.legacy_prompt_completion_handlers.insert(
6287 mounted.clone(),
6288 Self::remap_mounted_completion_handler(handler, name, mounted, true),
6289 );
6290 }
6291 for (name, handler) in final_prompt_completion_handlers {
6292 let mounted = Self::apply_prefix(&name, prompt_prefix);
6293 if !self.prompts.contains_key(&mounted) && !self.final_prompts.contains_key(&mounted) {
6294 continue;
6295 }
6296 if preexisting_prompts.contains(&mounted) && !replace {
6297 continue;
6298 }
6299 self.final_prompt_completion_handlers.insert(
6300 mounted.clone(),
6301 Self::remap_mounted_completion_handler(handler, name, mounted, true),
6302 );
6303 }
6304 for (uri, handler) in legacy_resource_template_completion_handlers {
6305 let mounted = Self::apply_prefix(&uri, template_prefix);
6306 if !self.resource_templates.contains_key(&mounted) {
6307 continue;
6308 }
6309 if preexisting_templates.contains(&mounted) && !replace {
6310 continue;
6311 }
6312 self.legacy_resource_template_completion_handlers.insert(
6313 mounted.clone(),
6314 Self::remap_mounted_completion_handler(handler, uri, mounted, false),
6315 );
6316 }
6317 for (uri, handler) in final_resource_template_completion_handlers {
6318 let mounted = Self::apply_prefix(&uri, template_prefix);
6319 if !self.resource_templates.contains_key(&mounted) {
6320 continue;
6321 }
6322 if preexisting_templates.contains(&mounted) && !replace {
6323 continue;
6324 }
6325 self.final_resource_template_completion_handlers.insert(
6326 mounted.clone(),
6327 Self::remap_mounted_completion_handler(handler, uri, mounted, false),
6328 );
6329 }
6330 if let Some(handler) = completion_handler {
6331 if self.completion_handler.is_none() || replace {
6332 self.completion_handler = Some(Self::remap_mounted_fallback_completion_handler(
6333 handler,
6334 prompt_prefix,
6335 template_prefix,
6336 ));
6337 self.default_final_completion_enabled = default_final_completion_enabled;
6338 }
6339 } else if self.completion_handler.is_none() {
6340 self.default_final_completion_enabled |= default_final_completion_enabled;
6341 }
6342 }
6343
6344 pub fn mount(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6360 self.mount_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6361 }
6362
6363 pub fn mount_with_behavior(
6369 &mut self,
6370 other: Router,
6371 prefix: Option<&str>,
6372 behavior: crate::DuplicateBehavior,
6373 ) -> MountResult {
6374 let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::All);
6375 if !preflight.is_success() {
6376 return preflight;
6377 }
6378
6379 let mut result = preflight;
6380 let preexisting_prompts = self.completion_prompt_keys();
6381 let preexisting_templates = self
6382 .resource_templates
6383 .keys()
6384 .cloned()
6385 .collect::<HashSet<_>>();
6386
6387 let Router {
6388 tools,
6389 tool_order,
6390 resources,
6391 final_only_resources,
6392 final_resources,
6393 resource_order,
6394 prompts,
6395 final_only_prompts,
6396 final_prompts,
6397 prompt_order,
6398 resource_templates,
6399 resource_template_order,
6400 completion_handler,
6401 default_final_completion_enabled,
6402 legacy_prompt_completion_handlers,
6403 legacy_resource_template_completion_handlers,
6404 final_prompt_completion_handlers,
6405 final_resource_template_completion_handlers,
6406 ..
6407 } = other;
6408
6409 result.merge(self.mount_tools_from(tools, tool_order, prefix, behavior));
6411
6412 result.merge(self.mount_resources_from(
6414 resources,
6415 final_only_resources,
6416 final_resources,
6417 resource_order,
6418 prefix,
6419 behavior,
6420 ));
6421
6422 result.merge(self.mount_resource_templates_from(
6424 resource_templates,
6425 resource_template_order,
6426 prefix,
6427 behavior,
6428 ));
6429
6430 result.merge(self.mount_prompts_from(
6432 prompts,
6433 final_only_prompts,
6434 final_prompts,
6435 prompt_order,
6436 prefix,
6437 behavior,
6438 ));
6439 self.mount_completion_providers_from(
6440 prefix,
6441 prefix,
6442 legacy_prompt_completion_handlers,
6443 legacy_resource_template_completion_handlers,
6444 final_prompt_completion_handlers,
6445 final_resource_template_completion_handlers,
6446 completion_handler,
6447 default_final_completion_enabled,
6448 &preexisting_prompts,
6449 &preexisting_templates,
6450 behavior,
6451 );
6452
6453 if result.has_components() {
6455 debug!(
6456 target: targets::HANDLER,
6457 "mounted {} tools, {} resources, {} templates, {} prompts; prefix_present={}; prefix_key={}",
6458 result.tools,
6459 result.resources,
6460 result.resource_templates,
6461 result.prompts,
6462 prefix.is_some(),
6463 safe_log_label(prefix.unwrap_or_default())
6464 );
6465 self.advance_final_catalog_revision();
6466 }
6467
6468 result
6469 }
6470
6471 pub fn mount_namespaced_with_behavior(
6478 &mut self,
6479 other: Router,
6480 prefix: Option<&str>,
6481 behavior: crate::DuplicateBehavior,
6482 ) -> MountResult {
6483 let mut preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Tools);
6484 preflight.merge(self.mount_preflight(&other, prefix, behavior, MountSelection::Prompts));
6485 preflight.merge(self.mount_preflight(&other, None, behavior, MountSelection::Resources));
6486 if !preflight.is_success() {
6487 return preflight;
6488 }
6489
6490 let mut result = preflight;
6491 let preexisting_prompts = self.completion_prompt_keys();
6492 let preexisting_templates = self
6493 .resource_templates
6494 .keys()
6495 .cloned()
6496 .collect::<HashSet<_>>();
6497 let Router {
6498 tools,
6499 tool_order,
6500 resources,
6501 final_only_resources,
6502 final_resources,
6503 resource_order,
6504 prompts,
6505 final_only_prompts,
6506 final_prompts,
6507 prompt_order,
6508 resource_templates,
6509 resource_template_order,
6510 completion_handler,
6511 default_final_completion_enabled,
6512 legacy_prompt_completion_handlers,
6513 legacy_resource_template_completion_handlers,
6514 final_prompt_completion_handlers,
6515 final_resource_template_completion_handlers,
6516 ..
6517 } = other;
6518
6519 result.merge(self.mount_tools_from(tools, tool_order, prefix, behavior));
6520 result.merge(self.mount_resources_from(
6521 resources,
6522 final_only_resources,
6523 final_resources,
6524 resource_order,
6525 None,
6526 behavior,
6527 ));
6528 result.merge(self.mount_resource_templates_from(
6529 resource_templates,
6530 resource_template_order,
6531 None,
6532 behavior,
6533 ));
6534 result.merge(self.mount_prompts_from(
6535 prompts,
6536 final_only_prompts,
6537 final_prompts,
6538 prompt_order,
6539 prefix,
6540 behavior,
6541 ));
6542 self.mount_completion_providers_from(
6543 prefix,
6544 None,
6545 legacy_prompt_completion_handlers,
6546 legacy_resource_template_completion_handlers,
6547 final_prompt_completion_handlers,
6548 final_resource_template_completion_handlers,
6549 completion_handler,
6550 default_final_completion_enabled,
6551 &preexisting_prompts,
6552 &preexisting_templates,
6553 behavior,
6554 );
6555
6556 if result.has_components() {
6557 debug!(
6558 target: targets::HANDLER,
6559 "mounted namespaced {} tools, {} resources, {} templates, {} prompts; prefix_present={}; prefix_key={}",
6560 result.tools,
6561 result.resources,
6562 result.resource_templates,
6563 result.prompts,
6564 prefix.is_some(),
6565 safe_log_label(prefix.unwrap_or_default())
6566 );
6567 self.advance_final_catalog_revision();
6568 }
6569
6570 result
6571 }
6572
6573 pub fn mount_tools(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6575 self.mount_tools_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6576 }
6577
6578 pub fn mount_tools_with_behavior(
6580 &mut self,
6581 other: Router,
6582 prefix: Option<&str>,
6583 behavior: crate::DuplicateBehavior,
6584 ) -> MountResult {
6585 let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Tools);
6586 if !preflight.is_success() {
6587 return preflight;
6588 }
6589 let result = self.mount_tools_from(other.tools, other.tool_order, prefix, behavior);
6590 if result.has_components() {
6591 self.advance_final_catalog_revision();
6592 }
6593 result
6594 }
6595
6596 fn mount_tools_from(
6598 &mut self,
6599 mut tools: HashMap<String, AdmittedToolRegistration>,
6600 tool_order: Vec<String>,
6601 prefix: Option<&str>,
6602 behavior: crate::DuplicateBehavior,
6603 ) -> MountResult {
6604 let mut result = MountResult::default();
6605
6606 for name in tool_order {
6607 let Some(entry) = tools.remove(&name) else {
6608 continue;
6609 };
6610 let mounted_name = Self::apply_prefix(&name, prefix);
6611 trace!(
6612 target: targets::HANDLER,
6613 "mounting tool; source_key={}; mounted_key={}",
6614 safe_log_label(&name),
6615 safe_log_label(&mounted_name)
6616 );
6617
6618 let existed = self.tools.contains_key(&mounted_name);
6620 if existed
6621 && !Self::should_mount_duplicate(behavior, "Tool", &mounted_name, &mut result)
6622 {
6623 continue;
6624 }
6625
6626 let mounted = entry.with_mounted_name(mounted_name.clone());
6630 let needs_order_push = !existed && !self.tool_order.iter().any(|n| n == &mounted_name);
6631 self.tools.insert(mounted_name.clone(), mounted);
6632 if needs_order_push {
6633 self.tool_order.push(mounted_name);
6634 }
6635 result.tools += 1;
6636 }
6637
6638 if !tools.is_empty() {
6639 let mut remaining: Vec<(String, AdmittedToolRegistration)> =
6642 tools.into_iter().collect();
6643 remaining.sort_by(|a, b| a.0.cmp(&b.0));
6644 for (name, entry) in remaining {
6645 let mounted_name = Self::apply_prefix(&name, prefix);
6646
6647 let existed = self.tools.contains_key(&mounted_name);
6648 if existed
6649 && !Self::should_mount_duplicate(behavior, "Tool", &mounted_name, &mut result)
6650 {
6651 continue;
6652 }
6653
6654 let mounted = entry.with_mounted_name(mounted_name.clone());
6655 self.tools.insert(mounted_name.clone(), mounted);
6656 if !existed && !self.tool_order.iter().any(|n| n == &mounted_name) {
6657 self.tool_order.push(mounted_name);
6658 }
6659 result.tools += 1;
6660 }
6661 }
6662
6663 result
6664 }
6665
6666 pub fn mount_resources(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6668 self.mount_resources_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6669 }
6670
6671 pub fn mount_resources_with_behavior(
6674 &mut self,
6675 other: Router,
6676 prefix: Option<&str>,
6677 behavior: crate::DuplicateBehavior,
6678 ) -> MountResult {
6679 let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Resources);
6680 if !preflight.is_success() {
6681 return preflight;
6682 }
6683
6684 let preexisting_templates = self
6685 .resource_templates
6686 .keys()
6687 .cloned()
6688 .collect::<HashSet<_>>();
6689 let Router {
6690 resources,
6691 final_only_resources,
6692 final_resources,
6693 resource_order,
6694 resource_templates,
6695 resource_template_order,
6696 legacy_resource_template_completion_handlers,
6697 final_resource_template_completion_handlers,
6698 ..
6699 } = other;
6700 let mut result = preflight;
6701 result.merge(self.mount_resources_from(
6702 resources,
6703 final_only_resources,
6704 final_resources,
6705 resource_order,
6706 prefix,
6707 behavior,
6708 ));
6709 let template_result = self.mount_resource_templates_from(
6710 resource_templates,
6711 resource_template_order,
6712 prefix,
6713 behavior,
6714 );
6715 result.merge(template_result);
6716 self.mount_completion_providers_from(
6717 None,
6718 prefix,
6719 HashMap::new(),
6720 legacy_resource_template_completion_handlers,
6721 HashMap::new(),
6722 final_resource_template_completion_handlers,
6723 None,
6724 false,
6725 &HashSet::new(),
6726 &preexisting_templates,
6727 behavior,
6728 );
6729 if result.has_components() {
6730 self.advance_final_catalog_revision();
6731 }
6732 result
6733 }
6734
6735 fn mount_resources_from(
6737 &mut self,
6738 mut resources: HashMap<String, BoxedResourceHandler>,
6739 final_only_resources: HashSet<String>,
6740 mut final_resources: HashMap<String, AdmittedFinalResourceRegistration>,
6741 resource_order: Vec<String>,
6742 prefix: Option<&str>,
6743 behavior: crate::DuplicateBehavior,
6744 ) -> MountResult {
6745 use crate::handler::MountedResourceHandler;
6746
6747 let mut result = MountResult::default();
6748
6749 for uri in resource_order {
6750 let Some(handler) = resources.remove(&uri) else {
6751 continue;
6752 };
6753 let mounted_uri = Self::apply_prefix(&uri, prefix);
6754 trace!(
6755 target: targets::HANDLER,
6756 "mounting resource; source_key={}; mounted_key={}",
6757 safe_log_label(&uri),
6758 safe_log_label(&mounted_uri)
6759 );
6760
6761 let existed = self.resources.contains_key(&mounted_uri);
6763 if existed
6764 && !Self::should_mount_duplicate(behavior, "Resource", &mounted_uri, &mut result)
6765 {
6766 continue;
6767 }
6768
6769 let mounted = MountedResourceHandler::new(handler, uri.clone(), mounted_uri.clone());
6771 let needs_order_push =
6772 !existed && !self.resource_order.iter().any(|u| u == &mounted_uri);
6773 self.resources
6774 .insert(mounted_uri.clone(), Box::new(mounted));
6775 if Self::prefix_preserves_keys(prefix) {
6776 if let Some(final_registration) = final_resources.remove(&uri) {
6777 self.final_resources
6778 .insert(mounted_uri.clone(), final_registration);
6779 } else {
6780 self.final_resources.remove(&mounted_uri);
6781 }
6782 } else {
6783 self.final_resources.remove(&mounted_uri);
6787 }
6788 if Self::prefix_preserves_keys(prefix) && final_only_resources.contains(&uri) {
6789 self.final_only_resources.insert(mounted_uri.clone());
6790 } else {
6791 self.final_only_resources.remove(&mounted_uri);
6792 }
6793 if needs_order_push {
6794 self.resource_order.push(mounted_uri);
6795 }
6796 result.resources += 1;
6797 }
6798
6799 if !resources.is_empty() {
6800 let mut remaining: Vec<(String, BoxedResourceHandler)> =
6801 resources.into_iter().collect();
6802 remaining.sort_by(|a, b| a.0.cmp(&b.0));
6803 for (uri, handler) in remaining {
6804 let mounted_uri = Self::apply_prefix(&uri, prefix);
6805
6806 let existed = self.resources.contains_key(&mounted_uri);
6807 if existed
6808 && !Self::should_mount_duplicate(
6809 behavior,
6810 "Resource",
6811 &mounted_uri,
6812 &mut result,
6813 )
6814 {
6815 continue;
6816 }
6817
6818 let mounted =
6819 MountedResourceHandler::new(handler, uri.clone(), mounted_uri.clone());
6820 self.resources
6821 .insert(mounted_uri.clone(), Box::new(mounted));
6822 if Self::prefix_preserves_keys(prefix) {
6823 if let Some(final_registration) = final_resources.remove(&uri) {
6824 self.final_resources
6825 .insert(mounted_uri.clone(), final_registration);
6826 } else {
6827 self.final_resources.remove(&mounted_uri);
6828 }
6829 } else {
6830 self.final_resources.remove(&mounted_uri);
6831 }
6832 if Self::prefix_preserves_keys(prefix) && final_only_resources.contains(&uri) {
6833 self.final_only_resources.insert(mounted_uri.clone());
6834 } else {
6835 self.final_only_resources.remove(&mounted_uri);
6836 }
6837 if !existed && !self.resource_order.iter().any(|u| u == &mounted_uri) {
6838 self.resource_order.push(mounted_uri);
6839 }
6840 result.resources += 1;
6841 }
6842 }
6843
6844 result
6845 }
6846
6847 fn mount_resource_templates_from(
6849 &mut self,
6850 mut templates: HashMap<String, ResourceTemplateEntry>,
6851 resource_template_order: Vec<String>,
6852 prefix: Option<&str>,
6853 behavior: crate::DuplicateBehavior,
6854 ) -> MountResult {
6855 use crate::handler::MountedResourceHandler;
6856
6857 let mut result = MountResult::default();
6858
6859 for uri_template in resource_template_order {
6860 let Some(entry) = templates.remove(&uri_template) else {
6861 continue;
6862 };
6863 let mounted_uri_template = Self::apply_prefix(&uri_template, prefix);
6864 trace!(
6865 target: targets::HANDLER,
6866 "mounting resource template; source_key={}; mounted_key={}",
6867 safe_log_label(&uri_template),
6868 safe_log_label(&mounted_uri_template)
6869 );
6870
6871 let existed = self.resource_templates.contains_key(&mounted_uri_template);
6873 if existed
6874 && !Self::should_mount_duplicate(
6875 behavior,
6876 "Resource template",
6877 &mounted_uri_template,
6878 &mut result,
6879 )
6880 {
6881 continue;
6882 }
6883
6884 let mut mounted_template = entry.template.clone();
6886 mounted_template.uri_template = mounted_uri_template.clone();
6887
6888 let mounted_handler = entry.handler.map(|h| {
6890 let wrapped: BoxedResourceHandler =
6891 Box::new(MountedResourceHandler::with_template(
6892 h,
6893 uri_template.clone(),
6894 mounted_uri_template.clone(),
6895 mounted_template.clone(),
6896 ));
6897 wrapped
6898 });
6899
6900 let legacy_matcher = if entry.legacy_enabled {
6902 match admit_legacy_resource_template(&mounted_uri_template) {
6903 Ok(matcher) => Some(matcher),
6904 Err(error) => {
6905 result.errors.push(format!(
6906 "Mount rejected exact-2024 resource template; template_key={}; code={:?}",
6907 safe_log_label(&mounted_uri_template),
6908 error.code
6909 ));
6910 continue;
6911 }
6912 }
6913 } else {
6914 None
6915 };
6916 let final_enabled =
6917 Self::prefix_preserves_keys(prefix) && entry.final_definition.is_some();
6918 let (matcher, specificity) = if final_enabled {
6919 match admit_resource_template(&mounted_uri_template) {
6920 Ok((matcher, specificity)) => (Some(matcher), specificity),
6921 Err(error) => {
6922 result.errors.push(format!(
6923 "Mount rejected resource template; template_key={}; code={:?}",
6924 safe_log_label(&mounted_uri_template),
6925 error.code
6926 ));
6927 continue;
6928 }
6929 }
6930 } else {
6931 let specificity = legacy_matcher.as_ref().map_or(
6932 entry.specificity,
6933 LegacyResourceTemplateMatcher::specificity,
6934 );
6935 (None, specificity)
6936 };
6937 let mounted_entry = ResourceTemplateEntry {
6938 matcher,
6939 legacy_matcher,
6940 specificity,
6941 template: mounted_template,
6942 handler: mounted_handler,
6943 final_definition: if Self::prefix_preserves_keys(prefix) {
6949 entry.final_definition.map(|mut definition| {
6950 definition.uri_template = mounted_uri_template.clone();
6951 definition
6952 })
6953 } else {
6954 None
6955 },
6956 uri_use_policy: entry.uri_use_policy,
6957 legacy_enabled: entry.legacy_enabled,
6958 };
6959
6960 let needs_order_push = !existed
6961 && !self
6962 .resource_template_order
6963 .iter()
6964 .any(|t| t == &mounted_uri_template);
6965 self.resource_templates
6966 .insert(mounted_uri_template.clone(), mounted_entry);
6967 if needs_order_push {
6968 self.resource_template_order.push(mounted_uri_template);
6969 }
6970 result.resource_templates += 1;
6971 }
6972
6973 if !templates.is_empty() {
6974 let mut remaining: Vec<(String, ResourceTemplateEntry)> =
6975 templates.into_iter().collect();
6976 remaining.sort_by(|a, b| a.0.cmp(&b.0));
6977 for (uri_template, entry) in remaining {
6978 let mounted_uri_template = Self::apply_prefix(&uri_template, prefix);
6979
6980 let existed = self.resource_templates.contains_key(&mounted_uri_template);
6981 if existed
6982 && !Self::should_mount_duplicate(
6983 behavior,
6984 "Resource template",
6985 &mounted_uri_template,
6986 &mut result,
6987 )
6988 {
6989 continue;
6990 }
6991
6992 let mut mounted_template = entry.template.clone();
6993 mounted_template.uri_template = mounted_uri_template.clone();
6994
6995 let mounted_handler = entry.handler.map(|h| {
6996 let wrapped: BoxedResourceHandler =
6997 Box::new(MountedResourceHandler::with_template(
6998 h,
6999 uri_template,
7000 mounted_uri_template.clone(),
7001 mounted_template.clone(),
7002 ));
7003 wrapped
7004 });
7005
7006 let legacy_matcher = if entry.legacy_enabled {
7007 match admit_legacy_resource_template(&mounted_uri_template) {
7008 Ok(matcher) => Some(matcher),
7009 Err(error) => {
7010 result.errors.push(format!(
7011 "Mount rejected exact-2024 resource template; template_key={}; code={:?}",
7012 safe_log_label(&mounted_uri_template),
7013 error.code
7014 ));
7015 continue;
7016 }
7017 }
7018 } else {
7019 None
7020 };
7021 let final_enabled =
7022 Self::prefix_preserves_keys(prefix) && entry.final_definition.is_some();
7023 let (matcher, specificity) = if final_enabled {
7024 match admit_resource_template(&mounted_uri_template) {
7025 Ok((matcher, specificity)) => (Some(matcher), specificity),
7026 Err(error) => {
7027 result.errors.push(format!(
7028 "Mount rejected resource template; template_key={}; code={:?}",
7029 safe_log_label(&mounted_uri_template),
7030 error.code
7031 ));
7032 continue;
7033 }
7034 }
7035 } else {
7036 let specificity = legacy_matcher.as_ref().map_or(
7037 entry.specificity,
7038 LegacyResourceTemplateMatcher::specificity,
7039 );
7040 (None, specificity)
7041 };
7042 let mounted_entry = ResourceTemplateEntry {
7043 matcher,
7044 legacy_matcher,
7045 specificity,
7046 template: mounted_template,
7047 handler: mounted_handler,
7048 final_definition: if Self::prefix_preserves_keys(prefix) {
7053 entry.final_definition.map(|mut definition| {
7054 definition.uri_template = mounted_uri_template.clone();
7055 definition
7056 })
7057 } else {
7058 None
7059 },
7060 uri_use_policy: entry.uri_use_policy,
7061 legacy_enabled: entry.legacy_enabled,
7062 };
7063
7064 self.resource_templates
7065 .insert(mounted_uri_template.clone(), mounted_entry);
7066 if !existed
7067 && !self
7068 .resource_template_order
7069 .iter()
7070 .any(|t| t == &mounted_uri_template)
7071 {
7072 self.resource_template_order
7073 .push(mounted_uri_template.clone());
7074 }
7075 result.resource_templates += 1;
7076 }
7077 }
7078
7079 if result.resource_templates > 0 {
7081 self.rebuild_sorted_template_keys();
7082 }
7083
7084 result
7085 }
7086
7087 pub fn mount_prompts(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
7089 self.mount_prompts_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
7090 }
7091
7092 pub fn mount_prompts_with_behavior(
7094 &mut self,
7095 other: Router,
7096 prefix: Option<&str>,
7097 behavior: crate::DuplicateBehavior,
7098 ) -> MountResult {
7099 let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Prompts);
7100 if !preflight.is_success() {
7101 return preflight;
7102 }
7103 let preexisting_prompts = self.completion_prompt_keys();
7104 let Router {
7105 prompts,
7106 final_only_prompts,
7107 final_prompts,
7108 prompt_order,
7109 legacy_prompt_completion_handlers,
7110 final_prompt_completion_handlers,
7111 ..
7112 } = other;
7113 let mut result = preflight;
7114 result.merge(self.mount_prompts_from(
7115 prompts,
7116 final_only_prompts,
7117 final_prompts,
7118 prompt_order,
7119 prefix,
7120 behavior,
7121 ));
7122 self.mount_completion_providers_from(
7123 prefix,
7124 None,
7125 legacy_prompt_completion_handlers,
7126 HashMap::new(),
7127 final_prompt_completion_handlers,
7128 HashMap::new(),
7129 None,
7130 false,
7131 &preexisting_prompts,
7132 &HashSet::new(),
7133 behavior,
7134 );
7135 if result.has_components() {
7136 self.advance_final_catalog_revision();
7137 }
7138 result
7139 }
7140
7141 fn mount_prompts_from(
7143 &mut self,
7144 mut prompts: HashMap<String, BoxedPromptHandler>,
7145 final_only_prompts: HashSet<String>,
7146 mut final_prompts: HashMap<String, AdmittedFinalPromptRegistration>,
7147 prompt_order: Vec<String>,
7148 prefix: Option<&str>,
7149 behavior: crate::DuplicateBehavior,
7150 ) -> MountResult {
7151 use crate::handler::MountedPromptHandler;
7152
7153 let mut result = MountResult::default();
7154
7155 for name in prompt_order {
7156 let Some(handler) = prompts.remove(&name) else {
7157 continue;
7158 };
7159 let mounted_name = Self::apply_prefix(&name, prefix);
7160 trace!(
7161 target: targets::HANDLER,
7162 "mounting prompt; source_key={}; mounted_key={}",
7163 safe_log_label(&name),
7164 safe_log_label(&mounted_name)
7165 );
7166
7167 let existed = self.prompts.contains_key(&mounted_name);
7169 if existed
7170 && !Self::should_mount_duplicate(behavior, "Prompt", &mounted_name, &mut result)
7171 {
7172 continue;
7173 }
7174
7175 let mounted = MountedPromptHandler::new(handler, mounted_name.clone());
7177 let needs_order_push =
7178 !existed && !self.prompt_order.iter().any(|n| n == &mounted_name);
7179 self.prompts.insert(mounted_name.clone(), Box::new(mounted));
7180 match final_prompts.remove(&name) {
7181 Some(mut final_registration) => {
7182 final_registration.definition.name.clone_from(&mounted_name);
7183 self.final_prompts
7184 .insert(mounted_name.clone(), final_registration);
7185 }
7186 None => {
7187 self.final_prompts.remove(&mounted_name);
7188 }
7189 }
7190 if final_only_prompts.contains(&name) {
7191 self.final_only_prompts.insert(mounted_name.clone());
7192 } else {
7193 self.final_only_prompts.remove(&mounted_name);
7194 }
7195 if needs_order_push {
7196 self.prompt_order.push(mounted_name);
7197 }
7198 result.prompts += 1;
7199 }
7200
7201 if !prompts.is_empty() {
7202 let mut remaining: Vec<(String, BoxedPromptHandler)> = prompts.into_iter().collect();
7203 remaining.sort_by(|a, b| a.0.cmp(&b.0));
7204 for (name, handler) in remaining {
7205 let mounted_name = Self::apply_prefix(&name, prefix);
7206
7207 let existed = self.prompts.contains_key(&mounted_name);
7208 if existed
7209 && !Self::should_mount_duplicate(behavior, "Prompt", &mounted_name, &mut result)
7210 {
7211 continue;
7212 }
7213
7214 let mounted = MountedPromptHandler::new(handler, mounted_name.clone());
7215 self.prompts.insert(mounted_name.clone(), Box::new(mounted));
7216 match final_prompts.remove(&name) {
7217 Some(mut final_registration) => {
7218 final_registration.definition.name.clone_from(&mounted_name);
7219 self.final_prompts
7220 .insert(mounted_name.clone(), final_registration);
7221 }
7222 None => {
7223 self.final_prompts.remove(&mounted_name);
7224 }
7225 }
7226 if final_only_prompts.contains(&name) {
7227 self.final_only_prompts.insert(mounted_name.clone());
7228 } else {
7229 self.final_only_prompts.remove(&mounted_name);
7230 }
7231 if !existed && !self.prompt_order.iter().any(|n| n == &mounted_name) {
7232 self.prompt_order.push(mounted_name);
7233 }
7234 result.prompts += 1;
7235 }
7236 }
7237
7238 result
7239 }
7240
7241 #[must_use]
7245 #[allow(dead_code)]
7246 pub(crate) fn into_parts(
7247 self,
7248 ) -> (
7249 HashMap<String, BoxedToolHandler>,
7250 HashMap<String, BoxedResourceHandler>,
7251 HashMap<String, ResourceTemplateEntry>,
7252 HashMap<String, BoxedPromptHandler>,
7253 ) {
7254 let tools = self
7255 .tools
7256 .into_iter()
7257 .map(|(name, entry)| (name, entry.handler))
7258 .collect();
7259 (tools, self.resources, self.resource_templates, self.prompts)
7260 }
7261}
7262
7263struct ResolvedResource<'a> {
7264 handler: &'a BoxedResourceHandler,
7265 params: UriParams,
7266 final_enabled: bool,
7267 legacy_enabled: bool,
7268 uri_use_policy: ResourceUriUsePolicy,
7269}
7270
7271impl ResolvedResource<'_> {
7272 const fn is_enabled_in(&self, era: ProtocolEra) -> bool {
7273 match era {
7274 ProtocolEra::Legacy2024 => self.legacy_enabled,
7275 ProtocolEra::Modern2026 => self.final_enabled,
7276 }
7277 }
7278}
7279
7280pub(crate) struct ResourceTemplateEntry {
7282 pub(crate) matcher: Option<ReversibleResourceTemplate>,
7283 legacy_matcher: Option<LegacyResourceTemplateMatcher>,
7284 specificity: (usize, usize, usize),
7285 pub(crate) template: ResourceTemplate,
7286 pub(crate) handler: Option<BoxedResourceHandler>,
7287 final_definition: Option<FinalResourceTemplate>,
7288 uri_use_policy: ResourceUriUsePolicy,
7289 legacy_enabled: bool,
7290}
7291
7292#[derive(Debug, Clone)]
7297struct LegacyResourceTemplateMatcher {
7298 segments: Vec<LegacyResourceTemplateSegment>,
7299}
7300
7301#[derive(Debug, Clone)]
7302enum LegacyResourceTemplateSegment {
7303 Literal(String),
7304 Parameter(String),
7305}
7306
7307impl LegacyResourceTemplateMatcher {
7308 fn parse(pattern: &str) -> Result<Self, ()> {
7309 let mut segments = Vec::new();
7310 let mut literal = String::new();
7311 let mut chars = pattern.chars().peekable();
7312 let mut names = HashSet::new();
7313
7314 while let Some(character) = chars.next() {
7315 match character {
7316 '{' if matches!(chars.peek(), Some('{')) => {
7317 let _ = chars.next();
7318 literal.push('{');
7319 }
7320 '{' => {
7321 if !literal.is_empty() {
7322 segments.push(LegacyResourceTemplateSegment::Literal(std::mem::take(
7323 &mut literal,
7324 )));
7325 }
7326 let mut expression = String::new();
7327 let mut closed = false;
7328 for next in chars.by_ref() {
7329 if next == '}' {
7330 closed = true;
7331 break;
7332 }
7333 expression.push(next);
7334 }
7335 if !closed {
7336 return Err(());
7337 }
7338
7339 let name = expression.strip_prefix('+').unwrap_or(&expression);
7340 if name.is_empty()
7341 || name.starts_with('+')
7342 || matches!(
7343 expression.chars().next(),
7344 Some('#' | '.' | '/' | ';' | '?' | '&')
7345 )
7346 || name
7347 .chars()
7348 .any(|character| matches!(character, '*' | ':' | ','))
7349 || !names.insert(name.to_owned())
7350 {
7351 return Err(());
7352 }
7353 segments.push(LegacyResourceTemplateSegment::Parameter(name.to_owned()));
7354 }
7355 '}' if matches!(chars.peek(), Some('}')) => {
7356 let _ = chars.next();
7357 literal.push('}');
7358 }
7359 '}' => return Err(()),
7360 character => literal.push(character),
7361 }
7362 }
7363
7364 if !literal.is_empty() {
7365 segments.push(LegacyResourceTemplateSegment::Literal(literal));
7366 }
7367 Ok(Self { segments })
7368 }
7369
7370 fn specificity(&self) -> (usize, usize, usize) {
7371 let mut literal_bytes = 0usize;
7372 let mut literal_parts = 0usize;
7373 for segment in &self.segments {
7374 if let LegacyResourceTemplateSegment::Literal(literal) = segment {
7375 literal_bytes = literal_bytes.saturating_add(literal.len());
7376 literal_parts = literal_parts.saturating_add(1);
7377 }
7378 }
7379 (literal_bytes, literal_parts, self.segments.len())
7380 }
7381
7382 fn matches(&self, uri: &str) -> Option<UriParams> {
7383 let mut params = UriParams::new();
7384 let mut remainder = uri;
7385 let mut segments = self.segments.iter().peekable();
7386
7387 while let Some(segment) = segments.next() {
7388 match segment {
7389 LegacyResourceTemplateSegment::Literal(literal) => {
7390 remainder = remainder.strip_prefix(literal)?;
7391 }
7392 LegacyResourceTemplateSegment::Parameter(name) => {
7393 let next_literal = segments.peek().and_then(|next| match next {
7394 LegacyResourceTemplateSegment::Literal(literal) => Some(literal.as_str()),
7395 LegacyResourceTemplateSegment::Parameter(_) => None,
7396 });
7397 if next_literal.is_none() && segments.peek().is_some() {
7398 return None;
7399 }
7400
7401 let value = if let Some(literal) = next_literal {
7402 let index = remainder.find(literal)?;
7403 let value = &remainder[..index];
7404 remainder = &remainder[index..];
7405 value
7406 } else {
7407 if remainder.is_empty() {
7408 return None;
7409 }
7410 let parameter_count = self
7411 .segments
7412 .iter()
7413 .filter(|segment| {
7414 matches!(segment, LegacyResourceTemplateSegment::Parameter(_))
7415 })
7416 .count();
7417 let end = if parameter_count == 1 {
7418 remainder.len()
7419 } else {
7420 remainder.find('/').unwrap_or(remainder.len())
7421 };
7422 let value = &remainder[..end];
7423 remainder = &remainder[end..];
7424 value
7425 };
7426 if value.is_empty() {
7427 return None;
7428 }
7429 params.insert(name.clone(), legacy_percent_decode(value)?);
7430 }
7431 }
7432 }
7433
7434 remainder.is_empty().then_some(params)
7435 }
7436}
7437
7438fn admit_legacy_resource_template(source: &str) -> McpResult<LegacyResourceTemplateMatcher> {
7439 LegacyResourceTemplateMatcher::parse(source).map_err(|()| {
7440 McpError::invalid_params(
7441 "exact-2024 resource templates only admit unmodified {name} and {+name} parameters",
7442 )
7443 })
7444}
7445
7446fn legacy_percent_decode(input: &str) -> Option<String> {
7447 if !input.as_bytes().contains(&b'%') {
7448 return Some(input.to_owned());
7449 }
7450 let bytes = input.as_bytes();
7451 let mut output = Vec::with_capacity(bytes.len());
7452 let mut index = 0usize;
7453 while index < bytes.len() {
7454 match bytes[index] {
7455 b'%' if index + 2 < bytes.len() => {
7456 let high = legacy_hex_value(bytes[index + 1])?;
7457 let low = legacy_hex_value(bytes[index + 2])?;
7458 output.push((high << 4) | low);
7459 index += 3;
7460 }
7461 b'%' => return None,
7462 byte => {
7463 output.push(byte);
7464 index += 1;
7465 }
7466 }
7467 }
7468 String::from_utf8(output).ok()
7469}
7470
7471const fn legacy_hex_value(byte: u8) -> Option<u8> {
7472 match byte {
7473 b'0'..=b'9' => Some(byte - b'0'),
7474 b'a'..=b'f' => Some(byte - b'a' + 10),
7475 b'A'..=b'F' => Some(byte - b'A' + 10),
7476 _ => None,
7477 }
7478}
7479
7480fn admit_resource_template(
7483 source: &str,
7484) -> McpResult<(ReversibleResourceTemplate, (usize, usize, usize))> {
7485 let template = fastmcp_protocol::UriTemplate::parse(source)
7486 .map_err(|_| McpError::invalid_params("resource template is not valid RFC 6570"))?;
7487 let specificity = resource_template_specificity(&template);
7488 let matcher = template
7489 .compile_reversible()
7490 .map_err(|_| McpError::invalid_params("resource template cannot be matched reversibly"))?;
7491 Ok((matcher, specificity))
7492}
7493
7494fn resource_template_specificity(
7495 template: &fastmcp_protocol::UriTemplate,
7496) -> (usize, usize, usize) {
7497 let mut literal_bytes = 0usize;
7498 let mut literal_parts = 0usize;
7499 for part in template.parts() {
7500 if let fastmcp_protocol::UriTemplatePart::Literal(literal) = part {
7501 literal_bytes = literal_bytes.saturating_add(literal.len());
7502 literal_parts = literal_parts.saturating_add(1);
7503 }
7504 }
7505 (literal_bytes, literal_parts, template.parts().len())
7506}
7507
7508fn reversible_templates_may_overlap(
7516 left: &ReversibleResourceTemplate,
7517 right: &ReversibleResourceTemplate,
7518) -> McpResult<bool> {
7519 let left_prefix = reversible_template_leading_literal_prefix(left)?;
7520 let right_prefix = reversible_template_leading_literal_prefix(right)?;
7521 Ok(left_prefix.starts_with(&right_prefix) || right_prefix.starts_with(&left_prefix))
7522}
7523
7524fn reversible_template_leading_literal_prefix(
7525 matcher: &ReversibleResourceTemplate,
7526) -> McpResult<String> {
7527 let mut literal = String::new();
7528 for part in matcher.template().parts() {
7529 let UriTemplatePart::Literal(part) = part else {
7530 break;
7531 };
7532 literal.push_str(part);
7533 }
7534 fastmcp_protocol::UriTemplate::parse(&literal)
7535 .and_then(|template| template.expand(&fastmcp_protocol::TemplateValues::new()))
7536 .map_err(|_| McpError::internal_error("admitted resource template lost literal prefix"))
7537}
7538
7539use fastmcp_core::{
7544 MAX_RESOURCE_READ_DEPTH, ResourceContentItem, ResourceReadResult, ResourceReader,
7545 ToolCallResult, ToolContentItem,
7546};
7547use std::pin::Pin;
7548
7549fn nested_resource_content_from_final(contents: EmbeddedResourceContents) -> ResourceContentItem {
7550 match contents {
7551 EmbeddedResourceContents::Text {
7552 uri,
7553 text,
7554 mime_type,
7555 ..
7556 } => ResourceContentItem {
7557 uri: uri.as_str().to_owned(),
7558 mime_type,
7559 text: Some(text),
7560 blob: None,
7561 },
7562 EmbeddedResourceContents::Blob {
7563 uri,
7564 blob,
7565 mime_type,
7566 ..
7567 } => ResourceContentItem {
7568 uri: uri.as_str().to_owned(),
7569 mime_type,
7570 text: None,
7571 blob: Some(blob),
7572 },
7573 }
7574}
7575
7576fn nested_tool_content_from_final(block: ContentBlock) -> ToolContentItem {
7577 match block {
7578 ContentBlock::Text { text, .. } => ToolContentItem::Text { text },
7579 ContentBlock::Image {
7580 data, mime_type, ..
7581 } => ToolContentItem::Image { data, mime_type },
7582 ContentBlock::Audio {
7583 data, mime_type, ..
7584 } => ToolContentItem::Audio { data, mime_type },
7585 ContentBlock::Resource { resource, .. } => match resource {
7586 EmbeddedResourceContents::Text {
7587 uri,
7588 text,
7589 mime_type,
7590 ..
7591 } => ToolContentItem::Resource {
7592 uri: uri.as_str().to_owned(),
7593 mime_type,
7594 text: Some(text),
7595 blob: None,
7596 },
7597 EmbeddedResourceContents::Blob {
7598 uri,
7599 blob,
7600 mime_type,
7601 ..
7602 } => ToolContentItem::Resource {
7603 uri: uri.as_str().to_owned(),
7604 mime_type,
7605 text: None,
7606 blob: Some(blob),
7607 },
7608 },
7609 ContentBlock::ResourceLink { uri, mime_type, .. } => ToolContentItem::Resource {
7610 uri: uri.as_str().to_owned(),
7611 mime_type,
7612 text: None,
7613 blob: None,
7614 },
7615 }
7616}
7617
7618#[derive(Clone)]
7623enum RouterAccess {
7624 Shared(Arc<Router>),
7625 RequestScoped(Weak<Router>),
7626}
7627
7628impl RouterAccess {
7629 fn upgrade(&self) -> McpResult<Arc<Router>> {
7630 match self {
7631 Self::Shared(router) => Ok(Arc::clone(router)),
7632 Self::RequestScoped(router) => router.upgrade().ok_or_else(|| {
7633 McpError::new(
7634 McpErrorCode::RequestCancelled,
7635 "Request router is no longer available",
7636 )
7637 }),
7638 }
7639 }
7640}
7641
7642pub(crate) struct RouterResourceReader {
7643 router: RouterAccess,
7645 session_state: SessionState,
7647}
7648
7649impl RouterResourceReader {
7650 #[must_use]
7652 pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
7653 Self {
7654 router: RouterAccess::Shared(router),
7655 session_state,
7656 }
7657 }
7658
7659 pub(crate) fn request_scoped(router: Weak<Router>, session_state: SessionState) -> Self {
7660 Self {
7661 router: RouterAccess::RequestScoped(router),
7662 session_state,
7663 }
7664 }
7665
7666 fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
7667 Self {
7668 router,
7669 session_state,
7670 }
7671 }
7672
7673 async fn read_resource_legacy(
7674 router: &Router,
7675 parent_ctx: &McpContext,
7676 router_access: RouterAccess,
7677 session_state: SessionState,
7678 uri: String,
7679 depth: u32,
7680 ) -> McpResult<ResourceReadResult> {
7681 let operation_started_at = parent_ctx.cx().now();
7682 if let Some(error) = budget_error(parent_ctx) {
7683 return Err(error);
7684 }
7685 if !session_state.is_resource_enabled(&uri) {
7686 return Err(McpError::new(
7687 McpErrorCode::ResourceNotFound,
7688 format!("Resource '{uri}' is disabled for this session"),
7689 ));
7690 }
7691
7692 let resolved = router.resolve_resource(&uri).ok_or_else(|| {
7693 McpError::new(
7694 McpErrorCode::ResourceNotFound,
7695 format!("Resource not found: {uri}"),
7696 )
7697 })?;
7698 let handler_timeout = read_handler_timeout(parent_ctx.cx(), "resource_timeout", || {
7699 resolved.handler.timeout()
7700 })?;
7701 let effective_budget = compose_handler_budget(
7702 parent_ctx.cx().budget(),
7703 parent_ctx.budget(),
7704 handler_timeout,
7705 operation_started_at,
7706 );
7707 let child_ctx = with_nested_component_access(
7708 parent_ctx
7709 .clone()
7710 .with_operation_deadline(effective_budget.deadline),
7711 router_access.clone(),
7712 session_state.clone(),
7713 depth,
7714 );
7715 let outcome = run_handler_in_request(
7716 &child_ctx,
7717 parent_ctx.cx(),
7718 effective_budget,
7719 "resource",
7720 |request_cx| {
7721 resolved.handler.read_async_with_uri_in_request(
7722 &child_ctx,
7723 request_cx,
7724 &uri,
7725 &resolved.params,
7726 )
7727 },
7728 )
7729 .await?;
7730 let contents = match outcome {
7731 Outcome::Ok(contents) => contents,
7732 Outcome::Err(error) => {
7733 return Err(sanitize_handler_error(parent_ctx.cx(), "resource", error));
7734 }
7735 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
7736 Outcome::Panicked(_payload) => {
7737 return Err(sanitized_handler_panic(parent_ctx.cx(), "resource"));
7738 }
7739 };
7740 Ok(ResourceReadResult::new(
7741 contents
7742 .into_iter()
7743 .map(|content| ResourceContentItem {
7744 uri: content.uri,
7745 mime_type: content.mime_type,
7746 text: content.text,
7747 blob: content.blob,
7748 })
7749 .collect(),
7750 ))
7751 }
7752
7753 async fn read_resource_final(
7754 router: &Router,
7755 parent_ctx: &McpContext,
7756 router_access: RouterAccess,
7757 session_state: SessionState,
7758 uri: String,
7759 depth: u32,
7760 ) -> McpResult<ResourceReadResult> {
7761 let operation_started_at = parent_ctx.cx().now();
7762 if let Some(error) = budget_error(parent_ctx) {
7763 return Err(error);
7764 }
7765 if !session_state.is_resource_enabled(&uri) || !parent_ctx.is_resource_enabled(&uri) {
7766 return Err(McpError::new(
7767 McpErrorCode::ResourceNotFound,
7768 format!("Resource '{uri}' is disabled for this session"),
7769 ));
7770 }
7771 let absolute = AbsoluteUri::parse(uri.clone()).map_err(|_| {
7772 McpError::with_data(
7773 McpErrorCode::InvalidParams,
7774 "Resource not found",
7775 serde_json::json!({ "uri": uri }),
7776 )
7777 })?;
7778 let resolved = router
7779 .resolve_resource_for_era(&uri, Some(ProtocolEra::Modern2026))
7780 .ok_or_else(|| {
7781 McpError::with_data(
7782 McpErrorCode::InvalidParams,
7783 "Resource not found",
7784 serde_json::json!({ "uri": uri }),
7785 )
7786 })?;
7787 if !resolved.final_enabled {
7788 return Err(McpError::invalid_params(
7789 "resource is registered only for exact MCP 2024-11-05 dispatch",
7790 ));
7791 }
7792 if resolved.handler.declares_final_mrtr() && parent_ctx.session_cache_partition().is_none()
7793 {
7794 return Err(McpError::invalid_params(
7795 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
7796 ));
7797 }
7798 admit_final_resource_uri(
7799 resolved.uri_use_policy,
7800 &absolute,
7801 FinalResourceUriUse::ResourceReadTarget,
7802 )?;
7803 let handler_timeout = read_handler_timeout(parent_ctx.cx(), "resource_timeout", || {
7804 resolved.handler.timeout()
7805 })?;
7806 let effective_budget = compose_handler_budget(
7807 parent_ctx.cx().budget(),
7808 parent_ctx.budget(),
7809 handler_timeout,
7810 operation_started_at,
7811 );
7812 let child_ctx = with_nested_component_access(
7813 parent_ctx
7814 .clone()
7815 .with_operation_deadline(effective_budget.deadline),
7816 router_access,
7817 session_state,
7818 depth,
7819 );
7820 let outcome = run_handler_in_request(
7821 &child_ctx,
7822 parent_ctx.cx(),
7823 effective_budget,
7824 "resource",
7825 |request_cx| {
7826 resolved
7827 .handler
7828 .read_final_outcome_async_with_uri_in_request(
7829 &child_ctx,
7830 request_cx,
7831 &uri,
7832 &resolved.params,
7833 )
7834 },
7835 )
7836 .await?;
7837 let result = match outcome {
7838 Outcome::Ok(result) => result,
7839 Outcome::Err(error) => {
7840 return Err(sanitize_handler_error(parent_ctx.cx(), "resource", error));
7841 }
7842 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
7843 Outcome::Panicked(_payload) => {
7844 return Err(sanitized_handler_panic(parent_ctx.cx(), "resource"));
7845 }
7846 };
7847 admit_final_resource_read_outcome(resolved.uri_use_policy, &result)?;
7848 let FinalMethodOutcome::Complete(complete) = result else {
7849 return Err(McpError::internal_error(
7850 "nested resource read cannot complete an input_required outcome",
7851 ));
7852 };
7853 Ok(ResourceReadResult::new(
7854 complete
7855 .payload
7856 .contents
7857 .into_iter()
7858 .map(nested_resource_content_from_final)
7859 .collect(),
7860 ))
7861 }
7862}
7863
7864impl ResourceReader for RouterResourceReader {
7865 fn read_resource<'a>(
7866 &'a self,
7867 parent_ctx: &'a McpContext,
7868 uri: &'a str,
7869 depth: u32,
7870 ) -> Pin<
7871 Box<
7872 dyn std::future::Future<Output = fastmcp_core::McpResult<ResourceReadResult>>
7873 + Send
7874 + 'a,
7875 >,
7876 > {
7877 if depth > MAX_RESOURCE_READ_DEPTH {
7879 return Box::pin(async move {
7880 Err(McpError::new(
7881 McpErrorCode::InternalError,
7882 format!(
7883 "Maximum resource read depth ({}) exceeded",
7884 MAX_RESOURCE_READ_DEPTH
7885 ),
7886 ))
7887 });
7888 }
7889
7890 let parent_ctx = parent_ctx.clone();
7892 let uri = uri.to_string();
7893 let router_access = self.router.clone();
7894 let session_state = self.session_state.clone();
7895 let final_surface = parent_ctx.is_final_request_surface();
7896
7897 Box::pin(async move {
7898 debug!(
7899 target: targets::HANDLER,
7900 "cross-component resource read; resource_key={}; depth={}; request={}; final_surface={}",
7901 safe_log_label(&uri),
7902 depth,
7903 parent_ctx.request_id(),
7904 final_surface
7905 );
7906 let router = router_access.upgrade()?;
7907 if final_surface {
7908 Self::read_resource_final(
7909 &router,
7910 &parent_ctx,
7911 router_access,
7912 session_state,
7913 uri,
7914 depth,
7915 )
7916 .await
7917 } else {
7918 Self::read_resource_legacy(
7919 &router,
7920 &parent_ctx,
7921 router_access,
7922 session_state,
7923 uri,
7924 depth,
7925 )
7926 .await
7927 }
7928 })
7929 }
7930}
7931
7932use fastmcp_core::{MAX_TOOL_CALL_DEPTH, ToolCaller};
7937
7938pub(crate) struct RouterToolCaller {
7943 router: RouterAccess,
7945 session_state: SessionState,
7947}
7948
7949impl RouterToolCaller {
7950 #[must_use]
7952 pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
7953 Self {
7954 router: RouterAccess::Shared(router),
7955 session_state,
7956 }
7957 }
7958
7959 pub(crate) fn request_scoped(router: Weak<Router>, session_state: SessionState) -> Self {
7960 Self {
7961 router: RouterAccess::RequestScoped(router),
7962 session_state,
7963 }
7964 }
7965
7966 fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
7967 Self {
7968 router,
7969 session_state,
7970 }
7971 }
7972
7973 async fn call_tool_legacy(
7974 router: &Router,
7975 parent_ctx: &McpContext,
7976 router_access: RouterAccess,
7977 session_state: SessionState,
7978 name: String,
7979 args: serde_json::Value,
7980 depth: u32,
7981 ) -> McpResult<ToolCallResult> {
7982 let operation_started_at = parent_ctx.cx().now();
7983 if let Some(error) = budget_error(parent_ctx) {
7984 return Err(error);
7985 }
7986 if !session_state.is_tool_enabled(&name) {
7987 return Err(McpError::new(
7988 McpErrorCode::MethodNotFound,
7989 format!("Tool '{name}' is disabled for this session"),
7990 ));
7991 }
7992 let entry = router
7993 .tools
7994 .get(&name)
7995 .ok_or_else(|| McpError::method_not_found(&format!("tool: {name}")))?;
7996 if !entry.legacy_enabled {
7997 return Err(McpError::method_not_found(&format!("tool: {name}")));
7998 }
7999 let handler = &entry.handler;
8000 let validation_result = if router.strict_input_validation {
8001 validate_strict(&entry.definition.input_schema, &args)
8002 } else {
8003 validate(&entry.definition.input_schema, &args)
8004 };
8005 if let Err(validation_errors) = validation_result {
8006 let error_messages: Vec<String> = validation_errors
8007 .iter()
8008 .map(|error| format!("{}: {}", error.path, error.message))
8009 .collect();
8010 return Err(McpError::invalid_params(format!(
8011 "Input validation failed: {}",
8012 error_messages.join("; ")
8013 )));
8014 }
8015 let handler_timeout =
8016 read_handler_timeout(parent_ctx.cx(), "tool_timeout", || handler.timeout())?;
8017 let effective_budget = compose_handler_budget(
8018 parent_ctx.cx().budget(),
8019 parent_ctx.budget(),
8020 handler_timeout,
8021 operation_started_at,
8022 );
8023 let child_ctx = with_nested_component_access(
8024 parent_ctx
8025 .clone()
8026 .with_operation_deadline(effective_budget.deadline),
8027 router_access.clone(),
8028 session_state.clone(),
8029 depth,
8030 );
8031 let outcome = run_handler_in_request(
8032 &child_ctx,
8033 parent_ctx.cx(),
8034 effective_budget,
8035 "tool",
8036 |request_cx| handler.call_async_in_request(&child_ctx, request_cx, args),
8037 )
8038 .await?;
8039 match outcome {
8040 Outcome::Ok(content) => Ok(ToolCallResult::success(
8041 content
8042 .into_iter()
8043 .map(|content| match content {
8044 Content::Text { text } => ToolContentItem::Text { text },
8045 Content::Image { data, mime_type } => {
8046 ToolContentItem::Image { data, mime_type }
8047 }
8048 Content::Audio { data, mime_type } => {
8049 ToolContentItem::Audio { data, mime_type }
8050 }
8051 Content::Resource { resource } => ToolContentItem::Resource {
8052 uri: resource.uri,
8053 mime_type: resource.mime_type,
8054 text: resource.text,
8055 blob: resource.blob,
8056 },
8057 })
8058 .collect(),
8059 )),
8060 Outcome::Err(error) => {
8061 let error = sanitize_handler_error(parent_ctx.cx(), "tool", error);
8062 if is_framework_terminal_tool_error(error.code) {
8063 return Err(error);
8064 }
8065 Ok(ToolCallResult::error(error.message))
8066 }
8067 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
8068 Outcome::Panicked(_payload) => Err(sanitized_handler_panic(parent_ctx.cx(), "tool")),
8069 }
8070 }
8071
8072 async fn call_tool_final(
8073 router: &Router,
8074 parent_ctx: &McpContext,
8075 router_access: RouterAccess,
8076 session_state: SessionState,
8077 name: String,
8078 args: serde_json::Value,
8079 depth: u32,
8080 ) -> McpResult<ToolCallResult> {
8081 let operation_started_at = parent_ctx.cx().now();
8082 if let Some(error) = budget_error(parent_ctx) {
8083 return Err(error);
8084 }
8085 if !session_state.is_tool_enabled(&name) || !parent_ctx.is_tool_enabled(&name) {
8086 return Err(McpError::new(
8087 McpErrorCode::MethodNotFound,
8088 format!("Tool '{name}' is disabled for this session"),
8089 ));
8090 }
8091 let entry = router
8092 .tools
8093 .get(&name)
8094 .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {name}")))?;
8095 let final_registration = entry
8096 .final_registration
8097 .as_ref()
8098 .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {name}")))?;
8099 let handler = &entry.handler;
8100 if handler.declares_final_mrtr() && parent_ctx.session_cache_partition().is_none() {
8101 return Err(McpError::invalid_params(
8102 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
8103 ));
8104 }
8105 let input_schema = final_registration.schemas.input.as_ref();
8106 let input_validation_failed = match input_schema {
8107 Some(schema) => {
8108 let validation = if router.strict_input_validation {
8109 validate_strict(schema.schema(), &args)
8110 } else {
8111 schema.validate(&args)
8112 };
8113 validation.is_err()
8114 }
8115 None if router.strict_input_validation => {
8116 validate_strict(&final_registration.final_definition.input_schema, &args).is_err()
8117 }
8118 None => false,
8119 };
8120 if input_validation_failed {
8121 return Ok(ToolCallResult::error(
8122 "Tool arguments do not match the declared input schema.",
8123 ));
8124 }
8125 let handler_timeout =
8126 read_handler_timeout(parent_ctx.cx(), "tool_timeout", || handler.timeout())?;
8127 let effective_budget = compose_handler_budget(
8128 parent_ctx.cx().budget(),
8129 parent_ctx.budget(),
8130 handler_timeout,
8131 operation_started_at,
8132 );
8133 let child_ctx = with_nested_component_access(
8134 parent_ctx
8135 .clone()
8136 .with_operation_deadline(effective_budget.deadline),
8137 router_access,
8138 session_state,
8139 depth,
8140 );
8141 let outcome = run_handler_in_request(
8142 &child_ctx,
8143 parent_ctx.cx(),
8144 effective_budget,
8145 "tool",
8146 |request_cx| {
8147 handler.call_final_outcome_async_resuming_in_request(
8148 &child_ctx, request_cx, args, None,
8149 )
8150 },
8151 )
8152 .await?;
8153 match outcome {
8154 Outcome::Ok(result) => match result {
8155 FinalToolOutcome::Complete(complete) => Ok(ToolCallResult {
8156 content: complete
8157 .payload
8158 .content
8159 .into_iter()
8160 .map(nested_tool_content_from_final)
8161 .collect(),
8162 is_error: complete.payload.is_error,
8163 }),
8164 FinalToolOutcome::InputRequired(_) => Err(McpError::internal_error(
8165 "nested tool call cannot complete an input_required outcome",
8166 )),
8167 #[cfg(feature = "tasks")]
8168 FinalToolOutcome::CreateTask { .. } => Err(McpError::internal_error(
8169 "nested tool call cannot create a final task",
8170 )),
8171 },
8172 Outcome::Err(error) => {
8173 let error = sanitize_handler_error(parent_ctx.cx(), "tool", error);
8174 if is_framework_terminal_tool_error(error.code) {
8175 return Err(error);
8176 }
8177 Ok(ToolCallResult::error(error.message))
8178 }
8179 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
8180 Outcome::Panicked(_payload) => Err(sanitized_handler_panic(parent_ctx.cx(), "tool")),
8181 }
8182 }
8183}
8184
8185impl ToolCaller for RouterToolCaller {
8186 fn call_tool<'a>(
8187 &'a self,
8188 parent_ctx: &'a McpContext,
8189 name: &'a str,
8190 args: serde_json::Value,
8191 depth: u32,
8192 ) -> Pin<
8193 Box<dyn std::future::Future<Output = fastmcp_core::McpResult<ToolCallResult>> + Send + 'a>,
8194 > {
8195 if depth > MAX_TOOL_CALL_DEPTH {
8197 return Box::pin(async move {
8198 Err(McpError::new(
8199 McpErrorCode::InternalError,
8200 format!("Maximum tool call depth ({}) exceeded", MAX_TOOL_CALL_DEPTH),
8201 ))
8202 });
8203 }
8204
8205 let parent_ctx = parent_ctx.clone();
8207 let name = name.to_string();
8208 let router_access = self.router.clone();
8209 let session_state = self.session_state.clone();
8210 let final_surface = parent_ctx.is_final_request_surface();
8211
8212 Box::pin(async move {
8213 debug!(
8214 target: targets::HANDLER,
8215 "cross-component tool call; tool_key={}; depth={}; request={}; final_surface={}",
8216 safe_log_label(&name),
8217 depth,
8218 parent_ctx.request_id(),
8219 final_surface
8220 );
8221 let router = router_access.upgrade()?;
8222 if final_surface {
8223 Self::call_tool_final(
8224 &router,
8225 &parent_ctx,
8226 router_access,
8227 session_state,
8228 name,
8229 args,
8230 depth,
8231 )
8232 .await
8233 } else {
8234 Self::call_tool_legacy(
8235 &router,
8236 &parent_ctx,
8237 router_access,
8238 session_state,
8239 name,
8240 args,
8241 depth,
8242 )
8243 .await
8244 }
8245 })
8246 }
8247}
8248
8249fn with_nested_component_access(
8250 ctx: McpContext,
8251 router: RouterAccess,
8252 state: SessionState,
8253 depth: u32,
8254) -> McpContext {
8255 ctx.with_resource_read_depth(depth)
8256 .with_tool_call_depth(depth)
8257 .with_prompt_get_depth(depth)
8258 .with_tool_caller(Arc::new(RouterToolCaller::from_access(
8259 router.clone(),
8260 state.clone(),
8261 )))
8262 .with_resource_reader(Arc::new(RouterResourceReader::from_access(
8263 router.clone(),
8264 state.clone(),
8265 )))
8266 .with_prompt_caller(Arc::new(RouterPromptCaller::from_access(router, state)))
8267}
8268
8269pub(crate) struct RouterPromptCaller {
8274 router: RouterAccess,
8275 session_state: SessionState,
8276}
8277
8278impl RouterPromptCaller {
8279 #[must_use]
8280 pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
8281 Self {
8282 router: RouterAccess::Shared(router),
8283 session_state,
8284 }
8285 }
8286
8287 fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
8288 Self {
8289 router,
8290 session_state,
8291 }
8292 }
8293}
8294
8295impl PromptCaller for RouterPromptCaller {
8296 fn get_prompt<'a>(
8297 &'a self,
8298 parent_ctx: &'a McpContext,
8299 name: &'a str,
8300 arguments: HashMap<String, String>,
8301 depth: u32,
8302 ) -> Pin<
8303 Box<dyn std::future::Future<Output = fastmcp_core::McpResult<PromptGetResult>> + Send + 'a>,
8304 > {
8305 if depth > fastmcp_core::MAX_PROMPT_GET_DEPTH {
8306 return Box::pin(async move {
8307 Err(McpError::new(
8308 McpErrorCode::InternalError,
8309 format!(
8310 "Maximum prompt get depth ({}) exceeded",
8311 fastmcp_core::MAX_PROMPT_GET_DEPTH
8312 ),
8313 ))
8314 });
8315 }
8316
8317 let parent_ctx = parent_ctx.clone();
8318 let name = name.to_string();
8319 let router_access = self.router.clone();
8320 let session_state = self.session_state.clone();
8321 let final_surface = parent_ctx.is_final_request_surface();
8322
8323 Box::pin(async move {
8324 debug!(
8325 target: targets::HANDLER,
8326 "cross-component prompt get; prompt_key={}; depth={}; request={}; final_surface={}",
8327 safe_log_label(&name),
8328 depth,
8329 parent_ctx.request_id(),
8330 final_surface
8331 );
8332 let router = router_access.upgrade()?;
8333 if final_surface {
8334 Self::get_prompt_final(
8335 &router,
8336 &parent_ctx,
8337 router_access,
8338 session_state,
8339 name,
8340 arguments,
8341 depth,
8342 )
8343 .await
8344 } else {
8345 Self::get_prompt_legacy(
8346 &router,
8347 &parent_ctx,
8348 router_access,
8349 session_state,
8350 name,
8351 arguments,
8352 depth,
8353 )
8354 .await
8355 }
8356 })
8357 }
8358}
8359
8360impl RouterPromptCaller {
8361 async fn get_prompt_legacy(
8362 router: &Router,
8363 parent_ctx: &McpContext,
8364 router_access: RouterAccess,
8365 session_state: SessionState,
8366 name: String,
8367 arguments: HashMap<String, String>,
8368 depth: u32,
8369 ) -> McpResult<PromptGetResult> {
8370 let operation_started_at = parent_ctx.cx().now();
8371 if let Some(error) = budget_error(parent_ctx) {
8372 return Err(error);
8373 }
8374 if !session_state.is_prompt_enabled(&name) {
8375 return Err(McpError::new(
8376 McpErrorCode::PromptNotFound,
8377 format!("Prompt '{name}' is disabled for this session"),
8378 ));
8379 }
8380 let handler = router.prompts.get(&name).ok_or_else(|| {
8381 McpError::new(
8382 McpErrorCode::PromptNotFound,
8383 format!("Prompt not found: {name}"),
8384 )
8385 })?;
8386 if router.final_only_prompts.contains(&name) {
8387 return Err(McpError::new(
8388 McpErrorCode::PromptNotFound,
8389 format!("Prompt not found: {name}"),
8390 ));
8391 }
8392 let description = crate::catch_extension_unwind(|| handler.definition().description)
8393 .map_err(|_payload| sanitized_handler_panic(parent_ctx.cx(), "prompt_definition"))?;
8394 let handler_timeout =
8395 read_handler_timeout(parent_ctx.cx(), "prompt_timeout", || handler.timeout())?;
8396 let effective_budget = compose_handler_budget(
8397 parent_ctx.cx().budget(),
8398 parent_ctx.budget(),
8399 handler_timeout,
8400 operation_started_at,
8401 );
8402 let child_ctx = with_nested_component_access(
8403 parent_ctx
8404 .clone()
8405 .with_operation_deadline(effective_budget.deadline),
8406 router_access,
8407 session_state,
8408 depth,
8409 );
8410 let outcome = run_handler_in_request(
8411 &child_ctx,
8412 parent_ctx.cx(),
8413 effective_budget,
8414 "prompt",
8415 |request_cx| handler.get_async_in_request(&child_ctx, request_cx, arguments),
8416 )
8417 .await?;
8418 let messages = match outcome {
8419 Outcome::Ok(messages) => messages,
8420 Outcome::Err(error) => {
8421 return Err(sanitize_handler_error(parent_ctx.cx(), "prompt", error));
8422 }
8423 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
8424 Outcome::Panicked(_payload) => {
8425 return Err(sanitized_handler_panic(parent_ctx.cx(), "prompt"));
8426 }
8427 };
8428 Ok(PromptGetResult {
8429 description,
8430 messages: messages
8431 .into_iter()
8432 .map(|message| PromptMessageItem {
8433 role: match message.role {
8434 Role::User => PromptMessageRole::User,
8435 Role::Assistant => PromptMessageRole::Assistant,
8436 },
8437 text: match message.content {
8438 Content::Text { text } => Some(text),
8439 _ => None,
8440 },
8441 })
8442 .collect(),
8443 })
8444 }
8445
8446 async fn get_prompt_final(
8447 router: &Router,
8448 parent_ctx: &McpContext,
8449 router_access: RouterAccess,
8450 session_state: SessionState,
8451 name: String,
8452 arguments: HashMap<String, String>,
8453 depth: u32,
8454 ) -> McpResult<PromptGetResult> {
8455 let operation_started_at = parent_ctx.cx().now();
8456 if let Some(error) = budget_error(parent_ctx) {
8457 return Err(error);
8458 }
8459 if !session_state.is_prompt_enabled(&name) || !parent_ctx.is_prompt_enabled(&name) {
8460 return Err(McpError::new(
8461 McpErrorCode::PromptNotFound,
8462 format!("Prompt '{name}' is disabled for this session"),
8463 ));
8464 }
8465 let handler = router
8466 .prompts
8467 .get(&name)
8468 .ok_or_else(|| McpError::invalid_params(format!("Unknown prompt: {name}")))?;
8469 let final_registration = router.final_prompts.get(&name).ok_or_else(|| {
8470 McpError::invalid_params("prompt is registered only for exact MCP 2024-11-05 dispatch")
8471 })?;
8472 if handler.declares_final_mrtr() && parent_ctx.session_cache_partition().is_none() {
8473 return Err(McpError::invalid_params(
8474 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
8475 ));
8476 }
8477 admit_declared_final_prompt_arguments(
8478 final_registration
8479 .definition
8480 .arguments
8481 .as_deref()
8482 .unwrap_or_default(),
8483 arguments.keys(),
8484 )?;
8485 let handler_timeout =
8486 read_handler_timeout(parent_ctx.cx(), "prompt_timeout", || handler.timeout())?;
8487 let effective_budget = compose_handler_budget(
8488 parent_ctx.cx().budget(),
8489 parent_ctx.budget(),
8490 handler_timeout,
8491 operation_started_at,
8492 );
8493 let child_ctx = with_nested_component_access(
8494 parent_ctx
8495 .clone()
8496 .with_operation_deadline(effective_budget.deadline),
8497 router_access,
8498 session_state,
8499 depth,
8500 );
8501 let outcome = run_handler_in_request(
8502 &child_ctx,
8503 parent_ctx.cx(),
8504 effective_budget,
8505 "prompt",
8506 |request_cx| {
8507 handler.get_final_outcome_async_in_request(&child_ctx, request_cx, arguments)
8508 },
8509 )
8510 .await?;
8511 let result = match outcome {
8512 Outcome::Ok(result) => result,
8513 Outcome::Err(error) => {
8514 return Err(sanitize_handler_error(parent_ctx.cx(), "prompt", error));
8515 }
8516 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
8517 Outcome::Panicked(_payload) => {
8518 return Err(sanitized_handler_panic(parent_ctx.cx(), "prompt"));
8519 }
8520 };
8521 admit_final_prompt_outcome(final_registration.uri_use_policy, &result)?;
8522 let FinalMethodOutcome::Complete(complete) = result else {
8523 return Err(McpError::internal_error(
8524 "nested prompt get cannot complete an input_required outcome",
8525 ));
8526 };
8527 Ok(PromptGetResult {
8528 description: complete.payload.description,
8529 messages: complete
8530 .payload
8531 .messages
8532 .into_iter()
8533 .map(|message| PromptMessageItem {
8534 role: match message.role {
8535 Role::User => PromptMessageRole::User,
8536 Role::Assistant => PromptMessageRole::Assistant,
8537 },
8538 text: match message.content {
8539 fastmcp_protocol::common_types::ContentBlock::Text { text, .. } => {
8540 Some(text)
8541 }
8542 _ => None,
8543 },
8544 })
8545 .collect(),
8546 })
8547 }
8548}
8549
8550#[cfg(test)]
8551mod safe_log_label_tests {
8552 use super::{LOG_LABEL_HASH_INPUT_LIMIT, safe_log_label};
8553
8554 #[test]
8555 fn safe_label_is_deterministic_non_verbatim_metadata() {
8556 let canary = "router-log-canary-secret";
8557 let first = format!("{}", safe_log_label(canary));
8558 let second = format!("{:?}", safe_log_label(canary));
8559
8560 assert_eq!(first, second);
8561 assert!(first.contains(&format!("bytes={}", canary.len())));
8562 assert!(first.contains("sha256_prefix="));
8563 assert!(!first.contains(canary));
8564 assert_ne!(first, format!("{}", safe_log_label("different-label")));
8565 }
8566
8567 #[test]
8568 fn safe_label_hashing_is_bounded_for_oversized_input() {
8569 let oversized = "x".repeat(LOG_LABEL_HASH_INPUT_LIMIT + 37);
8570 let rendered = format!("{}", safe_log_label(&oversized));
8571
8572 assert!(rendered.contains(&format!("bytes={}", oversized.len())));
8573 assert!(rendered.contains(&format!("hashed_prefix_bytes={LOG_LABEL_HASH_INPUT_LIMIT}")));
8574 assert!(!rendered.contains(&oversized));
8575 }
8576
8577 #[test]
8578 fn source_has_no_verbatim_argument_or_label_log_formats() {
8579 let source = include_str!("router.rs");
8580 let forbidden = [
8581 concat!("Tool ", "arguments:"),
8582 concat!("Prompt ", "arguments:"),
8583 concat!("Calling ", "tool: {}"),
8584 concat!("Reading ", "resource: {}"),
8585 concat!("Getting ", "prompt: {}"),
8586 concat!("Cross-component tool ", "call: {}"),
8587 concat!("Cross-component resource ", "read: {}"),
8588 concat!("Invalid URI template ", "'{}'"),
8589 ];
8590
8591 for format in forbidden {
8592 assert!(!source.contains(format), "raw log format remains: {format}");
8593 }
8594 }
8595}
8596
8597#[cfg(test)]
8598mod cursor_tests {
8599 use super::{
8600 FinalCatalogKind, FinalCatalogQuery, decode_cursor_offset,
8601 decode_final_catalog_cursor_offset, encode_cursor_offset, encode_final_catalog_cursor,
8602 };
8603
8604 #[test]
8605 fn roundtrip_zero() {
8606 let encoded = encode_cursor_offset(0);
8607 let decoded = decode_cursor_offset(Some(&encoded)).unwrap();
8608 assert_eq!(decoded, 0);
8609 }
8610
8611 #[test]
8612 fn roundtrip_large_offset() {
8613 let encoded = encode_cursor_offset(12345);
8614 let decoded = decode_cursor_offset(Some(&encoded)).unwrap();
8615 assert_eq!(decoded, 12345);
8616 }
8617
8618 #[test]
8619 fn none_cursor_returns_zero() {
8620 assert_eq!(decode_cursor_offset(None).unwrap(), 0);
8621 }
8622
8623 #[test]
8624 fn invalid_base64_returns_error() {
8625 let err = decode_cursor_offset(Some("not-valid-base64!!!")).unwrap_err();
8626 assert!(err.message.contains("base64"));
8627 }
8628
8629 #[test]
8630 fn valid_base64_but_not_json_returns_error() {
8631 let encoded =
8632 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"not json");
8633 let err = decode_cursor_offset(Some(&encoded)).unwrap_err();
8634 assert!(err.message.contains("JSON"));
8635 }
8636
8637 #[test]
8638 fn valid_json_but_no_offset_returns_error() {
8639 let payload = serde_json::json!({"other": 1});
8640 let bytes = serde_json::to_vec(&payload).unwrap();
8641 let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
8642 let err = decode_cursor_offset(Some(&encoded)).unwrap_err();
8643 assert!(err.message.contains("offset"));
8644 }
8645
8646 #[test]
8647 fn final_catalog_cursor_binds_the_catalog_revision_and_query() {
8648 let include_tags = vec!["Visible".to_owned(), "visible".to_owned()];
8649 let exclude_tags = vec!["excluded".to_owned()];
8650 let query = FinalCatalogQuery::from_tag_filters(Some(&include_tags), Some(&exclude_tags));
8651 let cursor = encode_final_catalog_cursor(FinalCatalogKind::Resources, 41, &query, 7, None);
8652 let equivalent_include_tags = vec!["visible".to_owned()];
8653 let equivalent_query = FinalCatalogQuery::from_tag_filters(
8654 Some(&equivalent_include_tags),
8655 Some(&exclude_tags),
8656 );
8657 assert_eq!(
8658 decode_final_catalog_cursor_offset(
8659 Some(&cursor),
8660 FinalCatalogKind::Resources,
8661 41,
8662 &equivalent_query,
8663 None,
8664 8,
8665 )
8666 .expect("a final cursor accepts a semantically equivalent canonical query"),
8667 7
8668 );
8669 let stale = decode_final_catalog_cursor_offset(
8670 Some(&cursor),
8671 FinalCatalogKind::Resources,
8672 42,
8673 &query,
8674 None,
8675 8,
8676 )
8677 .expect_err("changing only the catalog revision rejects a stale continuation");
8678 assert!(stale.message.contains("stale catalog revision"));
8679
8680 let wrong_kind = decode_final_catalog_cursor_offset(
8681 Some(&cursor),
8682 FinalCatalogKind::Prompts,
8683 41,
8684 &query,
8685 None,
8686 8,
8687 )
8688 .expect_err("changing only the list method rejects a cross-catalog continuation");
8689 assert!(wrong_kind.message.contains("another list method"));
8690
8691 let other_include_tags = vec!["other".to_owned()];
8692 let other_query =
8693 FinalCatalogQuery::from_tag_filters(Some(&other_include_tags), Some(&exclude_tags));
8694 let wrong_query = decode_final_catalog_cursor_offset(
8695 Some(&cursor),
8696 FinalCatalogKind::Resources,
8697 41,
8698 &other_query,
8699 None,
8700 8,
8701 )
8702 .expect_err("changing only the request filters rejects the continuation");
8703 assert!(wrong_query.message.contains("query filters"));
8704
8705 let out_of_range =
8706 encode_final_catalog_cursor(FinalCatalogKind::Resources, 41, &query, 8, None);
8707 let range_error = decode_final_catalog_cursor_offset(
8708 Some(&out_of_range),
8709 FinalCatalogKind::Resources,
8710 41,
8711 &query,
8712 None,
8713 8,
8714 )
8715 .expect_err("an offset at the end of a catalog is never a router-minted continuation");
8716 assert!(
8717 range_error
8718 .message
8719 .contains("outside the requested catalog page")
8720 );
8721
8722 let visible =
8723 encode_final_catalog_cursor(FinalCatalogKind::Tools, 41, &query, 0, Some("digest-a"));
8724 let visibility_error = decode_final_catalog_cursor_offset(
8725 Some(&visible),
8726 FinalCatalogKind::Tools,
8727 41,
8728 &query,
8729 Some("digest-b"),
8730 2,
8731 )
8732 .expect_err("changing only session visibility rejects the continuation");
8733 assert!(visibility_error.message.contains("session visibility"));
8734 assert_eq!(
8735 decode_final_catalog_cursor_offset(
8736 Some(&visible),
8737 FinalCatalogKind::Tools,
8738 41,
8739 &query,
8740 Some("digest-a"),
8741 2,
8742 )
8743 .expect("matching session visibility admits the continuation"),
8744 0
8745 );
8746 }
8747}
8748
8749#[cfg(test)]
8750mod tag_filter_tests {
8751 use super::TagFilters;
8752
8753 #[test]
8754 fn no_filters_matches_anything() {
8755 let f = TagFilters::default();
8756 assert!(f.matches(&[]));
8757 assert!(f.matches(&["a".to_string()]));
8758 }
8759
8760 #[test]
8761 fn include_filter_requires_all_tags() {
8762 let include = vec!["a".to_string(), "b".to_string()];
8763 let f = TagFilters::new(Some(&include), None);
8764 assert!(f.matches(&["a".to_string(), "b".to_string(), "c".to_string()]));
8765 assert!(!f.matches(&["a".to_string()])); }
8767
8768 #[test]
8769 fn exclude_filter_rejects_any_tag() {
8770 let exclude = vec!["x".to_string()];
8771 let f = TagFilters::new(None, Some(&exclude));
8772 assert!(f.matches(&["a".to_string(), "b".to_string()]));
8773 assert!(!f.matches(&["a".to_string(), "x".to_string()]));
8774 }
8775
8776 #[test]
8777 fn include_and_exclude_combined() {
8778 let include = vec!["a".to_string()];
8779 let exclude = vec!["b".to_string()];
8780 let f = TagFilters::new(Some(&include), Some(&exclude));
8781 assert!(f.matches(&["a".to_string()]));
8782 assert!(!f.matches(&["a".to_string(), "b".to_string()])); assert!(!f.matches(&["c".to_string()])); }
8785
8786 #[test]
8787 fn case_insensitive_matching() {
8788 let include = vec!["Alpha".to_string()];
8789 let f = TagFilters::new(Some(&include), None);
8790 assert!(f.matches(&["alpha".to_string()]));
8791 assert!(f.matches(&["ALPHA".to_string()]));
8792 }
8793
8794 #[test]
8795 fn empty_include_array_passes_all() {
8796 let include: Vec<String> = vec![];
8797 let f = TagFilters::new(Some(&include), None);
8798 assert!(f.matches(&[]));
8799 assert!(f.matches(&["anything".to_string()]));
8800 }
8801
8802 #[test]
8803 fn tag_filters_debug() {
8804 let f = TagFilters::default();
8805 let debug = format!("{:?}", f);
8806 assert!(debug.contains("TagFilters"));
8807 }
8808}
8809
8810#[cfg(test)]
8811mod router_tests {
8812 use super::*;
8813 use crate::bidirectional::MrtrInputResponse;
8814 use crate::handler::{
8815 CompletionHandler, DEFAULT_FINAL_RESOURCE_TTL_MS, FinalElicitationContextExt,
8816 FinalToolSchemaAuthority, PromptHandler, ResourceHandler, ToolHandler,
8817 UpstreamFinalToolSchemaRegistration,
8818 };
8819 use crate::http_admission::{HttpAdmissionLimits, HttpEndpointConfig, admit_modern_post};
8820 #[cfg(feature = "tasks")]
8821 use crate::tasks::{
8822 ApplicationTaskSupervisor, FinalTaskSupervisorFuture, FinalTaskSupervisorHandoff,
8823 FinalTaskWorkDescriptor,
8824 };
8825 #[cfg(feature = "tasks")]
8826 use crate::{FinalTaskRuntimeConfig, FinalTaskStore, InMemoryFinalTaskStore};
8827 use asupersync::channel::oneshot;
8828 use asupersync::runtime::{RuntimeBuilder, RuntimeHandle};
8829 use asupersync::types::CancelKind;
8830 use fastmcp_core::{ClientCapabilityInfo, McpContext, McpResult, SessionState};
8831 use fastmcp_protocol::common_types::{
8832 Annotations, ContentBlock, EmbeddedResourceContents, OpenMetadata, RawIcon,
8833 };
8834 use fastmcp_protocol::{
8835 CompleteResult, CompletionValues, Content, CoreResultDiscriminatorPolicy, DecodedResult,
8836 FinalCallToolResult, FinalCompletionParams, FinalGetPromptResult, FinalPromptMessage,
8837 LegacyCompletionParams, LegacyResourceContent, Prompt, PromptArgument, PromptMessage,
8838 Resource, ResourceContent, ResourceTemplate, ResultPeerEra, Tool, decode_peer_result,
8839 };
8840 use std::collections::BTreeMap;
8841 use std::fmt;
8842 #[cfg(feature = "tasks")]
8843 use std::future::Future;
8844 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8845 use std::sync::{Arc, Mutex};
8846 use std::task::Poll;
8847
8848 fn request_context(
8849 cx: &Cx,
8850 request_id: u64,
8851 budget: Budget,
8852 state: &SessionState,
8853 ) -> McpContext {
8854 McpContext::with_state(cx.clone(), request_id, state.clone()).with_budget_ceiling(budget)
8855 }
8856
8857 async fn yield_once() {
8858 let mut yielded = false;
8859 std::future::poll_fn(|task_cx| {
8860 if std::mem::replace(&mut yielded, true) {
8861 Poll::Ready(())
8862 } else {
8863 task_cx.waker().wake_by_ref();
8864 Poll::Pending
8865 }
8866 })
8867 .await;
8868 }
8869
8870 #[test]
8871 fn legacy_result_adapters_emit_exact_2024_defaults() {
8872 let tool = CallToolResult {
8873 content: legacy_contents_from_handler(vec![
8874 Content::Text {
8875 text: "ready".to_owned(),
8876 },
8877 Content::Image {
8878 data: "aGVsbG8=".to_owned(),
8879 mime_type: "image/png".to_owned(),
8880 },
8881 Content::Resource {
8882 resource: ResourceContent {
8883 uri: "file:///tool.txt".to_owned(),
8884 mime_type: Some("text/plain".to_owned()),
8885 text: Some("tool resource".to_owned()),
8886 blob: None,
8887 },
8888 },
8889 ])
8890 .expect("handler content is representable by the exact legacy union"),
8891 is_error: false,
8892 meta: None,
8893 additional: BTreeMap::new(),
8894 };
8895 assert_eq!(
8896 serde_json::to_value(tool).expect("legacy tool result serializes"),
8897 serde_json::json!({
8898 "content": [
8899 {"type": "text", "text": "ready"},
8900 {"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"},
8901 {
8902 "type": "resource",
8903 "resource": {
8904 "uri": "file:///tool.txt",
8905 "mimeType": "text/plain",
8906 "text": "tool resource"
8907 }
8908 }
8909 ]
8910 })
8911 );
8912
8913 let resource = ReadResourceResult {
8914 contents: legacy_resource_contents_from_handler(vec![ResourceContent {
8915 uri: "file:///report.bin".to_owned(),
8916 mime_type: Some("application/octet-stream".to_owned()),
8917 text: None,
8918 blob: Some("AAEC".to_owned()),
8919 }])
8920 .expect("handler resource is representable by the exact legacy union"),
8921 meta: None,
8922 additional: BTreeMap::new(),
8923 };
8924 assert_eq!(
8925 serde_json::to_value(resource).expect("legacy resource result serializes"),
8926 serde_json::json!({
8927 "contents": [{
8928 "uri": "file:///report.bin",
8929 "mimeType": "application/octet-stream",
8930 "blob": "AAEC"
8931 }]
8932 })
8933 );
8934
8935 let prompt = GetPromptResult {
8936 description: Some("ask a question".to_owned()),
8937 messages: legacy_prompt_messages_from_handler(vec![PromptMessage {
8938 role: fastmcp_protocol::Role::User,
8939 content: Content::Text {
8940 text: "summarize".to_owned(),
8941 },
8942 }])
8943 .expect("handler prompt message is representable by the exact legacy union"),
8944 meta: None,
8945 additional: BTreeMap::new(),
8946 };
8947 assert_eq!(
8948 serde_json::to_value(prompt).expect("legacy prompt result serializes"),
8949 serde_json::json!({
8950 "description": "ask a question",
8951 "messages": [{
8952 "role": "user",
8953 "content": {"type": "text", "text": "summarize"}
8954 }]
8955 })
8956 );
8957 }
8958
8959 #[test]
8960 fn legacy_resource_adapter_preserves_open_members_when_promoted() {
8961 let resource = LegacyResourceContent::Text {
8962 uri: "file:///open.txt".to_owned(),
8963 text: "preserve me".to_owned(),
8964 mime_type: Some("text/plain".to_owned()),
8965 additional: BTreeMap::from([
8966 (
8967 "_meta".to_owned(),
8968 serde_json::json!({"legacy": "uninterpreted"}),
8969 ),
8970 (
8971 "com.example/legacy".to_owned(),
8972 serde_json::json!({"retained": true}),
8973 ),
8974 ]),
8975 };
8976
8977 let promoted = promote_legacy_resource_content(resource)
8978 .expect("schema-valid legacy resource is promotable");
8979 assert_eq!(
8980 serde_json::to_value(promoted).expect("promoted resource serializes"),
8981 serde_json::json!({
8982 "uri": "file:///open.txt",
8983 "mimeType": "text/plain",
8984 "text": "preserve me",
8985 "_meta": {"legacy": "uninterpreted"},
8986 "com.example/legacy": {"retained": true}
8987 })
8988 );
8989 }
8990
8991 #[test]
8992 fn legacy_content_adapter_rejects_only_audio_without_mutating_baseline() {
8993 let baseline = vec![Content::Image {
8994 data: "aGVsbG8=".to_owned(),
8995 mime_type: "image/png".to_owned(),
8996 }];
8997 let baseline_wire = serde_json::to_value(
8998 legacy_contents_from_handler(baseline.clone())
8999 .expect("baseline content is representable by the legacy adapter"),
9000 )
9001 .expect("baseline legacy content serializes");
9002 let planted = vec![Content::Audio {
9003 data: "aGVsbG8=".to_owned(),
9004 mime_type: "image/png".to_owned(),
9005 }];
9006
9007 assert!(
9008 legacy_contents_from_handler(planted).is_err(),
9009 "changing only the content discriminator to audio rejects the legacy adapter"
9010 );
9011 assert_eq!(
9012 serde_json::to_value(
9013 legacy_contents_from_handler(baseline)
9014 .expect("baseline remains representable after rejection"),
9015 )
9016 .expect("baseline legacy content remains serializable"),
9017 baseline_wire,
9018 "rejected legacy conversion cannot mutate the accepted baseline"
9019 );
9020 }
9021
9022 fn spawn_owned_modern_request(
9023 runtime: &RuntimeHandle,
9024 router: Arc<Router>,
9025 request_context_id: u64,
9026 wire_id: &'static str,
9027 label: &'static str,
9028 control_sender: Option<oneshot::Sender<Cx>>,
9029 ) -> oneshot::Receiver<McpResult<serde_json::Value>> {
9030 let (response_sender, response_receiver) = oneshot::channel();
9031 runtime
9032 .try_spawn_with_cx(move |request_cx| {
9033 if let Some(control_sender) = control_sender {
9034 control_sender
9035 .send_blocking(request_cx.clone())
9036 .expect("the cancellation controller remains available");
9037 }
9038 let request_ctx =
9041 McpContext::with_state(request_cx, request_context_id, SessionState::new());
9042 let request = JsonRpcRequest::new(
9043 "tools/call",
9044 Some(serde_json::json!({
9045 "_meta": {
9046 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9047 "io.modelcontextprotocol/clientCapabilities": {},
9048 },
9049 "name": "concurrent-modern-tool",
9050 "arguments": {"request": label},
9051 })),
9052 wire_id,
9053 );
9054 async move {
9055 let result = router.dispatch_stateless_owned(request_ctx, request).await;
9056 response_sender
9057 .send_blocking(result)
9058 .expect("the modern dispatch observer remains available");
9059 }
9060 })
9061 .expect("the runtime admits the request owner");
9062 response_receiver
9063 }
9064
9065 struct NamedTool {
9068 name: String,
9069 tags: Vec<String>,
9070 }
9071
9072 impl NamedTool {
9073 fn new(name: &str) -> Self {
9074 Self {
9075 name: name.to_string(),
9076 tags: vec![],
9077 }
9078 }
9079 fn with_tags(name: &str, tags: Vec<String>) -> Self {
9080 Self {
9081 name: name.to_string(),
9082 tags,
9083 }
9084 }
9085 }
9086
9087 impl ToolHandler for NamedTool {
9088 fn definition(&self) -> Tool {
9089 Tool {
9090 name: self.name.clone(),
9091 description: Some(format!("Tool {}", self.name)),
9092 input_schema: serde_json::json!({"type": "object"}),
9093 output_schema: None,
9094 icon: None,
9095 version: None,
9096 tags: self.tags.clone(),
9097 annotations: None,
9098 }
9099 }
9100 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9101 Ok(vec![Content::text(format!("called {}", self.name))])
9102 }
9103 }
9104
9105 struct RouterProgressTool;
9106
9107 impl ToolHandler for RouterProgressTool {
9108 fn definition(&self) -> Tool {
9109 Tool {
9110 name: "router-progress-tool".to_owned(),
9111 description: None,
9112 input_schema: serde_json::json!({
9113 "type": "object",
9114 "required": ["total"],
9115 "properties": {"total": {"type": "number"}},
9116 "additionalProperties": false,
9117 }),
9118 output_schema: None,
9119 icon: None,
9120 version: None,
9121 tags: Vec::new(),
9122 annotations: None,
9123 }
9124 }
9125
9126 fn call(&self, ctx: &McpContext, args: serde_json::Value) -> McpResult<Vec<Content>> {
9127 let total = args
9128 .get("total")
9129 .and_then(serde_json::Value::as_f64)
9130 .ok_or_else(|| McpError::invalid_params("router progress total is required"))?;
9131 ctx.report_progress_with_total(12_000.0, total, Some("router-final"));
9132 Ok(vec![Content::text("progress emitted")])
9133 }
9134 }
9135
9136 struct DuplicateInvariantTool {
9137 label: &'static str,
9138 schema_property: &'static str,
9139 legacy_calls: Arc<AtomicUsize>,
9140 final_calls: Arc<AtomicUsize>,
9141 }
9142
9143 impl ToolHandler for DuplicateInvariantTool {
9144 fn definition(&self) -> Tool {
9145 Tool {
9146 name: "duplicate-invariant-tool".to_owned(),
9147 description: Some(self.label.to_owned()),
9148 input_schema: serde_json::json!({
9149 "type": "object",
9150 "properties": {
9151 (self.schema_property): {"type": "boolean"}
9152 },
9153 "additionalProperties": false
9154 }),
9155 output_schema: None,
9156 icon: None,
9157 version: None,
9158 tags: vec![self.label.to_owned()],
9159 annotations: None,
9160 }
9161 }
9162
9163 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9164 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9165 Ok(vec![Content::text(self.label)])
9166 }
9167
9168 fn call_final_outcome(
9169 &self,
9170 _ctx: &McpContext,
9171 _args: serde_json::Value,
9172 ) -> McpResult<FinalToolOutcome> {
9173 self.final_calls.fetch_add(1, Ordering::SeqCst);
9174 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9175 FinalCallToolResult {
9176 content: vec![ContentBlock::text(self.label)],
9177 is_error: false,
9178 structured_content: None,
9179 },
9180 )))
9181 }
9182 }
9183
9184 struct EraBoundaryResource {
9185 legacy_calls: Arc<AtomicUsize>,
9186 final_calls: Arc<AtomicUsize>,
9187 }
9188
9189 impl ResourceHandler for EraBoundaryResource {
9190 fn definition(&self) -> Resource {
9191 Resource {
9192 uri: "mcp://era-boundary".to_owned(),
9193 name: "era-boundary".to_owned(),
9194 description: None,
9195 mime_type: Some("text/plain".to_owned()),
9196 icon: None,
9197 version: None,
9198 tags: Vec::new(),
9199 }
9200 }
9201
9202 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
9203 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9204 Ok(vec![ResourceContent {
9205 uri: "mcp://era-boundary".to_owned(),
9206 mime_type: Some("text/plain".to_owned()),
9207 text: Some("legacy resource result".to_owned()),
9208 blob: None,
9209 }])
9210 }
9211
9212 fn read_final(
9213 &self,
9214 _ctx: &McpContext,
9215 ) -> McpResult<CompleteResult<FinalReadResourceResult>> {
9216 self.final_calls.fetch_add(1, Ordering::SeqCst);
9217 Ok(CompleteResult::new(
9218 FinalReadResourceResult {
9219 contents: vec![EmbeddedResourceContents::Text {
9220 uri: AbsoluteUri::parse("mcp://era-boundary")
9221 .expect("era-boundary resource URI is valid"),
9222 text: "final resource result".to_owned(),
9223 mime_type: Some("text/plain".to_owned()),
9224 meta: None,
9225 additional: BTreeMap::new(),
9226 }],
9227 ttl_ms: CacheTtl::milliseconds(DEFAULT_FINAL_RESOURCE_TTL_MS),
9228 cache_scope: CacheScope::Private,
9229 },
9230 empty_final_result_meta()?,
9231 ))
9232 }
9233 }
9234
9235 struct InvalidFinalSchemaNamedTool {
9238 name: String,
9239 tags: Vec<String>,
9240 }
9241
9242 impl InvalidFinalSchemaNamedTool {
9243 fn with_tags(name: &str, tags: Vec<String>) -> Self {
9244 Self {
9245 name: name.to_owned(),
9246 tags,
9247 }
9248 }
9249 }
9250
9251 impl ToolHandler for InvalidFinalSchemaNamedTool {
9252 fn definition(&self) -> Tool {
9253 Tool {
9254 name: self.name.clone(),
9255 description: Some(format!("Invalid-schema tool {}", self.name)),
9256 input_schema: serde_json::json!({"type": "object"}),
9257 output_schema: Some(serde_json::json!(false)),
9261 icon: None,
9262 version: None,
9263 tags: self.tags.clone(),
9264 annotations: None,
9265 }
9266 }
9267
9268 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9269 Ok(vec![Content::text(format!("called {}", self.name))])
9270 }
9271 }
9272
9273 fn final_tools_list_request(
9274 cursor: Option<&str>,
9275 include_tags: Option<Vec<&str>>,
9276 exclude_tags: Option<Vec<&str>>,
9277 id: i64,
9278 ) -> JsonRpcRequest {
9279 let mut params = serde_json::Map::new();
9280 params.insert(
9281 "_meta".to_owned(),
9282 serde_json::json!({
9283 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9284 "io.modelcontextprotocol/clientCapabilities": {},
9285 }),
9286 );
9287 if let Some(cursor) = cursor {
9288 params.insert("cursor".to_owned(), serde_json::json!(cursor));
9289 }
9290 if let Some(include_tags) = include_tags {
9291 params.insert("includeTags".to_owned(), serde_json::json!(include_tags));
9292 }
9293 if let Some(exclude_tags) = exclude_tags {
9294 params.insert("excludeTags".to_owned(), serde_json::json!(exclude_tags));
9295 }
9296 JsonRpcRequest::new("tools/list", Some(serde_json::Value::Object(params)), id)
9297 }
9298
9299 fn mounted_tool_router() -> Router {
9300 let mut source = Router::new();
9301 source
9302 .add_tool(NamedTool::with_tags("first", vec!["visible".to_owned()]))
9303 .expect("tool registration succeeds");
9304 source
9305 .add_tool(NamedTool::with_tags("second", vec!["visible".to_owned()]))
9306 .expect("tool registration succeeds");
9307 source
9308 .add_tool(NamedTool::with_tags(
9309 "excluded",
9310 vec!["visible".to_owned(), "excluded".to_owned()],
9311 ))
9312 .expect("tool registration succeeds");
9313 source
9314 .add_tool(NamedTool::with_tags("other", vec!["other".to_owned()]))
9315 .expect("tool registration succeeds");
9316
9317 let mut router = Router::new();
9318 let mounted = router.mount_tools(source, Some("peer"));
9319 assert_eq!(mounted.tools, 4);
9320 router
9321 }
9322
9323 static MACRO_DUAL_ERA_TOOL_CALLS: AtomicUsize = AtomicUsize::new(0);
9324 static MACRO_DUAL_ERA_TOOL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
9327
9328 fn final_tool_complete_result(
9329 payload: FinalCallToolResult,
9330 ) -> CompleteResult<FinalCallToolResult> {
9331 let params = serde_json::json!({
9332 "_meta": {
9333 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9334 "io.modelcontextprotocol/clientCapabilities": {},
9335 },
9336 "name": "macro_dual_era_tool",
9337 });
9338 let request = CoreRequest::decode(ProtocolEra::Modern2026, "tools/call", Some(¶ms))
9339 .expect("test final tools/call request");
9340 let mut wire = serde_json::to_value(payload).expect("final tool payload serializes");
9341 wire.as_object_mut()
9342 .expect("final tool payload is an object")
9343 .insert("resultType".to_owned(), serde_json::json!("complete"));
9344 let encoded = serde_json::to_string(&wire).expect("final tool wire serializes");
9345 let CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) = request
9346 .decode_result(&encoded)
9347 .expect("typed final tools/call result")
9348 else {
9349 panic!("typed final tools/call result is selected");
9350 };
9351 result
9352 }
9353
9354 #[fastmcp_derive::tool]
9355 fn macro_dual_era_tool() -> CompleteResult<FinalCallToolResult> {
9356 MACRO_DUAL_ERA_TOOL_CALLS.fetch_add(1, Ordering::SeqCst);
9357 final_tool_complete_result(FinalCallToolResult {
9358 content: vec![ContentBlock::text("macro final tool result")],
9359 is_error: false,
9360 structured_content: Some(serde_json::json!({"weather":"clear"})),
9361 })
9362 }
9363
9364 struct SchemaBoundaryTool {
9365 final_calls: Arc<AtomicUsize>,
9366 legacy_calls: Arc<AtomicUsize>,
9367 output_matches_schema: bool,
9368 output_is_error: bool,
9369 output_has_unevaluated_property: bool,
9370 invalid_final_input_schema: bool,
9371 missing_final_input_object_type: bool,
9372 invalid_final_output_schema: bool,
9373 }
9374
9375 impl ToolHandler for SchemaBoundaryTool {
9376 fn definition(&self) -> Tool {
9377 Tool {
9378 name: "schema-boundary-tool".to_owned(),
9379 description: None,
9380 input_schema: if self.invalid_final_input_schema {
9381 serde_json::json!(42)
9382 } else if self.missing_final_input_object_type {
9383 serde_json::json!({"properties": {"value": {"type": "string"}}})
9384 } else {
9385 serde_json::json!({
9386 "$schema": "https://json-schema.org/draft/2020-12/schema",
9387 "type": "object",
9388 "required": ["value"],
9389 "properties": {"value": {"type": "string"}},
9390 "unevaluatedProperties": false,
9391 })
9392 },
9393 output_schema: Some(if self.invalid_final_output_schema {
9394 serde_json::json!(42)
9395 } else {
9396 serde_json::json!({
9397 "$schema": "https://json-schema.org/draft/2020-12/schema",
9398 "type": "object",
9399 "properties": {
9400 "accepted": {"type": "boolean"},
9401 "error": {
9402 "enum": ["input-validation", "handler"]
9403 }
9404 },
9405 "anyOf": [
9406 {"required": ["accepted"]},
9407 {"required": ["error"]}
9408 ],
9409 "unevaluatedProperties": false,
9410 })
9411 }),
9412 icon: None,
9413 version: None,
9414 tags: Vec::new(),
9415 annotations: None,
9416 }
9417 }
9418
9419 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9420 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9421 Ok(vec![Content::text("legacy schema-boundary result")])
9422 }
9423
9424 fn final_tool_error_structured_content(
9425 &self,
9426 kind: ToolErrorKind,
9427 ) -> Option<serde_json::Value> {
9428 Some(match kind {
9429 ToolErrorKind::InputValidation => {
9430 serde_json::json!({"error": "input-validation"})
9431 }
9432 ToolErrorKind::Handler => serde_json::json!({"error": "handler"}),
9433 })
9434 }
9435
9436 fn call_final_outcome(
9437 &self,
9438 _ctx: &McpContext,
9439 _args: serde_json::Value,
9440 ) -> McpResult<FinalToolOutcome> {
9441 self.final_calls.fetch_add(1, Ordering::SeqCst);
9442 let structured_content = if !self.output_matches_schema {
9443 serde_json::json!({"accepted": "not-a-boolean"})
9444 } else if self.output_has_unevaluated_property {
9445 serde_json::json!({"accepted": true, "unexpected": true})
9446 } else {
9447 serde_json::json!({"accepted": true})
9448 };
9449 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9450 FinalCallToolResult {
9451 content: vec![ContentBlock::text("final schema-boundary result")],
9452 is_error: self.output_is_error,
9453 structured_content: Some(structured_content),
9454 },
9455 )))
9456 }
9457 }
9458
9459 struct UpstreamScalarSchemaTool {
9460 registered_proxy: bool,
9461 }
9462
9463 impl ToolHandler for UpstreamScalarSchemaTool {
9464 fn definition(&self) -> Tool {
9465 Tool {
9466 name: "upstream-scalar-schema-tool".to_owned(),
9467 description: None,
9468 input_schema: serde_json::json!({"type": "object"}),
9469 output_schema: Some(serde_json::json!(false)),
9470 icon: None,
9471 version: None,
9472 tags: Vec::new(),
9473 annotations: None,
9474 }
9475 }
9476
9477 fn final_tool_schema_authority(&self) -> FinalToolSchemaAuthority {
9478 FinalToolSchemaAuthority::Upstream
9480 }
9481
9482 fn upstream_final_tool_schema_registration(
9483 &self,
9484 ) -> Option<UpstreamFinalToolSchemaRegistration> {
9485 self.registered_proxy
9486 .then(UpstreamFinalToolSchemaRegistration::exact_proxy)
9487 }
9488
9489 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9490 Ok(vec![Content::text("legacy")])
9491 }
9492
9493 fn call_final_outcome(
9494 &self,
9495 _ctx: &McpContext,
9496 _args: serde_json::Value,
9497 ) -> McpResult<FinalToolOutcome> {
9498 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9499 FinalCallToolResult {
9500 content: vec![ContentBlock::text("upstream")],
9501 is_error: false,
9502 structured_content: Some(serde_json::json!({"upstream": true})),
9503 },
9504 )))
9505 }
9506 }
9507
9508 struct AdmittedSchemaReplacementTool {
9512 legacy_calls: Arc<AtomicUsize>,
9513 final_calls: Arc<AtomicUsize>,
9514 legacy_label: &'static str,
9515 output_schema: serde_json::Value,
9516 structured_content: Option<serde_json::Value>,
9517 }
9518
9519 impl ToolHandler for AdmittedSchemaReplacementTool {
9520 fn definition(&self) -> Tool {
9521 Tool {
9522 name: "admitted-schema-replacement-tool".to_owned(),
9523 description: Some(self.legacy_label.to_owned()),
9524 input_schema: serde_json::json!({
9525 "$schema": "https://json-schema.org/draft/2020-12/schema",
9526 "type": "object",
9527 "unevaluatedProperties": false,
9528 }),
9529 output_schema: Some(self.output_schema.clone()),
9530 icon: None,
9531 version: None,
9532 tags: Vec::new(),
9533 annotations: None,
9534 }
9535 }
9536
9537 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9538 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9539 Ok(vec![Content::text(self.legacy_label)])
9540 }
9541
9542 fn final_tool_error_structured_content(
9543 &self,
9544 kind: ToolErrorKind,
9545 ) -> Option<serde_json::Value> {
9546 match self
9547 .output_schema
9548 .get("type")
9549 .and_then(serde_json::Value::as_str)
9550 {
9551 Some("string") => Some(serde_json::json!(match kind {
9552 ToolErrorKind::InputValidation => "input-validation-error",
9553 ToolErrorKind::Handler => "handler-error",
9554 })),
9555 Some("boolean") => Some(serde_json::json!(matches!(kind, ToolErrorKind::Handler))),
9556 Some("null") => Some(serde_json::Value::Null),
9557 Some("object") => Some(serde_json::json!({
9558 "error": match kind {
9559 ToolErrorKind::InputValidation => "input-validation",
9560 ToolErrorKind::Handler => "handler",
9561 }
9562 })),
9563 _ => None,
9564 }
9565 }
9566
9567 fn call_final_outcome(
9568 &self,
9569 _ctx: &McpContext,
9570 _args: serde_json::Value,
9571 ) -> McpResult<FinalToolOutcome> {
9572 self.final_calls.fetch_add(1, Ordering::SeqCst);
9573 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9574 FinalCallToolResult {
9575 content: vec![ContentBlock::text(self.legacy_label)],
9576 is_error: false,
9577 structured_content: self.structured_content.clone(),
9578 },
9579 )))
9580 }
9581 }
9582
9583 #[derive(Clone, Copy)]
9584 enum ErrorMapperMode {
9585 Complete,
9586 MissingHandler,
9587 InvalidHandler,
9588 OversizedHandler,
9589 }
9590
9591 struct ErrorMappedTool {
9592 mode: ErrorMapperMode,
9593 calls: Arc<AtomicUsize>,
9594 }
9595
9596 impl ToolHandler for ErrorMappedTool {
9597 fn definition(&self) -> Tool {
9598 Tool {
9599 name: "error-mapped-tool".to_owned(),
9600 description: None,
9601 input_schema: serde_json::json!({
9602 "type": "object",
9603 "required": ["value"],
9604 "properties": {"value": {"type": "string"}},
9605 "additionalProperties": false
9606 }),
9607 output_schema: Some(serde_json::json!({
9608 "type": "object",
9609 "required": ["error"],
9610 "properties": {"error": {"type": "string"}},
9611 "additionalProperties": false
9612 })),
9613 icon: None,
9614 version: None,
9615 tags: Vec::new(),
9616 annotations: None,
9617 }
9618 }
9619
9620 fn final_tool_error_structured_content(
9621 &self,
9622 kind: ToolErrorKind,
9623 ) -> Option<serde_json::Value> {
9624 match (self.mode, kind) {
9625 (ErrorMapperMode::MissingHandler, ToolErrorKind::Handler) => None,
9626 (ErrorMapperMode::InvalidHandler, ToolErrorKind::Handler) => {
9627 Some(serde_json::json!({"error": 7}))
9628 }
9629 (ErrorMapperMode::OversizedHandler, ToolErrorKind::Handler) => Some(
9630 serde_json::json!({"error": "x".repeat(MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES)}),
9631 ),
9632 (_, ToolErrorKind::InputValidation) => {
9633 Some(serde_json::json!({"error": "input-validation"}))
9634 }
9635 (_, ToolErrorKind::Handler) => Some(serde_json::json!({"error": "handler"})),
9636 }
9637 }
9638
9639 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9640 self.calls.fetch_add(1, Ordering::SeqCst);
9641 Err(McpError::new(
9642 McpErrorCode::ToolExecutionError,
9643 "mapped handler failure",
9644 ))
9645 }
9646 }
9647
9648 fn final_tools_call_request(
9649 name: &str,
9650 arguments: serde_json::Value,
9651 id: i64,
9652 ) -> JsonRpcRequest {
9653 JsonRpcRequest::new(
9654 "tools/call",
9655 Some(serde_json::json!({
9656 "_meta": {
9657 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9658 "io.modelcontextprotocol/clientCapabilities": {},
9659 },
9660 "name": name,
9661 "arguments": arguments,
9662 })),
9663 id,
9664 )
9665 }
9666
9667 fn admit_http_wire(
9668 method: &str,
9669 target: &str,
9670 body: &[u8],
9671 ) -> (JsonRpcRequest, Option<Arc<str>>) {
9672 let endpoint = HttpEndpointConfig::new(
9673 "/mcp",
9674 HttpAdmissionLimits::new(16, 8_192, 65_536).expect("nonzero HTTP limits"),
9675 )
9676 .expect("HTTP endpoint config");
9677 let headers = vec![
9678 ("Content-Type".to_owned(), "application/json".to_owned()),
9679 ("Accept".to_owned(), "application/json".to_owned()),
9680 ("MCP-Protocol-Version".to_owned(), "2026-07-28".to_owned()),
9681 ("Mcp-Method".to_owned(), method.to_owned()),
9682 ("Mcp-Name".to_owned(), target.to_owned()),
9683 ];
9684 admit_modern_post(&endpoint, "POST", "/mcp", &headers, body)
9685 .expect("wire request admits through HTTP")
9686 .into_request_and_raw_params()
9687 }
9688
9689 #[test]
9690 fn final_router_progress_admits_one_smaller_total_without_replacing_an_outer_runtime() {
9691 let mut router = Router::new();
9692 router
9693 .add_tool(RouterProgressTool)
9694 .expect("router progress tool registers for both eras");
9695 let cx = Cx::for_testing();
9696 let state = SessionState::new();
9697 let request_ctx = request_context(&cx, 177, Budget::INFINITE, &state);
9698 let sent = Arc::new(Mutex::new(Vec::new()));
9699 let sent_clone = Arc::clone(&sent);
9700 let notification_sender: NotificationSender = Arc::new(move |notification| {
9701 sent_clone
9702 .lock()
9703 .expect("notification collection is not poisoned")
9704 .push(notification);
9705 });
9706 let baseline = serde_json::json!({
9707 "_meta": {
9708 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9709 "io.modelcontextprotocol/clientCapabilities": {},
9710 "progressToken": "router-final-progress",
9711 },
9712 "name": "router-progress-tool",
9713 "arguments": {"total": 12000.0},
9714 });
9715 let mut planted = baseline.clone();
9716 planted["arguments"]["total"] = serde_json::json!(11999.0);
9717 assert_eq!(
9718 baseline["_meta"], planted["_meta"],
9719 "the progress total is the sole planted request dimension"
9720 );
9721 assert_eq!(baseline["name"], planted["name"]);
9722
9723 let baseline: FinalCallToolParams =
9724 serde_json::from_value(baseline).expect("baseline final request is valid");
9725 let outcome = block_on(router.handle_tools_call_final_in_request(
9726 &request_ctx,
9727 request_ctx.cx(),
9728 baseline,
9729 state.clone(),
9730 Some(¬ification_sender),
9731 None,
9732 None,
9733 ))
9734 .expect("ordinary final router dispatch completes");
9735 assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
9736 let notification = sent
9737 .lock()
9738 .expect("notification collection is not poisoned")[0]
9739 .clone();
9740 let wire = serde_json::to_string(
9741 notification
9742 .params
9743 .as_ref()
9744 .expect("ordinary final progress has parameters"),
9745 )
9746 .expect("ordinary final progress parameters serialize");
9747 assert!(wire.contains("\"progress\":12000"));
9748 assert!(wire.contains("\"total\":12000"));
9749
9750 let planted: FinalCallToolParams =
9751 serde_json::from_value(planted).expect("one-variable planted request is valid");
9752 let outcome = block_on(router.handle_tools_call_final_in_request(
9753 &request_ctx,
9754 request_ctx.cx(),
9755 planted,
9756 state,
9757 Some(¬ification_sender),
9758 None,
9759 None,
9760 ))
9761 .expect("the handler result remains valid when only total is smaller");
9762 assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
9763 assert_eq!(
9764 sent.lock()
9765 .expect("notification collection is not poisoned")
9766 .len(),
9767 2,
9768 "a smaller total is not a final-progress violation"
9769 );
9770 let wire = serde_json::to_string(
9771 sent.lock()
9772 .expect("notification collection is not poisoned")[1]
9773 .params
9774 .as_ref()
9775 .expect("second final progress has parameters"),
9776 )
9777 .expect("second final progress parameters serialize");
9778 assert!(wire.contains("\"progress\":12000"));
9779 assert!(wire.contains("\"total\":11999"));
9780 }
9781
9782 #[test]
9783 fn final_router_preserves_an_outer_final_progress_runtime() {
9784 let mut router = Router::new();
9785 router
9786 .add_tool(RouterProgressTool)
9787 .expect("router progress tool registers for both eras");
9788 let cx = Cx::for_testing();
9789 let state = SessionState::new();
9790 let sent = Arc::new(Mutex::new(Vec::new()));
9791 let sent_clone = Arc::clone(&sent);
9792 let outer_runtime = Arc::new(crate::handler::FinalProgressRuntime::new(
9793 ProgressMarker::from("outer-owned-marker"),
9794 move |notification| {
9795 sent_clone
9796 .lock()
9797 .expect("notification collection is not poisoned")
9798 .push(notification);
9799 },
9800 ));
9801 let request_ctx =
9802 McpContext::with_progress(cx, 178, Arc::clone(&outer_runtime).into_reporter());
9803 let params: FinalCallToolParams = serde_json::from_value(serde_json::json!({
9804 "_meta": {
9805 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9806 "io.modelcontextprotocol/clientCapabilities": {},
9807 "progressToken": "handler-supplied-marker",
9808 },
9809 "name": "router-progress-tool",
9810 "arguments": {"total": 11999.0},
9811 }))
9812 .expect("final tool parameters are valid");
9813 let notification_sender: NotificationSender =
9814 Arc::new(|_| panic!("router must not replace an installed final-progress runtime"));
9815
9816 let outcome = block_on(router.handle_tools_call_final_in_request(
9817 &request_ctx,
9818 request_ctx.cx(),
9819 params,
9820 state,
9821 Some(¬ification_sender),
9822 None,
9823 None,
9824 ))
9825 .expect("outer final progress runtime remains usable");
9826 assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
9827 assert!(outer_runtime.flush_pending());
9828
9829 let sent = sent
9830 .lock()
9831 .expect("notification collection is not poisoned");
9832 assert_eq!(sent.len(), 1);
9833 let wire = serde_json::to_string(
9834 sent[0]
9835 .params
9836 .as_ref()
9837 .expect("outer runtime notification has parameters"),
9838 )
9839 .expect("outer runtime parameters serialize");
9840 assert!(wire.contains("\"progressToken\":\"outer-owned-marker\""));
9841 assert!(wire.contains("\"progress\":12000"));
9842 assert!(wire.contains("\"total\":11999"));
9843 }
9844
9845 #[cfg(feature = "tasks")]
9846 struct TaskCapableRouterTool {
9847 final_calls: Arc<AtomicUsize>,
9848 }
9849
9850 #[cfg(feature = "tasks")]
9851 impl ToolHandler for TaskCapableRouterTool {
9852 fn definition(&self) -> Tool {
9853 Tool {
9854 name: "task-capable-router-tool".to_owned(),
9855 description: None,
9856 input_schema: serde_json::json!({
9857 "$schema": "https://json-schema.org/draft/2020-12/schema",
9858 "type": "object",
9859 "unevaluatedProperties": false,
9860 }),
9861 output_schema: None,
9862 icon: None,
9863 version: None,
9864 tags: Vec::new(),
9865 annotations: None,
9866 }
9867 }
9868
9869 fn declares_final_tasks(&self) -> bool {
9870 true
9871 }
9872
9873 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9874 Ok(vec![Content::text("legacy task-capable router result")])
9875 }
9876
9877 fn call_final_outcome(
9878 &self,
9879 _ctx: &McpContext,
9880 _args: serde_json::Value,
9881 ) -> McpResult<FinalToolOutcome> {
9882 self.final_calls.fetch_add(1, Ordering::SeqCst);
9883 Ok(FinalToolOutcome::CreateTask {
9884 work_descriptor: FinalTaskWorkDescriptor::new(serde_json::json!({
9885 "operation": "task-capable-router-tool"
9886 }))?,
9887 status_message: Some("router task created".to_owned()),
9888 })
9889 }
9890 }
9891
9892 #[cfg(feature = "tasks")]
9896 struct ConditionalTaskCapableRouterTool {
9897 final_calls: Arc<AtomicUsize>,
9898 }
9899
9900 #[cfg(feature = "tasks")]
9901 impl ToolHandler for ConditionalTaskCapableRouterTool {
9902 fn definition(&self) -> Tool {
9903 Tool {
9904 name: "conditional-task-capable-router-tool".to_owned(),
9905 description: None,
9906 input_schema: serde_json::json!({
9907 "$schema": "https://json-schema.org/draft/2020-12/schema",
9908 "type": "object",
9909 "properties": {"createTask": {"type": "boolean"}},
9910 "required": ["createTask"],
9911 "unevaluatedProperties": false,
9912 }),
9913 output_schema: None,
9914 icon: None,
9915 version: None,
9916 tags: Vec::new(),
9917 annotations: None,
9918 }
9919 }
9920
9921 fn declares_final_tasks(&self) -> bool {
9922 true
9923 }
9924
9925 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9926 Ok(vec![Content::text(
9927 "legacy conditional task-capable router result",
9928 )])
9929 }
9930
9931 fn call_final_outcome(
9932 &self,
9933 _ctx: &McpContext,
9934 args: serde_json::Value,
9935 ) -> McpResult<FinalToolOutcome> {
9936 self.final_calls.fetch_add(1, Ordering::SeqCst);
9937 if args
9938 .get("createTask")
9939 .and_then(serde_json::Value::as_bool)
9940 .unwrap_or(false)
9941 {
9942 return Ok(FinalToolOutcome::CreateTask {
9943 work_descriptor: FinalTaskWorkDescriptor::new(serde_json::json!({
9944 "operation": "conditional-task-capable-router-tool"
9945 }))?,
9946 status_message: None,
9947 });
9948 }
9949 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9950 FinalCallToolResult {
9951 content: vec![ContentBlock::text("ordinary final result")],
9952 is_error: false,
9953 structured_content: None,
9954 },
9955 )))
9956 }
9957 }
9958
9959 #[cfg(feature = "tasks")]
9963 struct UndeclaredTaskOutcomeRouterTool {
9964 final_calls: Arc<AtomicUsize>,
9965 }
9966
9967 #[cfg(feature = "tasks")]
9968 impl ToolHandler for UndeclaredTaskOutcomeRouterTool {
9969 fn definition(&self) -> Tool {
9970 Tool {
9971 name: "undeclared-task-outcome-router-tool".to_owned(),
9972 description: None,
9973 input_schema: serde_json::json!({
9974 "$schema": "https://json-schema.org/draft/2020-12/schema",
9975 "type": "object",
9976 "unevaluatedProperties": false,
9977 }),
9978 output_schema: None,
9979 icon: None,
9980 version: None,
9981 tags: Vec::new(),
9982 annotations: None,
9983 }
9984 }
9985
9986 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9987 Ok(vec![Content::text("legacy undeclared-task outcome")])
9988 }
9989
9990 fn call_final_outcome_async_in_request<'a>(
9991 &'a self,
9992 _ctx: &'a McpContext,
9993 _request_cx: &'a Cx,
9994 _args: serde_json::Value,
9995 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
9996 self.final_calls.fetch_add(1, Ordering::SeqCst);
9997 let work_descriptor = FinalTaskWorkDescriptor::new(serde_json::json!({
9998 "operation": "undeclared-task-outcome-router-tool"
9999 }));
10000 Box::pin(async move {
10001 match work_descriptor {
10002 Ok(work_descriptor) => Outcome::Ok(FinalToolOutcome::CreateTask {
10003 work_descriptor,
10004 status_message: None,
10005 }),
10006 Err(error) => Outcome::Err(error),
10007 }
10008 })
10009 }
10010 }
10011
10012 #[cfg(feature = "tasks")]
10013 struct NoopFinalTaskSupervisor;
10014
10015 #[cfg(feature = "tasks")]
10016 impl ApplicationTaskSupervisor for NoopFinalTaskSupervisor {
10017 fn resume<'a>(
10018 &'a self,
10019 _cx: &'a Cx,
10020 _handoff: FinalTaskSupervisorHandoff,
10021 ) -> FinalTaskSupervisorFuture<'a> {
10022 Box::pin(async { Ok(()) })
10023 }
10024 }
10025
10026 #[cfg(feature = "tasks")]
10027 fn task_runtime_for_router(store: Arc<InMemoryFinalTaskStore>) -> FinalTaskRuntime {
10028 let store: Arc<dyn FinalTaskStore> = store;
10029 FinalTaskRuntime::new(
10030 store,
10031 FinalTaskRuntimeConfig::new(60_000, Some(5_000))
10032 .expect("a finite final Task policy is valid"),
10033 Arc::new(|_notification| {}),
10034 )
10035 }
10036
10037 #[cfg(feature = "tasks")]
10038 fn final_task_capable_tool_request(id: i64) -> JsonRpcRequest {
10039 JsonRpcRequest::new(
10040 "tools/call",
10041 Some(serde_json::json!({
10042 "_meta": {
10043 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
10044 "io.modelcontextprotocol/clientCapabilities": {
10045 "extensions": {
10046 "io.modelcontextprotocol/tasks": {}
10047 }
10048 },
10049 },
10050 "name": "task-capable-router-tool",
10051 "arguments": {},
10052 })),
10053 id,
10054 )
10055 }
10056
10057 struct FinalCatalogTool {
10058 metadata: OpenMetadata,
10059 icons: Vec<RawIcon>,
10060 }
10061
10062 impl ToolHandler for FinalCatalogTool {
10063 fn definition(&self) -> Tool {
10064 Tool {
10065 name: "final-catalog-tool".to_owned(),
10066 description: Some("final catalog description".to_owned()),
10067 input_schema: serde_json::json!({"type": "object"}),
10068 output_schema: Some(serde_json::json!({"type": "object"})),
10069 icon: Some(fastmcp_protocol::Icon::new("https://legacy.test/icon.png")),
10070 version: Some("legacy-version".to_owned()),
10071 tags: vec!["legacy-tag".to_owned()],
10072 annotations: None,
10073 }
10074 }
10075
10076 fn final_title(&self) -> Option<&str> {
10077 Some("Final Catalog Tool")
10078 }
10079
10080 fn final_icons(&self) -> Option<&[RawIcon]> {
10081 Some(&self.icons)
10082 }
10083
10084 fn final_metadata(&self) -> Option<&OpenMetadata> {
10085 Some(&self.metadata)
10086 }
10087
10088 fn final_definition(&self) -> Option<FinalTool> {
10089 Some(FinalTool {
10090 name: "final-catalog-tool".to_owned(),
10091 title: Some("Exact Final Catalog Tool".to_owned()),
10092 description: Some("exact final catalog description".to_owned()),
10093 input_schema: serde_json::json!({"type": "object"}),
10094 output_schema: Some(serde_json::json!({"type": "object"})),
10095 annotations: Some(FinalToolAnnotations {
10096 title: Some("Exact annotation title".to_owned()),
10097 destructive: Some(false),
10098 idempotent: Some(true),
10099 read_only: Some(true),
10100 open_world_hint: Some(false),
10101 }),
10102 icons: Some(self.icons.clone()),
10103 meta: Some(self.metadata.clone()),
10104 })
10105 }
10106
10107 fn final_tool_error_structured_content(
10108 &self,
10109 kind: ToolErrorKind,
10110 ) -> Option<serde_json::Value> {
10111 Some(serde_json::json!({
10112 "error": match kind {
10113 ToolErrorKind::InputValidation => "input-validation",
10114 ToolErrorKind::Handler => "handler",
10115 }
10116 }))
10117 }
10118
10119 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10120 Ok(vec![Content::text("legacy final catalog result")])
10121 }
10122 }
10123
10124 struct FinalCatalogResource {
10125 metadata: OpenMetadata,
10126 icons: Vec<RawIcon>,
10127 annotations: Annotations,
10128 }
10129
10130 impl ResourceHandler for FinalCatalogResource {
10131 fn definition(&self) -> Resource {
10132 Resource {
10133 uri: "file:///final-catalog-resource".to_owned(),
10134 name: "final-catalog-resource".to_owned(),
10135 description: Some("final resource description".to_owned()),
10136 mime_type: Some("text/plain".to_owned()),
10137 icon: None,
10138 version: None,
10139 tags: Vec::new(),
10140 }
10141 }
10142
10143 fn final_title(&self) -> Option<&str> {
10144 Some("Final Catalog Resource")
10145 }
10146
10147 fn final_icons(&self) -> Option<&[RawIcon]> {
10148 Some(&self.icons)
10149 }
10150
10151 fn final_annotations(&self) -> Option<&Annotations> {
10152 Some(&self.annotations)
10153 }
10154
10155 fn final_metadata(&self) -> Option<&OpenMetadata> {
10156 Some(&self.metadata)
10157 }
10158
10159 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10160 Ok(Vec::new())
10161 }
10162 }
10163
10164 struct FinalCatalogResourceTemplate {
10165 metadata: OpenMetadata,
10166 icons: Vec<RawIcon>,
10167 annotations: Annotations,
10168 }
10169
10170 impl ResourceHandler for FinalCatalogResourceTemplate {
10171 fn definition(&self) -> Resource {
10172 Resource {
10173 uri: "template://placeholder".to_owned(),
10174 name: "final-catalog-template".to_owned(),
10175 description: None,
10176 mime_type: None,
10177 icon: None,
10178 version: None,
10179 tags: Vec::new(),
10180 }
10181 }
10182
10183 fn template(&self) -> Option<ResourceTemplate> {
10184 Some(ResourceTemplate {
10185 uri_template: "template://{id}".to_owned(),
10186 name: "final-catalog-template".to_owned(),
10187 description: Some("final template description".to_owned()),
10188 mime_type: Some("application/json".to_owned()),
10189 icon: None,
10190 version: None,
10191 tags: Vec::new(),
10192 })
10193 }
10194
10195 fn final_template_title(&self) -> Option<&str> {
10196 Some("Final Catalog Template")
10197 }
10198
10199 fn final_template_icons(&self) -> Option<&[RawIcon]> {
10200 Some(&self.icons)
10201 }
10202
10203 fn final_template_annotations(&self) -> Option<&Annotations> {
10204 Some(&self.annotations)
10205 }
10206
10207 fn final_template_metadata(&self) -> Option<&OpenMetadata> {
10208 Some(&self.metadata)
10209 }
10210
10211 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10212 Ok(Vec::new())
10213 }
10214 }
10215
10216 struct FinalCatalogPrompt {
10217 metadata: OpenMetadata,
10218 icons: Vec<RawIcon>,
10219 }
10220
10221 impl PromptHandler for FinalCatalogPrompt {
10222 fn definition(&self) -> Prompt {
10223 Prompt {
10224 name: "final-catalog-prompt".to_owned(),
10225 description: Some("final prompt description".to_owned()),
10226 arguments: vec![PromptArgument {
10227 name: "optional-argument".to_owned(),
10228 description: Some("must remain explicitly false".to_owned()),
10229 required: false,
10230 }],
10231 icon: None,
10232 version: None,
10233 tags: Vec::new(),
10234 }
10235 }
10236
10237 fn final_title(&self) -> Option<&str> {
10238 Some("Final Catalog Prompt")
10239 }
10240
10241 fn final_icons(&self) -> Option<&[RawIcon]> {
10242 Some(&self.icons)
10243 }
10244
10245 fn final_metadata(&self) -> Option<&OpenMetadata> {
10246 Some(&self.metadata)
10247 }
10248
10249 fn get(
10250 &self,
10251 _ctx: &McpContext,
10252 _args: std::collections::HashMap<String, String>,
10253 ) -> McpResult<Vec<PromptMessage>> {
10254 Ok(Vec::new())
10255 }
10256 }
10257
10258 struct DirectFinalPrompt {
10259 final_calls: Arc<AtomicUsize>,
10260 }
10261
10262 impl PromptHandler for DirectFinalPrompt {
10263 fn definition(&self) -> Prompt {
10264 Prompt {
10265 name: "direct-final-prompt".to_owned(),
10266 description: Some("legacy prompt definition".to_owned()),
10267 arguments: vec![],
10268 icon: None,
10269 version: None,
10270 tags: Vec::new(),
10271 }
10272 }
10273
10274 fn get(
10275 &self,
10276 _ctx: &McpContext,
10277 _args: std::collections::HashMap<String, String>,
10278 ) -> McpResult<Vec<PromptMessage>> {
10279 Err(McpError::internal_error(
10280 "legacy prompt projection must not service a final request",
10281 ))
10282 }
10283
10284 fn get_final(
10285 &self,
10286 _ctx: &McpContext,
10287 _args: std::collections::HashMap<String, String>,
10288 ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
10289 self.final_calls.fetch_add(1, Ordering::SeqCst);
10290 let content_meta = OpenMetadata::try_from_entries([(
10291 "com.example/direct-prompt".to_owned(),
10292 serde_json::json!({"source": "final-handler"}),
10293 )])
10294 .expect("direct prompt content metadata is valid");
10295 Ok(CompleteResult::new(
10296 FinalGetPromptResult {
10297 description: Some("direct final prompt description".to_owned()),
10298 messages: vec![FinalPromptMessage {
10299 role: fastmcp_protocol::Role::Assistant,
10300 content: ContentBlock::Audio {
10301 data: "aGVsbG8=".to_owned(),
10302 mime_type: "audio/mpeg".to_owned(),
10303 annotations: None,
10304 meta: Some(content_meta),
10305 additional: BTreeMap::from([(
10306 "com.example/direct-field".to_owned(),
10307 serde_json::json!(true),
10308 )]),
10309 },
10310 }],
10311 },
10312 empty_final_result_meta()?,
10313 ))
10314 }
10315 }
10316
10317 struct PromptArgumentBoundary {
10318 final_calls: Arc<AtomicUsize>,
10319 legacy_calls: Arc<AtomicUsize>,
10320 }
10321
10322 impl PromptHandler for PromptArgumentBoundary {
10323 fn definition(&self) -> Prompt {
10324 Prompt {
10325 name: "prompt-argument-boundary".to_owned(),
10326 description: None,
10327 arguments: vec![PromptArgument {
10328 name: "topic".to_owned(),
10329 description: Some("required modern prompt topic".to_owned()),
10330 required: true,
10331 }],
10332 icon: None,
10333 version: None,
10334 tags: Vec::new(),
10335 }
10336 }
10337
10338 fn get(
10339 &self,
10340 _ctx: &McpContext,
10341 _args: std::collections::HashMap<String, String>,
10342 ) -> McpResult<Vec<PromptMessage>> {
10343 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10344 Ok(vec![PromptMessage {
10345 role: fastmcp_protocol::Role::Assistant,
10346 content: Content::text("legacy prompt-argument-boundary result"),
10347 }])
10348 }
10349
10350 fn get_final(
10351 &self,
10352 _ctx: &McpContext,
10353 _args: std::collections::HashMap<String, String>,
10354 ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
10355 self.final_calls.fetch_add(1, Ordering::SeqCst);
10356 Ok(CompleteResult::new(
10357 FinalGetPromptResult {
10358 description: None,
10359 messages: vec![FinalPromptMessage {
10360 role: fastmcp_protocol::Role::Assistant,
10361 content: ContentBlock::text("final prompt-argument-boundary result"),
10362 }],
10363 },
10364 empty_final_result_meta()?,
10365 ))
10366 }
10367 }
10368
10369 struct MutablePromptDefinition {
10372 expose_admitted_argument: Arc<AtomicBool>,
10373 final_calls: Arc<AtomicUsize>,
10374 }
10375
10376 impl PromptHandler for MutablePromptDefinition {
10377 fn definition(&self) -> Prompt {
10378 let argument = if self.expose_admitted_argument.load(Ordering::SeqCst) {
10379 PromptArgument {
10380 name: "topic".to_owned(),
10381 description: Some("admitted required argument".to_owned()),
10382 required: true,
10383 }
10384 } else {
10385 PromptArgument {
10386 name: "mutated".to_owned(),
10387 description: Some("must not affect final validation".to_owned()),
10388 required: false,
10389 }
10390 };
10391 Prompt {
10392 name: "mutable-prompt-definition".to_owned(),
10393 description: None,
10394 arguments: vec![argument],
10395 icon: None,
10396 version: None,
10397 tags: Vec::new(),
10398 }
10399 }
10400
10401 fn get(
10402 &self,
10403 _ctx: &McpContext,
10404 _args: std::collections::HashMap<String, String>,
10405 ) -> McpResult<Vec<PromptMessage>> {
10406 Ok(Vec::new())
10407 }
10408
10409 fn get_final(
10410 &self,
10411 _ctx: &McpContext,
10412 _args: std::collections::HashMap<String, String>,
10413 ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
10414 self.final_calls.fetch_add(1, Ordering::SeqCst);
10415 Ok(CompleteResult::new(
10416 FinalGetPromptResult {
10417 description: None,
10418 messages: Vec::new(),
10419 },
10420 empty_final_result_meta()?,
10421 ))
10422 }
10423 }
10424
10425 fn input_required_result(forged_request_state: &str) -> InputRequiredResult {
10426 let encoded = serde_json::json!({
10427 "resultType": "input_required",
10428 "inputRequests": {"roots": {"method": "roots/list"}},
10429 "requestState": forged_request_state,
10430 })
10431 .to_string();
10432 let (decoded, diagnostic) = decode_peer_result(
10433 &encoded,
10434 ResultPeerEra::Modern,
10435 &CoreResultDiscriminatorPolicy,
10436 )
10437 .expect("test input-required result decodes");
10438 assert!(diagnostic.is_none());
10439 let DecodedResult::InputRequired(result) = decoded else {
10440 panic!("test result is input_required");
10441 };
10442 result
10443 }
10444
10445 fn sampling_input_required_result(forged_request_state: &str) -> InputRequiredResult {
10446 let encoded = serde_json::json!({
10447 "resultType": "input_required",
10448 "inputRequests": {
10449 "sample": {
10450 "method": "sampling/createMessage",
10451 "params": {
10452 "messages": [{
10453 "role": "assistant",
10454 "content": {
10455 "type": "tool_use",
10456 "id": "weather-1",
10457 "name": "weather",
10458 "input": {"city": "Boston"},
10459 },
10460 }],
10461 "maxTokens": 16,
10462 "tools": [{"name": "weather", "inputSchema": {"type": "object"}}],
10463 "toolChoice": {"mode": "required"},
10464 },
10465 },
10466 },
10467 "requestState": forged_request_state,
10468 })
10469 .to_string();
10470 let (decoded, diagnostic) = decode_peer_result(
10471 &encoded,
10472 ResultPeerEra::Modern,
10473 &CoreResultDiscriminatorPolicy,
10474 )
10475 .expect("final sampling input-required result decodes");
10476 assert!(diagnostic.is_none());
10477 let DecodedResult::InputRequired(result) = decoded else {
10478 panic!("test result is sampling input_required");
10479 };
10480 result
10481 }
10482
10483 fn state_only_input_required_result(forged_request_state: &str) -> InputRequiredResult {
10484 let encoded = serde_json::json!({
10485 "resultType": "input_required",
10486 "requestState": forged_request_state,
10487 })
10488 .to_string();
10489 let (decoded, diagnostic) = decode_peer_result(
10490 &encoded,
10491 ResultPeerEra::Modern,
10492 &CoreResultDiscriminatorPolicy,
10493 )
10494 .expect("test state-only input-required result decodes");
10495 assert!(diagnostic.is_none());
10496 let DecodedResult::InputRequired(result) = decoded else {
10497 panic!("test result is state-only input_required");
10498 };
10499 assert!(result.input_requests().is_none());
10500 result
10501 }
10502
10503 fn router_roots_response_wire() -> serde_json::Value {
10504 serde_json::to_value(
10505 MrtrInputResponse::roots(fastmcp_protocol::ListRootsResult::empty())
10506 .expect("roots response serializes"),
10507 )
10508 .expect("roots response converts to a wire value")
10509 }
10510
10511 struct InputRequiredTool {
10512 legacy_calls: Arc<AtomicUsize>,
10513 final_calls: Arc<AtomicUsize>,
10514 }
10515
10516 struct StateOnlyInputRequiredTool {
10517 initial_calls: Arc<AtomicUsize>,
10518 resumed_calls: Arc<AtomicUsize>,
10519 }
10520
10521 impl ToolHandler for StateOnlyInputRequiredTool {
10522 fn definition(&self) -> Tool {
10523 Tool {
10524 name: "state-only-input-required-tool".to_owned(),
10525 description: None,
10526 input_schema: serde_json::json!({"type": "object"}),
10527 output_schema: None,
10528 icon: None,
10529 version: None,
10530 tags: Vec::new(),
10531 annotations: None,
10532 }
10533 }
10534
10535 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10536 Ok(vec![Content::text(
10537 "legacy state-only input-required result",
10538 )])
10539 }
10540
10541 fn declares_final_mrtr(&self) -> bool {
10542 true
10543 }
10544
10545 fn call_final_outcome(
10546 &self,
10547 _ctx: &McpContext,
10548 _args: serde_json::Value,
10549 ) -> McpResult<FinalToolOutcome> {
10550 self.initial_calls.fetch_add(1, Ordering::SeqCst);
10551 Ok(FinalToolOutcome::InputRequired(
10552 state_only_input_required_result("handler-forged-state"),
10553 ))
10554 }
10555
10556 fn call_final_outcome_async_resuming_in_request<'a>(
10557 &'a self,
10558 ctx: &'a McpContext,
10559 _request_cx: &'a Cx,
10560 arguments: serde_json::Value,
10561 resume_inputs: Option<&'a MrtrCompletedInputs>,
10562 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10563 Box::pin(async move {
10564 let Some(resume_inputs) = resume_inputs else {
10567 return match self.call_final_outcome(ctx, arguments) {
10568 Ok(result) => Outcome::Ok(result),
10569 Err(error) => Outcome::Err(error),
10570 };
10571 };
10572 if !resume_inputs.responses().is_empty() {
10573 return Outcome::Err(McpError::internal_error(
10574 "state-only MRTR resume unexpectedly carried inputs",
10575 ));
10576 }
10577 self.resumed_calls.fetch_add(1, Ordering::SeqCst);
10578 Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
10579 FinalCallToolResult {
10580 content: vec![ContentBlock::text("state-only resumed")],
10581 is_error: false,
10582 structured_content: None,
10583 },
10584 )))
10585 })
10586 }
10587 }
10588
10589 impl ToolHandler for InputRequiredTool {
10590 fn definition(&self) -> Tool {
10591 Tool {
10592 name: "input-required-tool".to_owned(),
10593 description: None,
10594 input_schema: serde_json::json!({"type": "object"}),
10595 output_schema: None,
10596 icon: None,
10597 version: None,
10598 tags: Vec::new(),
10599 annotations: None,
10600 }
10601 }
10602
10603 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10604 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10605 Ok(vec![Content::text("legacy tool result")])
10606 }
10607
10608 fn declares_final_mrtr(&self) -> bool {
10609 true
10610 }
10611
10612 fn call_final_outcome(
10613 &self,
10614 _ctx: &McpContext,
10615 _args: serde_json::Value,
10616 ) -> McpResult<FinalToolOutcome> {
10617 self.final_calls.fetch_add(1, Ordering::SeqCst);
10618 Ok(FinalToolOutcome::InputRequired(input_required_result(
10619 "tool-retry-state",
10620 )))
10621 }
10622
10623 fn call_final_outcome_async_resuming_in_request<'a>(
10624 &'a self,
10625 ctx: &'a McpContext,
10626 _request_cx: &'a Cx,
10627 arguments: serde_json::Value,
10628 resume_inputs: Option<&'a MrtrCompletedInputs>,
10629 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10630 Box::pin(async move {
10631 let Some(resume_inputs) = resume_inputs else {
10634 return match self.call_final_outcome(ctx, arguments) {
10635 Ok(result) => Outcome::Ok(result),
10636 Err(error) => Outcome::Err(error),
10637 };
10638 };
10639 match resume_inputs.roots("roots") {
10640 Ok(Some(_)) => match self.call_final_outcome(ctx, arguments) {
10641 Ok(result) => Outcome::Ok(result),
10642 Err(error) => Outcome::Err(error),
10643 },
10644 Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
10645 Err(error) => Outcome::Err(error),
10646 }
10647 })
10648 }
10649 }
10650
10651 struct OneShotMrtrTool {
10652 calls: Arc<AtomicUsize>,
10653 }
10654
10655 struct ContextElicitationTool {
10656 initial_calls: Arc<AtomicUsize>,
10657 resumed_calls: Arc<AtomicUsize>,
10658 }
10659
10660 struct ContextSamplingTool {
10661 calls: Arc<AtomicUsize>,
10662 }
10663
10664 impl ToolHandler for ContextElicitationTool {
10665 fn definition(&self) -> Tool {
10666 Tool {
10667 name: "context-elicitation-tool".to_owned(),
10668 description: None,
10669 input_schema: serde_json::json!({"type": "object"}),
10670 output_schema: None,
10671 icon: None,
10672 version: None,
10673 tags: Vec::new(),
10674 annotations: None,
10675 }
10676 }
10677
10678 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10679 Ok(vec![Content::text("legacy result")])
10680 }
10681
10682 fn declares_final_mrtr(&self) -> bool {
10683 true
10684 }
10685
10686 fn call_final_outcome(
10687 &self,
10688 ctx: &McpContext,
10689 _args: serde_json::Value,
10690 ) -> McpResult<FinalToolOutcome> {
10691 self.initial_calls.fetch_add(1, Ordering::SeqCst);
10692 let elicitation = ctx.final_elicitation_form(
10693 "approval",
10694 "Approve this operation",
10695 serde_json::json!({
10696 "type": "object",
10697 "properties": {"approved": {"type": "boolean"}},
10698 "required": ["approved"],
10699 }),
10700 )?;
10701 Ok(FinalToolOutcome::InputRequired(
10702 elicitation.into_input_required()?,
10703 ))
10704 }
10705
10706 fn call_final_outcome_async_resuming_in_request<'a>(
10707 &'a self,
10708 ctx: &'a McpContext,
10709 _request_cx: &'a Cx,
10710 arguments: serde_json::Value,
10711 resume_inputs: Option<&'a MrtrCompletedInputs>,
10712 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10713 Box::pin(async move {
10714 let Some(resume_inputs) = resume_inputs else {
10715 return match self.call_final_outcome(ctx, arguments) {
10716 Ok(result) => Outcome::Ok(result),
10717 Err(error) => Outcome::Err(error),
10718 };
10719 };
10720 let response = match resume_inputs.elicitation("approval") {
10721 Ok(Some(response)) => response,
10722 Ok(None) => {
10723 return Outcome::Err(McpError::internal_error(
10724 "MRTR elicitation response was lost",
10725 ));
10726 }
10727 Err(error) => return Outcome::Err(error),
10728 };
10729 if response.action != fastmcp_protocol::ElicitAction::Accept
10730 || response
10731 .content
10732 .as_ref()
10733 .and_then(|content| content.get("approved"))
10734 != Some(&fastmcp_protocol::ElicitContentValue::Bool(true))
10735 {
10736 return Outcome::Err(McpError::invalid_params(
10737 "MRTR elicitation response did not approve the operation",
10738 ));
10739 }
10740 self.resumed_calls.fetch_add(1, Ordering::SeqCst);
10741 Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
10742 FinalCallToolResult {
10743 content: vec![ContentBlock::text("approved")],
10744 is_error: false,
10745 structured_content: None,
10746 },
10747 )))
10748 })
10749 }
10750 }
10751
10752 impl ToolHandler for ContextSamplingTool {
10753 fn definition(&self) -> Tool {
10754 Tool {
10755 name: "context-sampling-tool".to_owned(),
10756 description: None,
10757 input_schema: serde_json::json!({"type": "object"}),
10758 output_schema: None,
10759 icon: None,
10760 version: None,
10761 tags: Vec::new(),
10762 annotations: None,
10763 }
10764 }
10765
10766 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10767 Ok(vec![Content::text("legacy result")])
10768 }
10769
10770 fn declares_final_mrtr(&self) -> bool {
10771 true
10772 }
10773
10774 fn call_final_outcome(
10775 &self,
10776 _ctx: &McpContext,
10777 _args: serde_json::Value,
10778 ) -> McpResult<FinalToolOutcome> {
10779 self.calls.fetch_add(1, Ordering::SeqCst);
10780 Ok(FinalToolOutcome::InputRequired(
10781 sampling_input_required_result("handler-forged-state"),
10782 ))
10783 }
10784 }
10785
10786 impl ToolHandler for OneShotMrtrTool {
10787 fn definition(&self) -> Tool {
10788 Tool {
10789 name: "one-shot-mrtr-tool".to_owned(),
10790 description: None,
10791 input_schema: serde_json::json!({"type": "object"}),
10792 output_schema: None,
10793 icon: None,
10794 version: None,
10795 tags: Vec::new(),
10796 annotations: None,
10797 }
10798 }
10799
10800 fn declares_final_mrtr(&self) -> bool {
10801 true
10802 }
10803
10804 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10805 Ok(vec![Content::text("one-shot legacy result")])
10806 }
10807
10808 fn call_final_outcome(
10809 &self,
10810 _ctx: &McpContext,
10811 _args: serde_json::Value,
10812 ) -> McpResult<FinalToolOutcome> {
10813 self.calls.fetch_add(1, Ordering::SeqCst);
10814 Ok(FinalToolOutcome::InputRequired(input_required_result(
10815 "one-shot-handler-state",
10816 )))
10817 }
10818
10819 fn call_final_outcome_async_resuming_in_request<'a>(
10820 &'a self,
10821 ctx: &'a McpContext,
10822 _request_cx: &'a Cx,
10823 arguments: serde_json::Value,
10824 resume_inputs: Option<&'a MrtrCompletedInputs>,
10825 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10826 Box::pin(async move {
10827 let Some(resume_inputs) = resume_inputs else {
10828 return match self.call_final_outcome(ctx, arguments) {
10829 Ok(result) => Outcome::Ok(result),
10830 Err(error) => Outcome::Err(error),
10831 };
10832 };
10833 match resume_inputs.roots("roots") {
10834 Ok(Some(_)) => {
10835 self.calls.fetch_add(1, Ordering::SeqCst);
10836 Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
10837 FinalCallToolResult {
10838 content: vec![ContentBlock::text("one-shot resumed")],
10839 is_error: false,
10840 structured_content: None,
10841 },
10842 )))
10843 }
10844 Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
10845 Err(error) => Outcome::Err(error),
10846 }
10847 })
10848 }
10849 }
10850
10851 #[cfg(feature = "tasks")]
10852 struct TaskCapableInputRequiredTool {
10853 final_calls: Arc<AtomicUsize>,
10854 }
10855
10856 #[cfg(feature = "tasks")]
10857 impl ToolHandler for TaskCapableInputRequiredTool {
10858 fn definition(&self) -> Tool {
10859 Tool {
10860 name: "task-capable-input-required-tool".to_owned(),
10861 description: None,
10862 input_schema: serde_json::json!({"type": "object"}),
10863 output_schema: None,
10864 icon: None,
10865 version: None,
10866 tags: Vec::new(),
10867 annotations: None,
10868 }
10869 }
10870
10871 fn declares_final_tasks(&self) -> bool {
10872 true
10873 }
10874
10875 fn declares_final_mrtr(&self) -> bool {
10876 true
10877 }
10878
10879 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10880 Ok(vec![Content::text(
10881 "legacy task-capable input-required result",
10882 )])
10883 }
10884
10885 fn call_final_outcome(
10886 &self,
10887 _ctx: &McpContext,
10888 _args: serde_json::Value,
10889 ) -> McpResult<FinalToolOutcome> {
10890 self.final_calls.fetch_add(1, Ordering::SeqCst);
10891 Ok(FinalToolOutcome::InputRequired(input_required_result(
10892 "task-capable-tool-retry-state",
10893 )))
10894 }
10895 }
10896
10897 struct InputRequiredResource {
10898 legacy_calls: Arc<AtomicUsize>,
10899 final_calls: Arc<AtomicUsize>,
10900 }
10901
10902 impl ResourceHandler for InputRequiredResource {
10903 fn definition(&self) -> Resource {
10904 Resource {
10905 uri: "file:///input-required-resource".to_owned(),
10906 name: "input-required-resource".to_owned(),
10907 description: None,
10908 mime_type: Some("text/plain".to_owned()),
10909 icon: None,
10910 version: None,
10911 tags: Vec::new(),
10912 }
10913 }
10914
10915 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10916 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10917 Ok(vec![ResourceContent {
10918 uri: "file:///input-required-resource".to_owned(),
10919 mime_type: Some("text/plain".to_owned()),
10920 text: Some("legacy resource result".to_owned()),
10921 blob: None,
10922 }])
10923 }
10924
10925 fn declares_final_mrtr(&self) -> bool {
10926 true
10927 }
10928
10929 fn read_final_outcome(
10930 &self,
10931 _ctx: &McpContext,
10932 ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
10933 self.final_calls.fetch_add(1, Ordering::SeqCst);
10934 Ok(FinalMethodOutcome::InputRequired(input_required_result(
10935 "resource-retry-state",
10936 )))
10937 }
10938
10939 fn read_final_outcome_async_with_uri_resuming_in_request<'a>(
10940 &'a self,
10941 ctx: &'a McpContext,
10942 _request_cx: &'a Cx,
10943 _uri: &'a str,
10944 _params: &'a UriParams,
10945 resume_inputs: Option<&'a MrtrCompletedInputs>,
10946 ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
10947 Box::pin(async move {
10948 let Some(resume_inputs) = resume_inputs else {
10949 return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
10950 };
10951 match resume_inputs.roots("roots") {
10952 Ok(Some(_)) => match self.read_final_outcome(ctx) {
10953 Ok(result) => Outcome::Ok(result),
10954 Err(error) => Outcome::Err(error),
10955 },
10956 Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
10957 Err(error) => Outcome::Err(error),
10958 }
10959 })
10960 }
10961 }
10962
10963 struct InputRequiredPrompt {
10964 legacy_calls: Arc<AtomicUsize>,
10965 final_calls: Arc<AtomicUsize>,
10966 }
10967
10968 impl PromptHandler for InputRequiredPrompt {
10969 fn definition(&self) -> Prompt {
10970 Prompt {
10971 name: "input-required-prompt".to_owned(),
10972 description: None,
10973 arguments: Vec::new(),
10974 icon: None,
10975 version: None,
10976 tags: Vec::new(),
10977 }
10978 }
10979
10980 fn get(
10981 &self,
10982 _ctx: &McpContext,
10983 _args: std::collections::HashMap<String, String>,
10984 ) -> McpResult<Vec<PromptMessage>> {
10985 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10986 Ok(Vec::new())
10987 }
10988
10989 fn declares_final_mrtr(&self) -> bool {
10990 true
10991 }
10992
10993 fn get_final_outcome(
10994 &self,
10995 _ctx: &McpContext,
10996 _args: std::collections::HashMap<String, String>,
10997 ) -> McpResult<FinalMethodOutcome<FinalGetPromptResult>> {
10998 self.final_calls.fetch_add(1, Ordering::SeqCst);
10999 Ok(FinalMethodOutcome::InputRequired(input_required_result(
11000 "prompt-retry-state",
11001 )))
11002 }
11003
11004 fn get_final_outcome_async_resuming_in_request<'a>(
11005 &'a self,
11006 ctx: &'a McpContext,
11007 _request_cx: &'a Cx,
11008 arguments: std::collections::HashMap<String, String>,
11009 resume_inputs: Option<&'a MrtrCompletedInputs>,
11010 ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalGetPromptResult>>> {
11011 Box::pin(async move {
11012 let Some(resume_inputs) = resume_inputs else {
11013 return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
11014 };
11015 match resume_inputs.roots("roots") {
11016 Ok(Some(_)) => match self.get_final_outcome(ctx, arguments) {
11017 Ok(result) => Outcome::Ok(result),
11018 Err(error) => Outcome::Err(error),
11019 },
11020 Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
11021 Err(error) => Outcome::Err(error),
11022 }
11023 })
11024 }
11025 }
11026
11027 fn direct_final_prompt_request(id: i64) -> JsonRpcRequest {
11028 JsonRpcRequest::new(
11029 "prompts/get",
11030 Some(serde_json::json!({
11031 "_meta": {
11032 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11033 "io.modelcontextprotocol/clientCapabilities": {},
11034 },
11035 "name": "direct-final-prompt",
11036 })),
11037 id,
11038 )
11039 }
11040
11041 fn final_prompt_get_request(
11042 name: &str,
11043 arguments: serde_json::Value,
11044 id: i64,
11045 ) -> JsonRpcRequest {
11046 JsonRpcRequest::new(
11047 "prompts/get",
11048 Some(serde_json::json!({
11049 "_meta": {
11050 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11051 "io.modelcontextprotocol/clientCapabilities": {},
11052 },
11053 "name": name,
11054 "arguments": arguments,
11055 })),
11056 id,
11057 )
11058 }
11059
11060 struct DirectFinalResource {
11061 legacy_calls: Arc<AtomicUsize>,
11062 final_calls: Arc<AtomicUsize>,
11063 }
11064
11065 impl ResourceHandler for DirectFinalResource {
11066 fn definition(&self) -> Resource {
11067 Resource {
11068 uri: "file:///direct-final-resource".to_owned(),
11069 name: "direct-final-resource".to_owned(),
11070 description: Some("legacy resource definition".to_owned()),
11071 mime_type: Some("text/plain".to_owned()),
11072 icon: None,
11073 version: None,
11074 tags: Vec::new(),
11075 }
11076 }
11077
11078 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11079 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
11080 Ok(vec![ResourceContent {
11081 uri: "file:///direct-final-resource".to_owned(),
11082 mime_type: Some("text/plain".to_owned()),
11083 text: Some("legacy resource result".to_owned()),
11084 blob: None,
11085 }])
11086 }
11087
11088 fn read_final(
11089 &self,
11090 _ctx: &McpContext,
11091 ) -> McpResult<CompleteResult<FinalReadResourceResult>> {
11092 self.final_calls.fetch_add(1, Ordering::SeqCst);
11093 let content_meta = OpenMetadata::try_from_entries([(
11094 "com.example/direct-resource".to_owned(),
11095 serde_json::json!({"source": "final-handler"}),
11096 )])
11097 .expect("direct resource content metadata is valid");
11098 Ok(CompleteResult::new(
11099 FinalReadResourceResult {
11100 contents: vec![EmbeddedResourceContents::Text {
11101 uri: AbsoluteUri::parse("file:///direct-final-resource")
11102 .expect("routed direct resource URI is valid"),
11103 text: "direct final resource result".to_owned(),
11104 mime_type: Some("text/markdown".to_owned()),
11105 meta: Some(content_meta),
11106 additional: BTreeMap::from([(
11107 "com.example/direct-field".to_owned(),
11108 serde_json::json!(true),
11109 )]),
11110 }],
11111 ttl_ms: CacheTtl::milliseconds(321),
11112 cache_scope: CacheScope::Public,
11113 },
11114 empty_final_result_meta()?,
11115 ))
11116 }
11117
11118 fn final_resource_read_cache_hint_provenance(
11119 &self,
11120 ) -> FinalResourceReadCacheHintProvenance {
11121 FinalResourceReadCacheHintProvenance::Explicit
11122 }
11123 }
11124
11125 struct HttpsCatalogResource {
11126 client_direct_https: bool,
11127 }
11128
11129 impl ResourceHandler for HttpsCatalogResource {
11130 fn definition(&self) -> Resource {
11131 Resource {
11132 uri: "https://client.example.test/catalog.txt".to_owned(),
11133 name: "client-direct-catalog".to_owned(),
11134 description: None,
11135 mime_type: Some("text/plain".to_owned()),
11136 icon: None,
11137 version: None,
11138 tags: Vec::new(),
11139 }
11140 }
11141
11142 fn final_client_direct_https(&self) -> bool {
11143 self.client_direct_https
11144 }
11145
11146 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11147 Ok(Vec::new())
11148 }
11149 }
11150
11151 struct ClientDirectHttpsPrompt;
11152
11153 impl PromptHandler for ClientDirectHttpsPrompt {
11154 fn definition(&self) -> Prompt {
11155 Prompt {
11156 name: "client-direct-https-prompt".to_owned(),
11157 description: None,
11158 arguments: Vec::new(),
11159 icon: None,
11160 version: None,
11161 tags: Vec::new(),
11162 }
11163 }
11164
11165 fn final_client_direct_https(&self) -> bool {
11166 true
11167 }
11168
11169 fn get(
11170 &self,
11171 _ctx: &McpContext,
11172 _args: std::collections::HashMap<String, String>,
11173 ) -> McpResult<Vec<PromptMessage>> {
11174 Ok(Vec::new())
11175 }
11176
11177 fn get_final(
11178 &self,
11179 _ctx: &McpContext,
11180 _args: std::collections::HashMap<String, String>,
11181 ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
11182 Ok(CompleteResult::new(
11183 FinalGetPromptResult {
11184 description: None,
11185 messages: vec![FinalPromptMessage {
11186 role: fastmcp_protocol::Role::Assistant,
11187 content: ContentBlock::ResourceLink {
11188 icons: None,
11189 name: "client-direct-link".to_owned(),
11190 title: None,
11191 uri: AbsoluteUri::parse("https://client.example.test/prompt-link")
11192 .expect("test HTTPS URI is valid"),
11193 description: None,
11194 mime_type: Some("text/plain".to_owned()),
11195 annotations: None,
11196 size: None,
11197 meta: None,
11198 additional: BTreeMap::new(),
11199 },
11200 }],
11201 },
11202 empty_final_result_meta()?,
11203 ))
11204 }
11205 }
11206
11207 struct MrtrHttpsEmbeddedResource {
11208 initial_calls: Arc<AtomicUsize>,
11209 resumed_calls: Arc<AtomicUsize>,
11210 }
11211
11212 impl ResourceHandler for MrtrHttpsEmbeddedResource {
11213 fn definition(&self) -> Resource {
11214 Resource {
11215 uri: "mcp://uri-policy/mrtr".to_owned(),
11216 name: "uri-policy-mrtr".to_owned(),
11217 description: None,
11218 mime_type: Some("text/plain".to_owned()),
11219 icon: None,
11220 version: None,
11221 tags: Vec::new(),
11222 }
11223 }
11224
11225 fn final_client_direct_https(&self) -> bool {
11226 true
11227 }
11228
11229 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11230 Ok(Vec::new())
11231 }
11232
11233 fn read_final_outcome(
11234 &self,
11235 _ctx: &McpContext,
11236 ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
11237 self.initial_calls.fetch_add(1, Ordering::SeqCst);
11238 Ok(FinalMethodOutcome::InputRequired(input_required_result(
11239 "uri-policy-mrtr-state",
11240 )))
11241 }
11242
11243 fn read_final_outcome_async_with_uri_resuming_in_request<'a>(
11244 &'a self,
11245 _ctx: &'a McpContext,
11246 _request_cx: &'a Cx,
11247 _uri: &'a str,
11248 _params: &'a UriParams,
11249 resume_inputs: Option<&'a MrtrCompletedInputs>,
11250 ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
11251 Box::pin(async move {
11252 let Some(resume_inputs) = resume_inputs else {
11253 return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
11254 };
11255 if !matches!(resume_inputs.roots("roots"), Ok(Some(_))) {
11256 return Outcome::Err(McpError::internal_error(
11257 "MRTR roots input was not preserved",
11258 ));
11259 }
11260 self.resumed_calls.fetch_add(1, Ordering::SeqCst);
11261 Outcome::Ok(FinalMethodOutcome::Complete(CompleteResult::new(
11262 FinalReadResourceResult {
11263 contents: vec![EmbeddedResourceContents::Text {
11264 uri: AbsoluteUri::parse("https://client.example.test/mrtr-content")
11265 .expect("test HTTPS URI is valid"),
11266 text: "must not be emitted as embedded content".to_owned(),
11267 mime_type: Some("text/plain".to_owned()),
11268 meta: None,
11269 additional: BTreeMap::new(),
11270 }],
11271 ttl_ms: CacheTtl::milliseconds(1),
11272 cache_scope: CacheScope::Private,
11273 },
11274 empty_final_result_meta().expect("empty final metadata is valid"),
11275 )))
11276 })
11277 }
11278 }
11279
11280 struct SentinelHintResource {
11281 provenance: FinalResourceReadCacheHintProvenance,
11282 }
11283
11284 impl ResourceHandler for SentinelHintResource {
11285 fn definition(&self) -> Resource {
11286 Resource {
11287 uri: "file:///sentinel-hint-resource".to_owned(),
11288 name: "sentinel-hint-resource".to_owned(),
11289 description: None,
11290 mime_type: Some("text/plain".to_owned()),
11291 icon: None,
11292 version: None,
11293 tags: Vec::new(),
11294 }
11295 }
11296
11297 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11298 Ok(Vec::new())
11299 }
11300
11301 fn read_final(
11302 &self,
11303 _ctx: &McpContext,
11304 ) -> McpResult<CompleteResult<FinalReadResourceResult>> {
11305 Ok(CompleteResult::new(
11306 FinalReadResourceResult {
11307 contents: Vec::new(),
11308 ttl_ms: CacheTtl::milliseconds(DEFAULT_FINAL_RESOURCE_TTL_MS),
11311 cache_scope: CacheScope::Private,
11312 },
11313 empty_final_result_meta()?,
11314 ))
11315 }
11316
11317 fn final_resource_read_cache_hint_provenance(
11318 &self,
11319 ) -> FinalResourceReadCacheHintProvenance {
11320 self.provenance
11321 }
11322 }
11323
11324 fn direct_final_resource_request(id: i64) -> JsonRpcRequest {
11325 JsonRpcRequest::new(
11326 "resources/read",
11327 Some(serde_json::json!({
11328 "_meta": {
11329 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11330 "io.modelcontextprotocol/clientCapabilities": {},
11331 },
11332 "uri": "file:///direct-final-resource",
11333 })),
11334 id,
11335 )
11336 }
11337
11338 struct EchoCompletion;
11339
11340 impl CompletionHandler for EchoCompletion {
11341 fn complete_legacy(
11342 &self,
11343 _ctx: &McpContext,
11344 params: LegacyCompletionParams,
11345 ) -> McpResult<CompletionValues> {
11346 Ok(CompletionValues {
11347 values: vec![format!("{}ging", params.argument.value)],
11348 total: Some(1),
11349 has_more: Some(false),
11350 })
11351 }
11352
11353 fn complete_final(
11354 &self,
11355 _ctx: &McpContext,
11356 params: FinalCompletionParams,
11357 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
11358 Ok(fastmcp_protocol::FinalCompletionValues {
11359 values: vec![format!("{}ging", params.argument.value)],
11360 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
11361 has_more: Some(false),
11362 })
11363 }
11364 }
11365
11366 struct CountingCompletion {
11367 final_calls: Arc<AtomicUsize>,
11368 }
11369
11370 impl CompletionHandler for CountingCompletion {
11371 fn complete_legacy(
11372 &self,
11373 _ctx: &McpContext,
11374 _params: LegacyCompletionParams,
11375 ) -> McpResult<CompletionValues> {
11376 Ok(CompletionValues {
11377 values: vec!["legacy".to_owned()],
11378 total: Some(1),
11379 has_more: Some(false),
11380 })
11381 }
11382
11383 fn complete_final(
11384 &self,
11385 _ctx: &McpContext,
11386 params: FinalCompletionParams,
11387 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
11388 self.final_calls.fetch_add(1, Ordering::SeqCst);
11389 Ok(fastmcp_protocol::FinalCompletionValues {
11390 values: vec![format!("{}ging", params.argument.value)],
11391 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
11392 has_more: Some(false),
11393 })
11394 }
11395 }
11396
11397 struct ProviderCompletion {
11398 value: &'static str,
11399 final_calls: Arc<AtomicUsize>,
11400 }
11401
11402 impl CompletionHandler for ProviderCompletion {
11403 fn complete_legacy(
11404 &self,
11405 _ctx: &McpContext,
11406 _params: LegacyCompletionParams,
11407 ) -> McpResult<CompletionValues> {
11408 Ok(CompletionValues {
11409 values: vec![self.value.to_owned()],
11410 total: Some(1),
11411 has_more: Some(false),
11412 })
11413 }
11414
11415 fn complete_final(
11416 &self,
11417 _ctx: &McpContext,
11418 _params: FinalCompletionParams,
11419 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
11420 self.final_calls.fetch_add(1, Ordering::SeqCst);
11421 Ok(fastmcp_protocol::FinalCompletionValues {
11422 values: vec![self.value.to_owned()],
11423 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
11424 has_more: Some(false),
11425 })
11426 }
11427 }
11428
11429 struct ReversibleLevelFourTemplateResource {
11430 read_calls: Arc<AtomicUsize>,
11431 }
11432
11433 impl ResourceHandler for ReversibleLevelFourTemplateResource {
11434 fn definition(&self) -> Resource {
11435 Resource {
11436 uri: "mcp://resource/template".to_owned(),
11437 name: "level-four-template".to_owned(),
11438 description: None,
11439 mime_type: Some("text/plain".to_owned()),
11440 icon: None,
11441 version: None,
11442 tags: Vec::new(),
11443 }
11444 }
11445
11446 fn template(&self) -> Option<ResourceTemplate> {
11447 Some(marked_template(
11448 "mcp://resource{/collection*}/manifest{?revision*}",
11449 "level-four-template",
11450 ))
11451 }
11452
11453 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11454 unreachable!("templated reads receive their URI parameters")
11455 }
11456
11457 fn read_with_uri(
11458 &self,
11459 _ctx: &McpContext,
11460 uri: &str,
11461 params: &UriParams,
11462 ) -> McpResult<Vec<ResourceContent>> {
11463 self.read_calls.fetch_add(1, Ordering::SeqCst);
11464 Ok(vec![ResourceContent {
11465 uri: uri.to_owned(),
11466 mime_type: Some("text/plain".to_owned()),
11467 text: Some(format!(
11468 "{}:{}",
11469 params.get("collection").expect("collection is captured"),
11470 params.get("revision").expect("revision is captured")
11471 )),
11472 blob: None,
11473 }])
11474 }
11475 }
11476
11477 struct LegacyTemplateResource {
11478 read_calls: Arc<AtomicUsize>,
11479 }
11480
11481 impl ResourceHandler for LegacyTemplateResource {
11482 fn definition(&self) -> Resource {
11483 Resource {
11484 uri: "mcp://resource/legacy-template".to_owned(),
11485 name: "legacy-template".to_owned(),
11486 description: None,
11487 mime_type: Some("text/plain".to_owned()),
11488 icon: None,
11489 version: None,
11490 tags: Vec::new(),
11491 }
11492 }
11493
11494 fn template(&self) -> Option<ResourceTemplate> {
11495 Some(marked_template(
11496 "mcp://resource/{collection}/manifest?revision={revision}",
11497 "legacy-template",
11498 ))
11499 }
11500
11501 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11502 unreachable!("templated reads receive their URI parameters")
11503 }
11504
11505 fn read_with_uri(
11506 &self,
11507 _ctx: &McpContext,
11508 uri: &str,
11509 params: &UriParams,
11510 ) -> McpResult<Vec<ResourceContent>> {
11511 self.read_calls.fetch_add(1, Ordering::SeqCst);
11512 Ok(vec![ResourceContent {
11513 uri: uri.to_owned(),
11514 mime_type: Some("text/plain".to_owned()),
11515 text: Some(format!(
11516 "{}:{}",
11517 params.get("collection").expect("collection is captured"),
11518 params.get("revision").expect("revision is captured")
11519 )),
11520 blob: None,
11521 }])
11522 }
11523 }
11524
11525 struct CompletionValueBoundary {
11526 final_calls: Arc<AtomicUsize>,
11527 }
11528
11529 impl CompletionHandler for CompletionValueBoundary {
11530 fn complete_legacy(
11531 &self,
11532 _ctx: &McpContext,
11533 _params: LegacyCompletionParams,
11534 ) -> McpResult<CompletionValues> {
11535 Ok(CompletionValues {
11536 values: Vec::new(),
11537 total: None,
11538 has_more: None,
11539 })
11540 }
11541
11542 fn complete_final(
11543 &self,
11544 _ctx: &McpContext,
11545 params: FinalCompletionParams,
11546 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
11547 self.final_calls.fetch_add(1, Ordering::SeqCst);
11548 let value_count = if params.argument.value == "one-over" {
11549 fastmcp_protocol::MAX_COMPLETION_VALUES + 1
11550 } else {
11551 fastmcp_protocol::MAX_COMPLETION_VALUES
11552 };
11553 Ok(fastmcp_protocol::FinalCompletionValues {
11554 values: (0..value_count)
11555 .map(|index| format!("completion-{index}"))
11556 .collect(),
11557 total: (params.argument.value == "negative-total")
11558 .then(|| fastmcp_protocol::JsonInteger::from(-1_i64)),
11559 has_more: None,
11560 })
11561 }
11562 }
11563
11564 struct ConcurrentModernTool {
11565 started: Arc<AtomicUsize>,
11566 completed: Arc<Mutex<Vec<String>>>,
11567 }
11568
11569 impl ConcurrentModernTool {
11570 fn new(started: Arc<AtomicUsize>, completed: Arc<Mutex<Vec<String>>>) -> Self {
11571 Self { started, completed }
11572 }
11573 }
11574
11575 impl ToolHandler for ConcurrentModernTool {
11576 fn definition(&self) -> Tool {
11577 Tool {
11578 name: "concurrent-modern-tool".to_string(),
11579 description: Some("deterministic modern dispatch probe".to_string()),
11580 input_schema: serde_json::json!({
11581 "type": "object",
11582 "required": ["request"],
11583 "properties": {"request": {"type": "string"}},
11584 }),
11585 output_schema: None,
11586 icon: None,
11587 version: None,
11588 tags: vec![],
11589 annotations: None,
11590 }
11591 }
11592
11593 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11594 Err(McpError::internal_error(
11595 "concurrent modern dispatch requires the async request hook",
11596 ))
11597 }
11598
11599 fn call_final_outcome_async_in_request<'a>(
11603 &'a self,
11604 ctx: &'a McpContext,
11605 request_cx: &'a Cx,
11606 arguments: serde_json::Value,
11607 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
11608 Box::pin(async move {
11609 let label = arguments
11610 .get("request")
11611 .and_then(serde_json::Value::as_str)
11612 .unwrap_or("missing")
11613 .to_string();
11614 self.started.fetch_add(1, Ordering::SeqCst);
11615
11616 while self.started.load(Ordering::SeqCst) < 2 {
11617 if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
11618 return Outcome::Cancelled(asupersync::CancelReason::user(
11619 "request cancelled before concurrent admission",
11620 ));
11621 }
11622 yield_once().await;
11623 }
11624
11625 if label == "cancelled" {
11626 loop {
11627 if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
11628 return Outcome::Cancelled(asupersync::CancelReason::user(
11629 "request cancellation observed by child Cx",
11630 ));
11631 }
11632 yield_once().await;
11633 }
11634 }
11635
11636 if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
11637 return Outcome::Cancelled(asupersync::CancelReason::user(
11638 "request cancelled before completion",
11639 ));
11640 }
11641 self.completed
11642 .lock()
11643 .expect("completion probe lock is not poisoned")
11644 .push(label.clone());
11645 match crate::handler::promote_legacy_tool_content(vec![Content::text(label)]) {
11646 Ok(result) => Outcome::Ok(FinalToolOutcome::Complete(result)),
11647 Err(error) => Outcome::Err(error),
11648 }
11649 })
11650 }
11651 }
11652
11653 struct ErrorTool {
11654 name: &'static str,
11655 code: McpErrorCode,
11656 }
11657
11658 impl ToolHandler for ErrorTool {
11659 fn definition(&self) -> Tool {
11660 Tool {
11661 name: self.name.to_string(),
11662 description: None,
11663 input_schema: serde_json::json!({"type": "object"}),
11664 output_schema: None,
11665 icon: None,
11666 version: None,
11667 tags: vec![],
11668 annotations: None,
11669 }
11670 }
11671
11672 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11673 Err(McpError::new(self.code, "nested tool error"))
11674 }
11675 }
11676
11677 struct AlternatingTool {
11678 calls: Arc<AtomicU64>,
11679 }
11680
11681 impl ToolHandler for AlternatingTool {
11682 fn definition(&self) -> Tool {
11683 Tool {
11684 name: "alternating_tool".to_string(),
11685 description: None,
11686 input_schema: serde_json::json!({"type": "object"}),
11687 output_schema: None,
11688 icon: None,
11689 version: None,
11690 tags: vec![],
11691 annotations: None,
11692 }
11693 }
11694
11695 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11696 Err(McpError::internal_error("async alternating tool only"))
11697 }
11698
11699 fn call_async<'a>(
11700 &'a self,
11701 ctx: &'a McpContext,
11702 _arguments: serde_json::Value,
11703 ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
11704 Box::pin(async move {
11705 self.calls.fetch_add(1, Ordering::Relaxed);
11706 match ctx.read_resource("loop://resource").await {
11707 Ok(_) => Outcome::Ok(vec![Content::text("unexpected completion")]),
11708 Err(error) => Outcome::Err(error),
11709 }
11710 })
11711 }
11712 }
11713
11714 struct NamedResource {
11715 uri: String,
11716 tags: Vec<String>,
11717 }
11718
11719 impl NamedResource {
11720 fn new(uri: &str) -> Self {
11721 Self {
11722 uri: uri.to_string(),
11723 tags: vec![],
11724 }
11725 }
11726 fn with_tags(uri: &str, tags: Vec<String>) -> Self {
11727 Self {
11728 uri: uri.to_string(),
11729 tags,
11730 }
11731 }
11732 }
11733
11734 impl ResourceHandler for NamedResource {
11735 fn definition(&self) -> Resource {
11736 Resource {
11737 uri: self.uri.clone(),
11738 name: self.uri.clone(),
11739 description: None,
11740 mime_type: Some("text/plain".to_string()),
11741 icon: None,
11742 version: None,
11743 tags: self.tags.clone(),
11744 }
11745 }
11746 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11747 Ok(vec![ResourceContent {
11748 uri: self.uri.clone(),
11749 mime_type: Some("text/plain".to_string()),
11750 text: Some("content".to_string()),
11751 blob: None,
11752 }])
11753 }
11754 }
11755
11756 struct AlternatingResource {
11757 calls: Arc<AtomicU64>,
11758 }
11759
11760 impl ResourceHandler for AlternatingResource {
11761 fn definition(&self) -> Resource {
11762 Resource {
11763 uri: "loop://resource".to_string(),
11764 name: "alternating-resource".to_string(),
11765 description: None,
11766 mime_type: Some("text/plain".to_string()),
11767 icon: None,
11768 version: None,
11769 tags: vec![],
11770 }
11771 }
11772
11773 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11774 Err(McpError::internal_error("async alternating resource only"))
11775 }
11776
11777 fn read_async<'a>(
11778 &'a self,
11779 ctx: &'a McpContext,
11780 ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
11781 Box::pin(async move {
11782 self.calls.fetch_add(1, Ordering::Relaxed);
11783 match ctx
11784 .call_tool("alternating_tool", serde_json::json!({}))
11785 .await
11786 {
11787 Ok(_) => Outcome::Ok(vec![ResourceContent {
11788 uri: "loop://resource".to_string(),
11789 mime_type: Some("text/plain".to_string()),
11790 text: Some("unexpected completion".to_string()),
11791 blob: None,
11792 }]),
11793 Err(error) => Outcome::Err(error),
11794 }
11795 })
11796 }
11797 }
11798
11799 struct CostLedgerTool {
11800 remaining_after_parent_debit: Arc<AtomicU64>,
11801 remaining_after_nested_read: Arc<AtomicU64>,
11802 }
11803
11804 impl ToolHandler for CostLedgerTool {
11805 fn definition(&self) -> Tool {
11806 Tool {
11807 name: "cost_ledger_tool".to_string(),
11808 description: None,
11809 input_schema: serde_json::json!({"type": "object"}),
11810 output_schema: None,
11811 icon: None,
11812 version: None,
11813 tags: vec![],
11814 annotations: None,
11815 }
11816 }
11817
11818 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11819 Err(McpError::internal_error("async cost-ledger tool only"))
11820 }
11821
11822 fn call_async<'a>(
11823 &'a self,
11824 ctx: &'a McpContext,
11825 _arguments: serde_json::Value,
11826 ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
11827 Box::pin(async move {
11828 if ctx.consume_cost(1).is_err() {
11829 return Outcome::Err(McpError::request_cancelled());
11830 }
11831 self.remaining_after_parent_debit.store(
11832 ctx.budget()
11833 .cost_quota
11834 .expect("test request has finite cost quota"),
11835 Ordering::Relaxed,
11836 );
11837
11838 if let Err(error) = ctx.read_resource("cost://nested").await {
11839 return Outcome::Err(error);
11840 }
11841 self.remaining_after_nested_read.store(
11842 ctx.budget()
11843 .cost_quota
11844 .expect("test request has finite cost quota"),
11845 Ordering::Relaxed,
11846 );
11847 Outcome::Ok(vec![Content::text("shared ledger")])
11848 })
11849 }
11850 }
11851
11852 struct CostLedgerResource {
11853 remaining_after_nested_debit: Arc<AtomicU64>,
11854 }
11855
11856 impl ResourceHandler for CostLedgerResource {
11857 fn definition(&self) -> Resource {
11858 Resource {
11859 uri: "cost://nested".to_string(),
11860 name: "cost-ledger-resource".to_string(),
11861 description: None,
11862 mime_type: Some("text/plain".to_string()),
11863 icon: None,
11864 version: None,
11865 tags: vec![],
11866 }
11867 }
11868
11869 fn read(&self, ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11870 ctx.consume_cost(1)
11871 .map_err(|_| McpError::request_cancelled())?;
11872 self.remaining_after_nested_debit.store(
11873 ctx.budget()
11874 .cost_quota
11875 .expect("test request has finite cost quota"),
11876 Ordering::Relaxed,
11877 );
11878 Ok(vec![ResourceContent {
11879 uri: "cost://nested".to_string(),
11880 mime_type: Some("text/plain".to_string()),
11881 text: Some("nested debit".to_string()),
11882 blob: None,
11883 }])
11884 }
11885 }
11886
11887 struct NamedPrompt {
11888 name: String,
11889 tags: Vec<String>,
11890 }
11891
11892 impl NamedPrompt {
11893 fn new(name: &str) -> Self {
11894 Self {
11895 name: name.to_string(),
11896 tags: vec![],
11897 }
11898 }
11899 fn with_tags(name: &str, tags: Vec<String>) -> Self {
11900 Self {
11901 name: name.to_string(),
11902 tags,
11903 }
11904 }
11905 }
11906
11907 impl PromptHandler for NamedPrompt {
11908 fn definition(&self) -> Prompt {
11909 Prompt {
11910 name: self.name.clone(),
11911 description: Some(format!("Prompt {}", self.name)),
11912 arguments: vec![],
11913 icon: None,
11914 version: None,
11915 tags: self.tags.clone(),
11916 }
11917 }
11918 fn get(
11919 &self,
11920 _ctx: &McpContext,
11921 _args: std::collections::HashMap<String, String>,
11922 ) -> McpResult<Vec<PromptMessage>> {
11923 Ok(vec![])
11924 }
11925 }
11926
11927 fn marked_template(uri_template: &str, marker: &str) -> ResourceTemplate {
11928 ResourceTemplate {
11929 uri_template: uri_template.to_string(),
11930 name: marker.to_string(),
11931 description: Some(marker.to_string()),
11932 mime_type: None,
11933 icon: None,
11934 version: None,
11935 tags: vec![marker.to_string()],
11936 }
11937 }
11938
11939 fn marked_router(marker: &str) -> Router {
11940 let mut router = Router::new();
11941 router
11942 .add_tool(NamedTool::with_tags(
11943 "duplicate_tool",
11944 vec![marker.to_string()],
11945 ))
11946 .expect("tool registration succeeds");
11947 router.add_resource(NamedResource::with_tags(
11948 "duplicate://resource",
11949 vec![marker.to_string()],
11950 ));
11951 router
11952 .add_resource_template_with_behavior(
11953 marked_template("mcp://duplicate-item/{item}", marker),
11954 crate::DuplicateBehavior::Replace,
11955 )
11956 .expect("marked resource template is disjoint from the marked exact resource");
11957 router.add_prompt(NamedPrompt::with_tags(
11958 "duplicate_prompt",
11959 vec![marker.to_string()],
11960 ));
11961 router
11962 }
11963
11964 fn assert_router_marker(router: &Router, marker: &str) {
11965 assert_eq!(
11966 router
11967 .get_tool("duplicate_tool")
11968 .expect("tool exists")
11969 .definition()
11970 .tags,
11971 vec![marker.to_string()]
11972 );
11973 assert_eq!(
11974 router
11975 .get_resource("duplicate://resource")
11976 .expect("resource exists")
11977 .definition()
11978 .tags,
11979 vec![marker.to_string()]
11980 );
11981 assert_eq!(
11982 router
11983 .get_resource_template("mcp://duplicate-item/{item}")
11984 .expect("resource template exists")
11985 .tags,
11986 vec![marker.to_string()]
11987 );
11988 assert_eq!(
11989 router
11990 .get_prompt("duplicate_prompt")
11991 .expect("prompt exists")
11992 .definition()
11993 .tags,
11994 vec![marker.to_string()]
11995 );
11996 }
11997
11998 struct BudgetProbeTool {
11999 timeout: Option<Duration>,
12000 delay: Duration,
12001 observed_deadline: Arc<Mutex<Option<Time>>>,
12002 timeout_read: Arc<std::sync::atomic::AtomicBool>,
12003 }
12004
12005 impl ToolHandler for BudgetProbeTool {
12006 fn definition(&self) -> Tool {
12007 Tool {
12008 name: "budget_probe".to_string(),
12009 description: None,
12010 input_schema: serde_json::json!({"type": "object"}),
12011 output_schema: None,
12012 icon: None,
12013 version: None,
12014 tags: vec![],
12015 annotations: None,
12016 }
12017 }
12018
12019 fn timeout(&self) -> Option<Duration> {
12020 self.timeout_read.store(true, Ordering::Relaxed);
12021 self.timeout
12022 }
12023
12024 fn call(&self, ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12025 eprintln!(
12026 "WM-BUDGET call entered deadline={:?}",
12027 ctx.budget().deadline
12028 );
12029 *self
12030 .observed_deadline
12031 .lock()
12032 .unwrap_or_else(std::sync::PoisonError::into_inner) = ctx.budget().deadline;
12033 if !self.delay.is_zero() {
12034 std::thread::sleep(self.delay);
12035 }
12036 Ok(vec![Content::text("completed")])
12037 }
12038 }
12039
12040 struct SlowDefinitionTool {
12041 definition_reads: Arc<AtomicU64>,
12042 called: Arc<std::sync::atomic::AtomicBool>,
12043 }
12044
12045 impl ToolHandler for SlowDefinitionTool {
12046 fn definition(&self) -> Tool {
12047 if self.definition_reads.fetch_add(1, Ordering::Relaxed) > 0 {
12048 eprintln!(
12049 "WM-PROBE requery backtrace:\n{}",
12050 std::backtrace::Backtrace::force_capture()
12051 );
12052 std::thread::sleep(Duration::from_millis(15));
12053 }
12054 Tool {
12055 name: "slow_definition".to_string(),
12056 description: None,
12057 input_schema: serde_json::json!({"type": "object"}),
12058 output_schema: None,
12059 icon: None,
12060 version: None,
12061 tags: vec![],
12062 annotations: None,
12063 }
12064 }
12065
12066 fn timeout(&self) -> Option<Duration> {
12067 Some(Duration::from_millis(1))
12068 }
12069
12070 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12071 self.called.store(true, Ordering::Relaxed);
12072 Ok(vec![Content::text("must not run")])
12073 }
12074 }
12075
12076 const PANIC_CANARY: &str = "Bearer peer-secret\n\u{001b}[31mred\u{001b}[0m\u{001b}]8;;https://invalid\u{0007}link\u{202e}";
12077
12078 struct PanickingDisplay(String);
12079
12080 impl fmt::Display for PanickingDisplay {
12081 fn fmt(&self, _formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
12082 panic!("display-must-not-run: {}", self.0);
12083 }
12084 }
12085
12086 struct UnwindingPanicTool {
12087 payload: String,
12088 non_string: bool,
12089 }
12090
12091 impl ToolHandler for UnwindingPanicTool {
12092 fn definition(&self) -> Tool {
12093 Tool {
12094 name: "panic_tool".to_string(),
12095 description: None,
12096 input_schema: serde_json::json!({"type": "object"}),
12097 output_schema: None,
12098 icon: None,
12099 version: None,
12100 tags: vec![],
12101 annotations: None,
12102 }
12103 }
12104
12105 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12106 unreachable!("the async override is the handler boundary under test")
12107 }
12108
12109 fn call_async<'a>(
12110 &'a self,
12111 _ctx: &'a McpContext,
12112 _arguments: serde_json::Value,
12113 ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
12114 Box::pin(async move {
12115 if self.non_string {
12116 std::panic::panic_any(PanickingDisplay(self.payload.clone()));
12117 }
12118 panic!("{}", self.payload);
12119 })
12120 }
12121 }
12122
12123 struct OutcomePanicTool(String);
12124
12125 impl ToolHandler for OutcomePanicTool {
12126 fn definition(&self) -> Tool {
12127 Tool {
12128 name: "outcome_panic_tool".to_string(),
12129 description: None,
12130 input_schema: serde_json::json!({"type": "object"}),
12131 output_schema: None,
12132 icon: None,
12133 version: None,
12134 tags: vec![],
12135 annotations: None,
12136 }
12137 }
12138
12139 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12140 unreachable!("the async override is the handler boundary under test")
12141 }
12142
12143 fn call_async<'a>(
12144 &'a self,
12145 _ctx: &'a McpContext,
12146 _arguments: serde_json::Value,
12147 ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
12148 Box::pin(async move {
12149 Outcome::Panicked(asupersync::types::PanicPayload::new(self.0.clone()))
12150 })
12151 }
12152 }
12153
12154 struct OpaqueInternalTool;
12155
12156 impl ToolHandler for OpaqueInternalTool {
12157 fn definition(&self) -> Tool {
12158 Tool {
12159 name: "opaque_internal_tool".to_string(),
12160 description: None,
12161 input_schema: serde_json::json!({"type": "object"}),
12162 output_schema: None,
12163 icon: None,
12164 version: None,
12165 tags: vec![],
12166 annotations: None,
12167 }
12168 }
12169
12170 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12171 Err(McpError::with_data(
12172 McpErrorCode::InternalError,
12173 PANIC_CANARY,
12174 serde_json::json!({"secret": PANIC_CANARY}),
12175 ))
12176 }
12177 }
12178
12179 struct OpaqueInternalResource;
12180
12181 impl ResourceHandler for OpaqueInternalResource {
12182 fn definition(&self) -> Resource {
12183 Resource {
12184 uri: "opaque://internal".to_string(),
12185 name: "opaque-internal-resource".to_string(),
12186 description: None,
12187 mime_type: None,
12188 icon: None,
12189 version: None,
12190 tags: vec![],
12191 }
12192 }
12193
12194 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12195 Err(McpError::with_data(
12196 McpErrorCode::InternalError,
12197 PANIC_CANARY,
12198 serde_json::json!({"secret": PANIC_CANARY}),
12199 ))
12200 }
12201 }
12202
12203 struct OpaqueInternalPrompt;
12204
12205 impl PromptHandler for OpaqueInternalPrompt {
12206 fn definition(&self) -> Prompt {
12207 Prompt {
12208 name: "opaque_internal_prompt".to_string(),
12209 description: None,
12210 arguments: vec![],
12211 icon: None,
12212 version: None,
12213 tags: vec![],
12214 }
12215 }
12216
12217 fn get(
12218 &self,
12219 _ctx: &McpContext,
12220 _args: std::collections::HashMap<String, String>,
12221 ) -> McpResult<Vec<PromptMessage>> {
12222 Err(McpError::with_data(
12223 McpErrorCode::InternalError,
12224 PANIC_CANARY,
12225 serde_json::json!({"secret": PANIC_CANARY}),
12226 ))
12227 }
12228 }
12229
12230 struct PanicResource;
12231
12232 impl ResourceHandler for PanicResource {
12233 fn definition(&self) -> Resource {
12234 Resource {
12235 uri: "panic://resource".to_string(),
12236 name: "panic-resource".to_string(),
12237 description: None,
12238 mime_type: None,
12239 icon: None,
12240 version: None,
12241 tags: vec![],
12242 }
12243 }
12244
12245 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12246 panic!("{PANIC_CANARY}")
12247 }
12248 }
12249
12250 struct PanicPrompt;
12251
12252 impl PromptHandler for PanicPrompt {
12253 fn definition(&self) -> Prompt {
12254 Prompt {
12255 name: "panic_prompt".to_string(),
12256 description: None,
12257 arguments: vec![],
12258 icon: None,
12259 version: None,
12260 tags: vec![],
12261 }
12262 }
12263
12264 fn get(
12265 &self,
12266 _ctx: &McpContext,
12267 _args: std::collections::HashMap<String, String>,
12268 ) -> McpResult<Vec<PromptMessage>> {
12269 panic!("{PANIC_CANARY}")
12270 }
12271 }
12272
12273 struct DefinitionPanicTool(std::sync::atomic::AtomicBool);
12274
12275 impl ToolHandler for DefinitionPanicTool {
12276 fn definition(&self) -> Tool {
12277 if self.0.swap(true, Ordering::Relaxed) {
12278 panic!("{PANIC_CANARY}");
12279 }
12280 Tool {
12281 name: "definition_panic_tool".to_string(),
12282 description: None,
12283 input_schema: serde_json::json!({"type": "object"}),
12284 output_schema: None,
12285 icon: None,
12286 version: None,
12287 tags: vec![],
12288 annotations: None,
12289 }
12290 }
12291
12292 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12293 Ok(vec![])
12294 }
12295 }
12296
12297 struct DefinitionPanicResource(std::sync::atomic::AtomicBool);
12298
12299 impl ResourceHandler for DefinitionPanicResource {
12300 fn definition(&self) -> Resource {
12301 if self.0.swap(true, Ordering::Relaxed) {
12302 panic!("{PANIC_CANARY}");
12303 }
12304 Resource {
12305 uri: "panic://definition".to_string(),
12306 name: "definition-panic-resource".to_string(),
12307 description: None,
12308 mime_type: None,
12309 icon: None,
12310 version: None,
12311 tags: vec![],
12312 }
12313 }
12314
12315 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12316 Ok(vec![])
12317 }
12318 }
12319
12320 struct TemplatePanicResource(std::sync::atomic::AtomicBool);
12321
12322 impl ResourceHandler for TemplatePanicResource {
12323 fn definition(&self) -> Resource {
12324 Resource {
12325 uri: "panic-template://placeholder".to_string(),
12326 name: "template-panic-resource".to_string(),
12327 description: None,
12328 mime_type: None,
12329 icon: None,
12330 version: None,
12331 tags: vec![],
12332 }
12333 }
12334
12335 fn template(&self) -> Option<ResourceTemplate> {
12336 if self.0.swap(true, Ordering::Relaxed) {
12337 panic!("{PANIC_CANARY}");
12338 }
12339 Some(ResourceTemplate {
12340 uri_template: "panic-template://{id}".to_string(),
12341 name: "template-panic-resource".to_string(),
12342 description: None,
12343 mime_type: None,
12344 icon: None,
12345 version: None,
12346 tags: vec![],
12347 })
12348 }
12349
12350 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12351 unreachable!("templated reads use read_with_uri")
12352 }
12353
12354 fn read_with_uri(
12355 &self,
12356 _ctx: &McpContext,
12357 uri: &str,
12358 _params: &UriParams,
12359 ) -> McpResult<Vec<ResourceContent>> {
12360 Ok(vec![ResourceContent {
12361 uri: uri.to_string(),
12362 mime_type: None,
12363 text: Some("template-content".to_string()),
12364 blob: None,
12365 }])
12366 }
12367 }
12368
12369 struct DefinitionPanicPrompt(std::sync::atomic::AtomicBool);
12370
12371 impl PromptHandler for DefinitionPanicPrompt {
12372 fn definition(&self) -> Prompt {
12373 if self.0.swap(true, Ordering::Relaxed) {
12374 panic!("{PANIC_CANARY}");
12375 }
12376 Prompt {
12377 name: "definition_panic_prompt".to_string(),
12378 description: None,
12379 arguments: vec![],
12380 icon: None,
12381 version: None,
12382 tags: vec![],
12383 }
12384 }
12385
12386 fn get(
12387 &self,
12388 _ctx: &McpContext,
12389 _args: std::collections::HashMap<String, String>,
12390 ) -> McpResult<Vec<PromptMessage>> {
12391 Ok(vec![])
12392 }
12393 }
12394
12395 #[test]
12398 fn new_router_is_empty() {
12399 let r = Router::new();
12400 assert_eq!(r.tools_count(), 0);
12401 assert_eq!(r.resources_count(), 0);
12402 assert_eq!(r.resource_templates_count(), 0);
12403 assert_eq!(r.prompts_count(), 0);
12404 assert!(r.tools().is_empty());
12405 assert!(r.resources().is_empty());
12406 assert!(r.resource_templates().is_empty());
12407 assert!(r.prompts().is_empty());
12408 }
12409
12410 #[test]
12411 fn default_router_is_empty() {
12412 let r = Router::default();
12413 assert_eq!(r.tools_count(), 0);
12414 }
12415
12416 #[test]
12419 fn add_and_get_tool() {
12420 let mut r = Router::new();
12421 r.add_tool(NamedTool::new("my_tool"))
12422 .expect("tool registration succeeds");
12423 assert_eq!(r.tools_count(), 1);
12424 assert!(r.get_tool("my_tool").is_some());
12425 assert!(r.get_tool("other").is_none());
12426 }
12427
12428 #[test]
12429 fn add_tool_replace_on_duplicate() {
12430 let mut r = Router::new();
12431 r.add_tool(NamedTool::new("t"))
12432 .expect("initial registration succeeds");
12433 r.add_tool(NamedTool::new("t"))
12434 .expect("valid replacement succeeds");
12435 assert_eq!(r.tools_count(), 1);
12436 assert_eq!(r.tools().len(), 1);
12438 }
12439
12440 #[test]
12441 fn tools_returns_definitions_in_order() {
12442 let mut r = Router::new();
12443 r.add_tool(NamedTool::new("b"))
12444 .expect("tool registration succeeds");
12445 r.add_tool(NamedTool::new("a"))
12446 .expect("tool registration succeeds");
12447 let names: Vec<_> = r.tools().iter().map(|t| t.name.clone()).collect();
12448 assert_eq!(names, vec!["b", "a"]); }
12450
12451 #[test]
12452 fn explicit_legacy_tool_is_callable_only_on_exact_2024_routes() {
12453 let mut router = Router::new();
12454 router
12455 .add_legacy_tool(InvalidFinalSchemaNamedTool::with_tags(
12456 "legacy-only-tool",
12457 vec!["legacy".to_owned()],
12458 ))
12459 .expect("explicit legacy registration does not claim final admission");
12460 assert_eq!(
12461 router
12462 .tools()
12463 .into_iter()
12464 .map(|tool| tool.name)
12465 .collect::<Vec<_>>(),
12466 vec!["legacy-only-tool"]
12467 );
12468 assert!(
12469 !router
12470 .server_discovery_behavior_registry()
12471 .contains(ServerBehavior::ToolsList),
12472 "a legacy-only catalog cannot produce a modern discovery claim"
12473 );
12474
12475 let cx = Cx::for_testing();
12476 let state = SessionState::new();
12477 let request_ctx = request_context(&cx, 78, Budget::INFINITE, &state);
12478 let legacy = router
12479 .handle_tools_call(
12480 &request_ctx,
12481 CallToolParams {
12482 name: "legacy-only-tool".to_owned(),
12483 arguments: Some(serde_json::json!({})),
12484 meta: None,
12485 },
12486 state,
12487 None,
12488 None,
12489 )
12490 .expect("exact 2024 dispatch reaches the explicit legacy handler");
12491 assert_eq!(
12492 serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
12493 "called legacy-only-tool"
12494 );
12495
12496 let modern_catalog = router
12497 .dispatch_stateless(
12498 &request_ctx,
12499 &final_tools_list_request(None, None, None, 79_i64),
12500 )
12501 .expect("modern tools/list remains a valid empty catalog");
12502 assert_eq!(modern_catalog["tools"], serde_json::json!([]));
12503 let modern_error = router
12504 .dispatch_stateless(
12505 &request_ctx,
12506 &final_tools_call_request("legacy-only-tool", serde_json::json!({}), 80_i64),
12507 )
12508 .expect_err("modern dispatch cannot resolve an explicit legacy-only tool");
12509 assert_eq!(modern_error.code, McpErrorCode::InvalidParams);
12510 assert_eq!(router.tools_count(), 1);
12511 }
12512
12513 #[test]
12514 fn explicit_legacy_resource_and_prompt_are_inert_on_final_routes() {
12515 let mut router = Router::new();
12516 router.add_legacy_resource(NamedResource::new("file:///legacy-only"));
12517 router.add_legacy_resource_template(marked_template("file:///{name}", "legacy-only"));
12518 router.add_legacy_prompt(NamedPrompt::new("legacy-only-prompt"));
12519 let cx = Cx::for_testing();
12520 let state = SessionState::new();
12521 let request_ctx = request_context(&cx, 181, Budget::INFINITE, &state);
12522
12523 let legacy_resource = router
12524 .handle_resources_read(
12525 &request_ctx,
12526 &ReadResourceParams {
12527 uri: "file:///legacy-only".to_owned(),
12528 meta: None,
12529 },
12530 state.clone(),
12531 None,
12532 None,
12533 )
12534 .expect("the exact legacy resource route retains its explicit registration");
12535 assert_eq!(legacy_resource.contents.len(), 1);
12536 let legacy_prompt = router
12537 .handle_prompts_get(
12538 &request_ctx,
12539 GetPromptParams {
12540 name: "legacy-only-prompt".to_owned(),
12541 arguments: None,
12542 meta: None,
12543 },
12544 state,
12545 None,
12546 None,
12547 )
12548 .expect("the exact legacy prompt route retains its explicit registration");
12549 assert!(legacy_prompt.messages.is_empty());
12550
12551 let modern_resources = router
12552 .dispatch_stateless(
12553 &request_ctx,
12554 &JsonRpcRequest::new(
12555 "resources/list",
12556 Some(serde_json::json!({
12557 "_meta": {
12558 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
12559 "io.modelcontextprotocol/clientCapabilities": {},
12560 },
12561 })),
12562 182_i64,
12563 ),
12564 )
12565 .expect("final discovery remains valid with only legacy resources");
12566 assert_eq!(modern_resources["resources"], serde_json::json!([]));
12567 let modern_templates = router
12568 .dispatch_stateless(
12569 &request_ctx,
12570 &JsonRpcRequest::new(
12571 "resources/templates/list",
12572 Some(serde_json::json!({
12573 "_meta": {
12574 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
12575 "io.modelcontextprotocol/clientCapabilities": {},
12576 },
12577 })),
12578 183_i64,
12579 ),
12580 )
12581 .expect("final template discovery remains valid with only legacy templates");
12582 assert_eq!(modern_templates["resourceTemplates"], serde_json::json!([]));
12583
12584 let modern_prompt = router
12585 .dispatch_stateless(
12586 &request_ctx,
12587 &JsonRpcRequest::new(
12588 "prompts/get",
12589 Some(serde_json::json!({
12590 "_meta": {
12591 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
12592 "io.modelcontextprotocol/clientCapabilities": {},
12593 },
12594 "name": "legacy-only-prompt",
12595 })),
12596 184_i64,
12597 ),
12598 )
12599 .expect_err("changing only the dispatch era refuses the legacy-only prompt");
12600 assert_eq!(modern_prompt.code, McpErrorCode::InvalidParams);
12601 assert!(
12602 !router
12603 .server_discovery_behavior_registry()
12604 .contains(ServerBehavior::ResourcesList)
12605 );
12606 assert!(
12607 !router
12608 .server_discovery_behavior_registry()
12609 .contains(ServerBehavior::PromptsList)
12610 );
12611 }
12612
12613 #[test]
12616 fn duplicate_error_warn_and_ignore_preserve_handler_schema_and_order() {
12617 for behavior in [
12618 crate::DuplicateBehavior::Error,
12619 crate::DuplicateBehavior::Warn,
12620 crate::DuplicateBehavior::Ignore,
12621 ] {
12622 let original_legacy_calls = Arc::new(AtomicUsize::new(0));
12623 let original_final_calls = Arc::new(AtomicUsize::new(0));
12624 let candidate_legacy_calls = Arc::new(AtomicUsize::new(0));
12625 let candidate_final_calls = Arc::new(AtomicUsize::new(0));
12626 let mut router = Router::new();
12627 router
12628 .add_tool(NamedTool::new("before"))
12629 .expect("baseline tool admission succeeds");
12630 router
12631 .add_tool(DuplicateInvariantTool {
12632 label: "original",
12633 schema_property: "original_property",
12634 legacy_calls: Arc::clone(&original_legacy_calls),
12635 final_calls: Arc::clone(&original_final_calls),
12636 })
12637 .expect("original admission succeeds");
12638 router
12639 .add_tool(NamedTool::new("after"))
12640 .expect("trailing tool admission succeeds");
12641
12642 let cx = Cx::for_testing();
12643 let state = SessionState::new();
12644 let request_ctx = request_context(&cx, 81, Budget::INFINITE, &state);
12645 let legacy_before =
12646 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
12647 let modern_before = router
12648 .dispatch_stateless(
12649 &request_ctx,
12650 &final_tools_list_request(None, None, None, 81_i64),
12651 )
12652 .expect("modern catalog is available");
12653
12654 let registration = router.add_tool_with_behavior(
12655 DuplicateInvariantTool {
12656 label: "candidate",
12657 schema_property: "candidate_property",
12658 legacy_calls: Arc::clone(&candidate_legacy_calls),
12659 final_calls: Arc::clone(&candidate_final_calls),
12660 },
12661 behavior,
12662 );
12663 if behavior == crate::DuplicateBehavior::Error {
12664 let error = registration.expect_err("Error rejects the duplicate");
12665 assert!(error.message.contains("already exists"));
12666 } else {
12667 registration.expect("Warn and Ignore retain the original");
12668 }
12669
12670 assert_eq!(
12671 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
12672 legacy_before
12673 );
12674 assert_eq!(
12675 router
12676 .dispatch_stateless(
12677 &request_ctx,
12678 &final_tools_list_request(None, None, None, 82_i64),
12679 )
12680 .expect("modern catalog remains available"),
12681 modern_before
12682 );
12683 assert_eq!(
12684 router
12685 .tools()
12686 .into_iter()
12687 .map(|tool| tool.name)
12688 .collect::<Vec<_>>(),
12689 vec!["before", "duplicate-invariant-tool", "after"]
12690 );
12691
12692 let legacy = router
12693 .handle_tools_call(
12694 &request_ctx,
12695 CallToolParams {
12696 name: "duplicate-invariant-tool".to_owned(),
12697 arguments: Some(serde_json::json!({})),
12698 meta: None,
12699 },
12700 state.clone(),
12701 None,
12702 None,
12703 )
12704 .expect("legacy dispatch retains the original");
12705 assert_eq!(
12706 serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
12707 "original"
12708 );
12709 let modern = router
12710 .dispatch_stateless(
12711 &request_ctx,
12712 &final_tools_call_request(
12713 "duplicate-invariant-tool",
12714 serde_json::json!({}),
12715 83_i64,
12716 ),
12717 )
12718 .expect("modern dispatch retains the original");
12719 assert_eq!(modern["content"][0]["text"], "original");
12720 assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 1);
12721 assert_eq!(original_final_calls.load(Ordering::SeqCst), 1);
12722 assert_eq!(candidate_legacy_calls.load(Ordering::SeqCst), 0);
12723 assert_eq!(candidate_final_calls.load(Ordering::SeqCst), 0);
12724 }
12725 }
12726
12727 #[test]
12728 fn duplicate_replace_atomically_updates_handler_schema_and_retains_order() {
12729 let replacement_legacy_calls = Arc::new(AtomicUsize::new(0));
12730 let replacement_final_calls = Arc::new(AtomicUsize::new(0));
12731 let mut router = Router::new();
12732 router
12733 .add_tool(NamedTool::new("before"))
12734 .expect("baseline admission succeeds");
12735 router
12736 .add_tool(DuplicateInvariantTool {
12737 label: "original",
12738 schema_property: "original_property",
12739 legacy_calls: Arc::new(AtomicUsize::new(0)),
12740 final_calls: Arc::new(AtomicUsize::new(0)),
12741 })
12742 .expect("original admission succeeds");
12743 router
12744 .add_tool(NamedTool::new("after"))
12745 .expect("trailing admission succeeds");
12746 router
12747 .add_tool_with_behavior(
12748 DuplicateInvariantTool {
12749 label: "replacement",
12750 schema_property: "replacement_property",
12751 legacy_calls: Arc::clone(&replacement_legacy_calls),
12752 final_calls: Arc::clone(&replacement_final_calls),
12753 },
12754 crate::DuplicateBehavior::Replace,
12755 )
12756 .expect("Replace admits before committing the candidate");
12757
12758 assert_eq!(
12759 router
12760 .tools()
12761 .into_iter()
12762 .map(|tool| tool.name)
12763 .collect::<Vec<_>>(),
12764 vec!["before", "duplicate-invariant-tool", "after"]
12765 );
12766 let cx = Cx::for_testing();
12767 let state = SessionState::new();
12768 let request_ctx = request_context(&cx, 84, Budget::INFINITE, &state);
12769 let modern_catalog = router
12770 .dispatch_stateless(
12771 &request_ctx,
12772 &final_tools_list_request(None, None, None, 84_i64),
12773 )
12774 .expect("modern replacement catalog is available");
12775 assert_eq!(
12776 modern_catalog["tools"][1]["inputSchema"]["properties"],
12777 serde_json::json!({"replacement_property": {"type": "boolean"}})
12778 );
12779 assert_eq!(
12780 router.tools()[1].description.as_deref(),
12781 Some("replacement")
12782 );
12783
12784 let legacy = router
12785 .handle_tools_call(
12786 &request_ctx,
12787 CallToolParams {
12788 name: "duplicate-invariant-tool".to_owned(),
12789 arguments: Some(serde_json::json!({})),
12790 meta: None,
12791 },
12792 state,
12793 None,
12794 None,
12795 )
12796 .expect("legacy dispatch uses replacement");
12797 assert_eq!(
12798 serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
12799 "replacement"
12800 );
12801 let modern = router
12802 .dispatch_stateless(
12803 &request_ctx,
12804 &final_tools_call_request(
12805 "duplicate-invariant-tool",
12806 serde_json::json!({}),
12807 85_i64,
12808 ),
12809 )
12810 .expect("modern dispatch uses replacement");
12811 assert_eq!(modern["content"][0]["text"], "replacement");
12812 assert_eq!(replacement_legacy_calls.load(Ordering::SeqCst), 1);
12813 assert_eq!(replacement_final_calls.load(Ordering::SeqCst), 1);
12814 }
12815
12816 #[test]
12817 fn add_tool_behavior_new_tool_ok() {
12818 let mut r = Router::new();
12819 r.add_tool_with_behavior(NamedTool::new("t"), crate::DuplicateBehavior::Error)
12820 .unwrap();
12821 assert_eq!(r.tools_count(), 1);
12822 }
12823
12824 #[test]
12827 fn add_and_get_resource() {
12828 let mut r = Router::new();
12829 r.add_resource(NamedResource::new("file:///a.txt"));
12830 assert_eq!(r.resources_count(), 1);
12831 assert!(r.get_resource("file:///a.txt").is_some());
12832 assert!(r.get_resource("file:///b.txt").is_none());
12833 }
12834
12835 #[test]
12836 fn resources_returns_definitions_in_order() {
12837 let mut r = Router::new();
12838 r.add_resource(NamedResource::new("file:///b"));
12839 r.add_resource(NamedResource::new("file:///a"));
12840 let uris: Vec<_> = r.resources().iter().map(|res| res.uri.clone()).collect();
12841 assert_eq!(uris, vec!["file:///b", "file:///a"]);
12842 }
12843
12844 #[test]
12847 fn add_resource_behavior_error_on_duplicate() {
12848 let mut r = Router::new();
12849 r.add_resource(NamedResource::new("file:///a"));
12850 let err = r
12851 .add_resource_with_behavior(
12852 NamedResource::new("file:///a"),
12853 crate::DuplicateBehavior::Error,
12854 )
12855 .unwrap_err();
12856 assert!(err.message.contains("already exists"));
12857 }
12858
12859 #[test]
12860 fn add_resource_behavior_ignore() {
12861 let mut r = Router::new();
12862 r.add_resource(NamedResource::new("file:///a"));
12863 r.add_resource_with_behavior(
12864 NamedResource::new("file:///a"),
12865 crate::DuplicateBehavior::Ignore,
12866 )
12867 .unwrap();
12868 assert_eq!(r.resources_count(), 1);
12869 }
12870
12871 #[test]
12874 fn add_and_get_prompt() {
12875 let mut r = Router::new();
12876 r.add_prompt(NamedPrompt::new("greet"));
12877 assert_eq!(r.prompts_count(), 1);
12878 assert!(r.get_prompt("greet").is_some());
12879 assert!(r.get_prompt("other").is_none());
12880 }
12881
12882 #[test]
12883 fn prompts_returns_definitions_in_order() {
12884 let mut r = Router::new();
12885 r.add_prompt(NamedPrompt::new("z"));
12886 r.add_prompt(NamedPrompt::new("a"));
12887 let names: Vec<_> = r.prompts().iter().map(|p| p.name.clone()).collect();
12888 assert_eq!(names, vec!["z", "a"]);
12889 }
12890
12891 #[test]
12894 fn add_prompt_behavior_error_on_duplicate() {
12895 let mut r = Router::new();
12896 r.add_prompt(NamedPrompt::new("p"));
12897 let err = r
12898 .add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Error)
12899 .unwrap_err();
12900 assert!(err.message.contains("already exists"));
12901 }
12902
12903 #[test]
12904 fn add_prompt_behavior_warn_keeps_original() {
12905 let mut r = Router::new();
12906 r.add_prompt(NamedPrompt::new("p"));
12907 r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Warn)
12908 .unwrap();
12909 assert_eq!(r.prompts_count(), 1);
12910 }
12911
12912 #[test]
12913 fn duplicate_registration_errors_do_not_echo_peer_identifiers() {
12914 let canary = "raw-peer-identifier-canary";
12915
12916 let mut tools = Router::new();
12917 tools
12918 .add_tool(NamedTool::new(canary))
12919 .expect("tool registration succeeds");
12920 let tool_error = tools
12921 .add_tool_with_behavior(NamedTool::new(canary), crate::DuplicateBehavior::Error)
12922 .unwrap_err();
12923
12924 let canary_uri = format!("resource://{canary}");
12927 let mut resources = Router::new();
12928 resources.add_resource(NamedResource::new(&canary_uri));
12929 let resource_error = resources
12930 .add_resource_with_behavior(
12931 NamedResource::new(&canary_uri),
12932 crate::DuplicateBehavior::Error,
12933 )
12934 .unwrap_err();
12935
12936 let mut templates = Router::new();
12937 templates.add_resource_template(marked_template(&canary_uri, "original"));
12938 let template_error = templates
12939 .add_resource_template_with_behavior(
12940 marked_template(&canary_uri, "incoming"),
12941 crate::DuplicateBehavior::Error,
12942 )
12943 .unwrap_err();
12944
12945 let mut prompts = Router::new();
12946 prompts.add_prompt(NamedPrompt::new(canary));
12947 let prompt_error = prompts
12948 .add_prompt_with_behavior(NamedPrompt::new(canary), crate::DuplicateBehavior::Error)
12949 .unwrap_err();
12950
12951 for error in [tool_error, resource_error, template_error, prompt_error] {
12952 assert!(error.message.contains("already exists"));
12953 assert!(!error.message.contains(canary));
12954 assert!(
12955 is_duplicate_registration_error(&error),
12956 "duplicate registration errors must be recognizable without the peer key: {error:?}"
12957 );
12958 }
12959 assert!(
12960 !is_duplicate_registration_error(&McpError::internal_error(
12961 "tool metadata hook panicked during admission"
12962 )),
12963 "a non-duplicate admission failure must not be treated as a colliding catalog skip"
12964 );
12965 assert!(
12966 !is_duplicate_registration_error(&McpError::invalid_request(
12967 "MCP Apps tool metadata requires ServerBuilder::mcp_apps_tool"
12968 )),
12969 "an InvalidRequest that is not a duplicate must still fail prefixed as_proxy_typed"
12970 );
12971 }
12972
12973 #[test]
12976 fn add_resource_template_and_list() {
12977 let mut r = Router::new();
12978 let tmpl = ResourceTemplate {
12979 uri_template: "db://{table}".to_string(),
12980 name: "db".to_string(),
12981 description: None,
12982 mime_type: None,
12983 icon: None,
12984 version: None,
12985 tags: vec![],
12986 };
12987 r.add_resource_template(tmpl);
12988 assert_eq!(r.resource_templates_count(), 1);
12989 assert!(r.get_resource_template("db://{table}").is_some());
12990 assert!(r.get_resource_template("db://{other}").is_none());
12991 }
12992
12993 #[test]
12994 fn add_resource_template_replaces_existing() {
12995 let mut r = Router::new();
12996 let tmpl1 = ResourceTemplate {
12997 uri_template: "db://{table}".to_string(),
12998 name: "db1".to_string(),
12999 description: None,
13000 mime_type: None,
13001 icon: None,
13002 version: None,
13003 tags: vec![],
13004 };
13005 let tmpl2 = ResourceTemplate {
13006 uri_template: "db://{table}".to_string(),
13007 name: "db2".to_string(),
13008 description: None,
13009 mime_type: None,
13010 icon: None,
13011 version: None,
13012 tags: vec![],
13013 };
13014 r.add_resource_template(tmpl1);
13015 r.add_resource_template(tmpl2);
13016 assert_eq!(r.resource_templates_count(), 1);
13017 let tmpl = r.get_resource_template("db://{table}").unwrap();
13018 assert_eq!(tmpl.name, "db2");
13019 }
13020
13021 #[test]
13022 fn add_resource_template_with_behavior_preserves_or_replaces_identity() {
13023 for behavior in [
13024 crate::DuplicateBehavior::Warn,
13025 crate::DuplicateBehavior::Ignore,
13026 crate::DuplicateBehavior::Error,
13027 ] {
13028 let mut router = Router::new();
13029 router.add_resource_template(marked_template("peer://{secret}", "original"));
13030 let result = router.add_resource_template_with_behavior(
13031 marked_template("peer://{secret}", "incoming"),
13032 behavior,
13033 );
13034
13035 if behavior == crate::DuplicateBehavior::Error {
13036 let error = result.expect_err("Error policy rejects the duplicate");
13037 assert!(error.message.contains("already exists"));
13038 assert!(!error.message.contains("peer://{secret}"));
13039 } else {
13040 result.expect("Warn and Ignore keep the original");
13041 }
13042 assert_eq!(router.resource_templates_count(), 1);
13043 assert_eq!(
13044 router
13045 .get_resource_template("peer://{secret}")
13046 .expect("original template remains")
13047 .name,
13048 "original"
13049 );
13050 }
13051
13052 let mut router = Router::new();
13053 router.add_resource_template(marked_template("peer://{secret}", "original"));
13054 router
13055 .add_resource_template_with_behavior(
13056 marked_template("peer://{secret}", "incoming"),
13057 crate::DuplicateBehavior::Replace,
13058 )
13059 .expect("Replace accepts the duplicate");
13060 assert_eq!(router.resource_templates_count(), 1);
13061 assert_eq!(
13062 router
13063 .get_resource_template("peer://{secret}")
13064 .expect("replacement template exists")
13065 .name,
13066 "incoming"
13067 );
13068 }
13069
13070 #[test]
13073 fn resource_exists_for_static_resource() {
13074 let mut r = Router::new();
13075 r.add_resource(NamedResource::new("file:///a.txt"));
13076 assert!(r.resource_exists("file:///a.txt"));
13077 assert!(!r.resource_exists("file:///b.txt"));
13078 }
13079
13080 #[test]
13083 fn strict_input_validation_default_off() {
13084 let r = Router::new();
13085 assert!(!r.strict_input_validation());
13086 }
13087
13088 #[test]
13089 fn set_strict_input_validation() {
13090 let mut r = Router::new();
13091 r.set_strict_input_validation(true);
13092 assert!(r.strict_input_validation());
13093 r.set_strict_input_validation(false);
13094 assert!(!r.strict_input_validation());
13095 }
13096
13097 #[test]
13100 fn set_list_page_size_zero_treated_as_none() {
13101 let mut r = Router::new();
13102 r.set_list_page_size(Some(0));
13103 assert!(r.list_page_size.is_none());
13105 }
13106
13107 #[test]
13108 fn set_list_page_size_positive() {
13109 let mut r = Router::new();
13110 r.set_list_page_size(Some(10));
13111 assert_eq!(r.list_page_size, Some(10));
13112 }
13113
13114 #[test]
13115 fn set_list_page_size_none() {
13116 let mut r = Router::new();
13117 r.set_list_page_size(Some(10));
13118 r.set_list_page_size(None);
13119 assert!(r.list_page_size.is_none());
13120 }
13121
13122 #[test]
13125 fn tools_filtered_no_filters_returns_all() {
13126 let mut r = Router::new();
13127 r.add_tool(NamedTool::new("a"))
13128 .expect("tool registration succeeds");
13129 r.add_tool(NamedTool::new("b"))
13130 .expect("tool registration succeeds");
13131 let tools = r.tools_filtered(None, None);
13132 assert_eq!(tools.len(), 2);
13133 }
13134
13135 #[test]
13136 fn tools_filtered_by_session_state_disables() {
13137 let mut r = Router::new();
13138 r.add_tool(NamedTool::new("a"))
13139 .expect("tool registration succeeds");
13140 r.add_tool(NamedTool::new("b"))
13141 .expect("tool registration succeeds");
13142 let state = SessionState::new();
13143 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
13144 state.set("fastmcp.disabled_tools", &disabled);
13145 let tools = r.tools_filtered(Some(&state), None);
13146 assert_eq!(tools.len(), 1);
13147 assert_eq!(tools[0].name, "b");
13148 }
13149
13150 #[test]
13151 fn tools_filtered_by_tags() {
13152 let mut r = Router::new();
13153 r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
13154 .expect("tool registration succeeds");
13155 r.add_tool(NamedTool::with_tags("b", vec!["web".to_string()]))
13156 .expect("tool registration succeeds");
13157 let include = vec!["db".to_string()];
13158 let filters = TagFilters::new(Some(&include), None);
13159 let tools = r.tools_filtered(None, Some(&filters));
13160 assert_eq!(tools.len(), 1);
13161 assert_eq!(tools[0].name, "a");
13162 }
13163
13164 #[test]
13167 fn resources_filtered_by_session_state() {
13168 let mut r = Router::new();
13169 r.add_resource(NamedResource::new("file:///a"));
13170 r.add_resource(NamedResource::new("file:///b"));
13171 let state = SessionState::new();
13172 let disabled: std::collections::HashSet<String> =
13173 ["file:///a".to_string()].into_iter().collect();
13174 state.set("fastmcp.disabled_resources", &disabled);
13175 let res = r.resources_filtered(Some(&state), None);
13176 assert_eq!(res.len(), 1);
13177 assert_eq!(res[0].uri, "file:///b");
13178 }
13179
13180 #[test]
13183 fn prompts_filtered_by_session_state() {
13184 let mut r = Router::new();
13185 r.add_prompt(NamedPrompt::new("a"));
13186 r.add_prompt(NamedPrompt::new("b"));
13187 let state = SessionState::new();
13188 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
13189 state.set("fastmcp.disabled_prompts", &disabled);
13190 let prompts = r.prompts_filtered(Some(&state), None);
13191 assert_eq!(prompts.len(), 1);
13192 assert_eq!(prompts[0].name, "b");
13193 }
13194
13195 #[test]
13196 fn prompts_filtered_by_tags() {
13197 let mut r = Router::new();
13198 r.add_prompt(NamedPrompt::with_tags("a", vec!["internal".to_string()]));
13199 r.add_prompt(NamedPrompt::with_tags("b", vec!["public".to_string()]));
13200 let exclude = vec!["internal".to_string()];
13201 let filters = TagFilters::new(None, Some(&exclude));
13202 let prompts = r.prompts_filtered(None, Some(&filters));
13203 assert_eq!(prompts.len(), 1);
13204 assert_eq!(prompts[0].name, "b");
13205 }
13206
13207 #[test]
13210 fn resource_templates_filtered_by_session_state() {
13211 let mut r = Router::new();
13212 r.add_resource_template(ResourceTemplate {
13213 uri_template: "db://{table}".to_string(),
13214 name: "db".to_string(),
13215 description: None,
13216 mime_type: None,
13217 icon: None,
13218 version: None,
13219 tags: vec!["admin".to_string()],
13220 });
13221 r.add_resource_template(ResourceTemplate {
13222 uri_template: "cache://{key}".to_string(),
13223 name: "cache".to_string(),
13224 description: None,
13225 mime_type: None,
13226 icon: None,
13227 version: None,
13228 tags: vec![],
13229 });
13230 let state = SessionState::new();
13231 let disabled: std::collections::HashSet<String> =
13232 ["db://{table}".to_string()].into_iter().collect();
13233 state.set("fastmcp.disabled_resources", &disabled);
13234 let tmpls = r.resource_templates_filtered(Some(&state), None);
13235 assert_eq!(tmpls.len(), 1);
13236 assert_eq!(tmpls[0].name, "cache");
13237 }
13238
13239 #[test]
13242 fn apply_prefix_with_prefix() {
13243 assert_eq!(Router::apply_prefix("tool", Some("ns")), "ns/tool");
13244 }
13245
13246 #[test]
13247 fn apply_prefix_no_prefix() {
13248 assert_eq!(Router::apply_prefix("tool", None), "tool");
13249 }
13250
13251 #[test]
13252 fn apply_prefix_empty_prefix() {
13253 assert_eq!(Router::apply_prefix("tool", Some("")), "tool");
13254 }
13255
13256 #[test]
13257 fn validate_prefix_valid() {
13258 assert!(Router::validate_prefix("my-prefix_1").is_ok());
13259 }
13260
13261 #[test]
13262 fn validate_prefix_empty_is_ok() {
13263 assert!(Router::validate_prefix("").is_ok());
13264 }
13265
13266 #[test]
13267 fn validate_prefix_rejects_slashes() {
13268 let err = Router::validate_prefix("a/b").unwrap_err();
13269 assert!(err.contains("slashes"));
13270 }
13271
13272 #[test]
13273 fn validate_prefix_rejects_special_chars() {
13274 let err = Router::validate_prefix("a@b").unwrap_err();
13275 assert!(err.contains("invalid character"));
13276 }
13277
13278 #[test]
13281 fn mount_result_default_has_no_components() {
13282 let r = MountResult::default();
13283 assert!(!r.has_components());
13284 assert!(r.is_success());
13285 }
13286
13287 #[test]
13288 fn mount_result_with_tools_has_components() {
13289 let mut r = MountResult::default();
13290 r.tools = 1;
13291 assert!(r.has_components());
13292 }
13293
13294 #[test]
13295 fn mount_result_debug() {
13296 let r = MountResult::default();
13297 let debug = format!("{:?}", r);
13298 assert!(debug.contains("MountResult"));
13299 }
13300
13301 #[test]
13304 fn mount_tools_with_prefix() {
13305 let mut main = Router::new();
13306 let mut sub = Router::new();
13307 sub.add_tool(NamedTool::new("query"))
13308 .expect("tool registration succeeds");
13309 let result = main.mount(sub, Some("db"));
13310 assert_eq!(result.tools, 1);
13311 assert!(main.get_tool("db/query").is_some());
13312 assert!(main.get_tool("query").is_none());
13313 }
13314
13315 #[test]
13316 fn mount_without_prefix() {
13317 let mut main = Router::new();
13318 let mut sub = Router::new();
13319 sub.add_tool(NamedTool::new("query"))
13320 .expect("tool registration succeeds");
13321 let result = main.mount(sub, None);
13322 assert_eq!(result.tools, 1);
13323 assert!(main.get_tool("query").is_some());
13324 }
13325
13326 struct RecordingNameCompletion {
13327 names: Arc<Mutex<Vec<String>>>,
13328 }
13329
13330 impl CompletionHandler for RecordingNameCompletion {
13331 fn complete_legacy(
13332 &self,
13333 _ctx: &McpContext,
13334 params: LegacyCompletionParams,
13335 ) -> McpResult<CompletionValues> {
13336 let name = match params.reference {
13337 fastmcp_protocol::LegacyCompletionReference::Prompt { name } => name,
13338 fastmcp_protocol::LegacyCompletionReference::Resource { uri } => uri,
13339 };
13340 self.names
13341 .lock()
13342 .unwrap_or_else(std::sync::PoisonError::into_inner)
13343 .push(name);
13344 Ok(CompletionValues {
13345 values: vec![format!("{}ging", params.argument.value)],
13346 total: Some(1),
13347 has_more: Some(false),
13348 })
13349 }
13350
13351 fn complete_final(
13352 &self,
13353 _ctx: &McpContext,
13354 params: FinalCompletionParams,
13355 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
13356 let name = match params.reference {
13357 fastmcp_protocol::FinalCompletionReference::Prompt { name }
13358 | fastmcp_protocol::FinalCompletionReference::PromptWithTitle { name, .. } => name,
13359 fastmcp_protocol::FinalCompletionReference::Resource { uri } => uri,
13360 };
13361 self.names
13362 .lock()
13363 .unwrap_or_else(std::sync::PoisonError::into_inner)
13364 .push(name);
13365 Ok(fastmcp_protocol::FinalCompletionValues {
13366 values: vec![format!("{}ging", params.argument.value)],
13367 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
13368 has_more: Some(false),
13369 })
13370 }
13371 }
13372
13373 #[test]
13374 fn mount_transfers_prompt_completion_providers_and_rewrites_prefixed_names() {
13375 let seen = Arc::new(Mutex::new(Vec::new()));
13376 let mut child = Router::new();
13377 child.add_prompt(PromptArgumentBoundary {
13378 final_calls: Arc::new(AtomicUsize::new(0)),
13379 legacy_calls: Arc::new(AtomicUsize::new(0)),
13380 });
13381 child.add_legacy_prompt_completion_handler(
13382 "prompt-argument-boundary",
13383 RecordingNameCompletion {
13384 names: Arc::clone(&seen),
13385 },
13386 );
13387 child.add_prompt_completion_handler(
13388 "prompt-argument-boundary",
13389 RecordingNameCompletion {
13390 names: Arc::clone(&seen),
13391 },
13392 );
13393
13394 let mut main = Router::new();
13395 let result = main.mount(child, Some("ns"));
13396 assert!(result.is_success());
13397 assert_eq!(result.prompts, 1);
13398 assert!(main.has_completion_handler());
13399
13400 let cx = Cx::for_testing();
13401 let state = SessionState::new();
13402 let request_ctx = request_context(&cx, 401, Budget::INFINITE, &state);
13403 let legacy = main
13404 .dispatch_legacy_completion(
13405 &request_ctx,
13406 &JsonRpcRequest::new(
13407 COMPLETION_COMPLETE,
13408 Some(serde_json::json!({
13409 "ref": {"type": "ref/prompt", "name": "ns/prompt-argument-boundary"},
13410 "argument": {"name": "topic", "value": "sta"},
13411 })),
13412 401_i64,
13413 ),
13414 )
13415 .expect("prefixed legacy completion must reach the mounted provider");
13416 assert_eq!(
13417 legacy["completion"]["values"],
13418 serde_json::json!(["staging"])
13419 );
13420
13421 let unprefixed = main.dispatch_legacy_completion(
13422 &request_ctx,
13423 &JsonRpcRequest::new(
13424 COMPLETION_COMPLETE,
13425 Some(serde_json::json!({
13426 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
13427 "argument": {"name": "topic", "value": "sta"},
13428 })),
13429 402_i64,
13430 ),
13431 );
13432 assert!(
13433 unprefixed.is_err(),
13434 "the child's original completion name must not remain after a prefixed mount"
13435 );
13436
13437 let seen = seen
13438 .lock()
13439 .unwrap_or_else(std::sync::PoisonError::into_inner);
13440 assert_eq!(
13441 seen.as_slice(),
13442 ["prompt-argument-boundary"],
13443 "the mounted provider must observe the child's original prompt name"
13444 );
13445 }
13446
13447 #[test]
13448 fn mount_without_prefix_keeps_completion_provider_keys() {
13449 let mut child = Router::new();
13450 child.add_prompt(PromptArgumentBoundary {
13451 final_calls: Arc::new(AtomicUsize::new(0)),
13452 legacy_calls: Arc::new(AtomicUsize::new(0)),
13453 });
13454 child.add_legacy_prompt_completion_handler("prompt-argument-boundary", EchoCompletion);
13455 let mut main = Router::new();
13456 assert!(main.mount(child, None).is_success());
13457 let cx = Cx::for_testing();
13458 let state = SessionState::new();
13459 let request_ctx = request_context(&cx, 403, Budget::INFINITE, &state);
13460 let legacy = main
13461 .dispatch_legacy_completion(
13462 &request_ctx,
13463 &JsonRpcRequest::new(
13464 COMPLETION_COMPLETE,
13465 Some(serde_json::json!({
13466 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
13467 "argument": {"name": "topic", "value": "sta"},
13468 })),
13469 403_i64,
13470 ),
13471 )
13472 .expect("unprefixed mount keeps the original completion key");
13473 assert_eq!(
13474 legacy["completion"]["values"],
13475 serde_json::json!(["staging"])
13476 );
13477 }
13478
13479 #[test]
13480 fn mount_transfers_resource_template_completion_providers_and_rewrites_prefixed_uris() {
13481 let seen = Arc::new(Mutex::new(Vec::new()));
13482 let mut child = Router::new();
13483 child
13484 .add_resource_template_with_behavior(
13485 marked_template("mcp://books/{id}", "books"),
13486 crate::DuplicateBehavior::Replace,
13487 )
13488 .expect("child resource template is admitted");
13489 child.add_legacy_resource_template_completion_handler(
13490 "mcp://books/{id}",
13491 RecordingNameCompletion {
13492 names: Arc::clone(&seen),
13493 },
13494 );
13495 child.add_resource_template_completion_handler(
13496 "mcp://books/{id}",
13497 RecordingNameCompletion {
13498 names: Arc::clone(&seen),
13499 },
13500 );
13501
13502 let mut main = Router::new();
13503 let result = main.mount(child, Some("ns"));
13504 assert!(result.is_success());
13505 assert_eq!(result.resource_templates, 1);
13506 assert!(main.has_completion_handler());
13507
13508 let cx = Cx::for_testing();
13509 let state = SessionState::new();
13510 let request_ctx = request_context(&cx, 411, Budget::INFINITE, &state);
13511 let legacy = main
13512 .dispatch_legacy_completion(
13513 &request_ctx,
13514 &JsonRpcRequest::new(
13515 COMPLETION_COMPLETE,
13516 Some(serde_json::json!({
13517 "ref": {"type": "ref/resource", "uri": "ns/mcp://books/{id}"},
13518 "argument": {"name": "id", "value": "sta"},
13519 })),
13520 411_i64,
13521 ),
13522 )
13523 .expect("prefixed legacy template completion must reach the mounted provider");
13524 assert_eq!(
13525 legacy["completion"]["values"],
13526 serde_json::json!(["staging"])
13527 );
13528
13529 let unprefixed = main.dispatch_legacy_completion(
13530 &request_ctx,
13531 &JsonRpcRequest::new(
13532 COMPLETION_COMPLETE,
13533 Some(serde_json::json!({
13534 "ref": {"type": "ref/resource", "uri": "mcp://books/{id}"},
13535 "argument": {"name": "id", "value": "sta"},
13536 })),
13537 412_i64,
13538 ),
13539 );
13540 assert!(
13541 unprefixed.is_err(),
13542 "the child's original template completion URI must not remain after a prefixed mount"
13543 );
13544
13545 let seen = seen
13546 .lock()
13547 .unwrap_or_else(std::sync::PoisonError::into_inner);
13548 assert_eq!(
13549 seen.as_slice(),
13550 ["mcp://books/{id}"],
13551 "the mounted provider must observe the child's original template URI"
13552 );
13553 }
13554
13555 #[test]
13556 fn prefixed_mount_strips_prefix_for_server_wide_completion_fallback() {
13557 let seen = Arc::new(Mutex::new(Vec::new()));
13558 let mut child = Router::new();
13559 child.add_prompt(PromptArgumentBoundary {
13560 final_calls: Arc::new(AtomicUsize::new(0)),
13561 legacy_calls: Arc::new(AtomicUsize::new(0)),
13562 });
13563 child.add_completion_handler(RecordingNameCompletion {
13564 names: Arc::clone(&seen),
13565 });
13566
13567 let mut main = Router::new();
13568 assert!(main.mount(child, Some("ns")).is_success());
13569
13570 let cx = Cx::for_testing();
13571 let state = SessionState::new();
13572 let request_ctx = request_context(&cx, 414, Budget::INFINITE, &state);
13573 let legacy = main
13574 .dispatch_legacy_completion(
13575 &request_ctx,
13576 &JsonRpcRequest::new(
13577 COMPLETION_COMPLETE,
13578 Some(serde_json::json!({
13579 "ref": {"type": "ref/prompt", "name": "ns/prompt-argument-boundary"},
13580 "argument": {"name": "topic", "value": "sta"},
13581 })),
13582 414_i64,
13583 ),
13584 )
13585 .expect("prefixed fallback completion must reach the child's server-wide handler");
13586 assert_eq!(
13587 legacy["completion"]["values"],
13588 serde_json::json!(["staging"])
13589 );
13590
13591 let seen = seen
13592 .lock()
13593 .unwrap_or_else(std::sync::PoisonError::into_inner);
13594 assert_eq!(
13595 seen.as_slice(),
13596 ["prompt-argument-boundary"],
13597 "the child's server-wide handler must observe the original prompt name"
13598 );
13599 }
13600
13601 #[test]
13602 fn mount_prompts_does_not_replace_destination_fallback_completion_handler() {
13603 let mut dest = Router::new();
13604 dest.add_prompt(NamedPrompt::new("kept"));
13605 dest.add_completion_handler(CountingCompletion {
13606 final_calls: Arc::new(AtomicUsize::new(0)),
13607 });
13608 let mut child = Router::new();
13609 child.add_prompt(NamedPrompt::new("incoming"));
13610 child.add_completion_handler(EchoCompletion);
13611
13612 assert!(
13613 dest.mount_prompts_with_behavior(child, None, crate::DuplicateBehavior::Replace)
13614 .is_success()
13615 );
13616
13617 let cx = Cx::for_testing();
13618 let state = SessionState::new();
13619 let request_ctx = request_context(&cx, 413, Budget::INFINITE, &state);
13620 let kept = dest
13621 .dispatch_legacy_completion(
13622 &request_ctx,
13623 &JsonRpcRequest::new(
13624 COMPLETION_COMPLETE,
13625 Some(serde_json::json!({
13626 "ref": {"type": "ref/prompt", "name": "kept"},
13627 "argument": {"name": "topic", "value": "sta"},
13628 })),
13629 413_i64,
13630 ),
13631 )
13632 .expect("prompts-only mount must keep the destination fallback completion handler");
13633 assert_eq!(kept["completion"]["values"], serde_json::json!(["legacy"]));
13634 }
13635
13636 #[test]
13637 fn mount_resources_with_prefix() {
13638 let mut main = Router::new();
13639 let mut sub = Router::new();
13640 sub.add_resource(NamedResource::new("file:///a"));
13641 let result = main.mount(sub, Some("ns"));
13642 assert_eq!(result.resources, 1);
13643 assert!(main.get_resource("ns/file:///a").is_some());
13644
13645 let cx = Cx::for_testing();
13646 let state = SessionState::new();
13647 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
13648 let read = main
13649 .handle_resources_read(
13650 &request_ctx,
13651 &ReadResourceParams {
13652 uri: "ns/file:///a".to_string(),
13653 meta: None,
13654 },
13655 state,
13656 None,
13657 None,
13658 )
13659 .expect("mounted resource is readable through its public URI");
13660 let [
13661 LegacyResourceContent::Text {
13662 uri,
13663 text,
13664 additional,
13665 ..
13666 },
13667 ] = read.contents.as_slice()
13668 else {
13669 panic!("mounted resource must retain the exact legacy text shape");
13670 };
13671 assert_eq!(uri, "ns/file:///a");
13672 assert_eq!(text, "content");
13673 assert!(additional.is_empty());
13674 }
13675
13676 #[test]
13677 fn mount_namespaced_prefixes_tools_and_keeps_resource_uris() {
13678 let mut main = Router::new();
13679 let mut sub = Router::new();
13680 sub.add_tool(NamedTool::new("query"))
13681 .expect("namespaced query tool registers");
13682 sub.add_resource(NamedResource::new("file:///a"));
13683 let result =
13684 main.mount_namespaced_with_behavior(sub, Some("ns"), crate::DuplicateBehavior::Replace);
13685 assert!(result.is_success());
13686 assert_eq!(result.tools, 1);
13687 assert_eq!(result.resources, 1);
13688 assert!(main.get_tool("ns/query").is_some());
13689 assert!(main.get_tool("query").is_none());
13690 assert!(main.get_resource("file:///a").is_some());
13691 assert!(main.get_resource("ns/file:///a").is_none());
13692 assert!(main.final_resources.contains_key("file:///a"));
13693 }
13694
13695 #[test]
13696 fn mounting_resource_does_not_requery_one_shot_definition() {
13697 let mut source = Router::new();
13698 source.add_resource(DefinitionPanicResource(std::sync::atomic::AtomicBool::new(
13699 false,
13700 )));
13701 let mut destination = Router::new();
13702
13703 let result = destination.mount_resources(source, Some("ns"));
13704
13705 assert!(result.is_success());
13706 assert_eq!(result.resources, 1);
13707 assert!(destination.get_resource("ns/panic://definition").is_some());
13708 }
13709
13710 #[test]
13711 fn mounting_resource_template_does_not_requery_one_shot_template() {
13712 let mut source = Router::new();
13713 source.add_resource(TemplatePanicResource(std::sync::atomic::AtomicBool::new(
13714 false,
13715 )));
13716 let mut destination = Router::new();
13717
13718 let result = destination.mount_resources(source, Some("ns"));
13719
13720 assert!(result.is_success());
13721 assert_eq!(result.resource_templates, 1);
13722 assert!(
13723 destination
13724 .get_resource_template("ns/panic-template://{id}")
13725 .is_some()
13726 );
13727 }
13728
13729 #[test]
13730 fn prefixed_resource_template_mount_is_legacy_only_after_final_source_dispatch() {
13731 struct FinalTemplateResource;
13732
13733 impl ResourceHandler for FinalTemplateResource {
13734 fn definition(&self) -> Resource {
13735 Resource {
13736 uri: "mcp://mounted/template".to_owned(),
13737 name: "mounted-template".to_owned(),
13738 description: None,
13739 mime_type: Some("text/plain".to_owned()),
13740 icon: None,
13741 version: None,
13742 tags: Vec::new(),
13743 }
13744 }
13745
13746 fn template(&self) -> Option<ResourceTemplate> {
13747 Some(marked_template("mcp://mounted/{id}", "mounted-template"))
13748 }
13749
13750 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
13751 unreachable!("templated reads receive their URI parameters")
13752 }
13753
13754 fn read_with_uri(
13755 &self,
13756 _ctx: &McpContext,
13757 uri: &str,
13758 _params: &UriParams,
13759 ) -> McpResult<Vec<ResourceContent>> {
13760 Ok(vec![ResourceContent {
13761 uri: uri.to_owned(),
13762 mime_type: Some("text/plain".to_owned()),
13763 text: Some("source-template".to_owned()),
13764 blob: None,
13765 }])
13766 }
13767 }
13768
13769 let cx = Cx::for_testing();
13770 let state = SessionState::new();
13771 let request_ctx = request_context(&cx, 191, Budget::INFINITE, &state);
13772 let final_metadata = serde_json::json!({
13773 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
13774 "io.modelcontextprotocol/clientCapabilities": {},
13775 });
13776 let mut source = Router::new();
13777 source.add_resource(FinalTemplateResource);
13778
13779 let source_final_read = source
13780 .dispatch_stateless(
13781 &request_ctx,
13782 &JsonRpcRequest::new(
13783 "resources/read",
13784 Some(serde_json::json!({
13785 "_meta": final_metadata.clone(),
13786 "uri": "mcp://mounted/item",
13787 })),
13788 191_i64,
13789 ),
13790 )
13791 .expect("the unprefixed final template dispatches");
13792 assert_eq!(source_final_read["contents"][0]["text"], "source-template");
13793
13794 let mut destination = Router::new();
13795 let mounted = destination.mount_resources(source, Some("peer"));
13796 assert!(mounted.is_success());
13797 assert_eq!(mounted.resource_templates, 1);
13798
13799 let legacy_templates = destination
13800 .handle_resource_templates_list(
13801 &request_ctx,
13802 ListResourceTemplatesParams::default(),
13803 None,
13804 )
13805 .expect("the prefixed template remains in legacy discovery");
13806 assert_eq!(
13807 legacy_templates.resource_templates[0].uri_template,
13808 "peer/mcp://mounted/{id}"
13809 );
13810 let legacy_read = destination
13811 .handle_resources_read(
13812 &request_ctx,
13813 &ReadResourceParams {
13814 uri: "peer/mcp://mounted/item".to_owned(),
13815 meta: None,
13816 },
13817 state.clone(),
13818 None,
13819 None,
13820 )
13821 .expect("the prefixed template remains readable on the legacy surface");
13822 assert_eq!(
13823 serde_json::to_value(legacy_read).expect("legacy result serializes")["contents"][0]["text"],
13824 "source-template"
13825 );
13826
13827 let final_templates = destination
13828 .dispatch_stateless(
13829 &request_ctx,
13830 &JsonRpcRequest::new(
13831 "resources/templates/list",
13832 Some(serde_json::json!({"_meta": final_metadata.clone()})),
13833 192_i64,
13834 ),
13835 )
13836 .expect("prefixed legacy-only templates do not break final discovery");
13837 assert_eq!(final_templates["resourceTemplates"], serde_json::json!([]));
13838
13839 let final_error = destination
13840 .dispatch_stateless(
13841 &request_ctx,
13842 &JsonRpcRequest::new(
13843 "resources/read",
13844 Some(serde_json::json!({
13845 "_meta": final_metadata,
13846 "uri": "peer/mcp://mounted/item",
13847 })),
13848 193_i64,
13849 ),
13850 )
13851 .expect_err("the relative mounted namespace is not exposed to final dispatch");
13852 assert_eq!(final_error.code, McpErrorCode::InvalidParams);
13853 }
13854
13855 #[test]
13856 fn nested_resource_mounts_translate_both_namespace_layers() {
13857 let mut leaf = Router::new();
13858 leaf.add_resource(NamedResource::new("file:///a"));
13859
13860 let mut middle = Router::new();
13861 assert!(middle.mount(leaf, Some("ns")).is_success());
13862 let mut outer = Router::new();
13863 assert!(outer.mount(middle, Some("ns")).is_success());
13864
13865 let cx = Cx::for_testing();
13866 let state = SessionState::new();
13867 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
13868 let read = outer
13869 .handle_resources_read(
13870 &request_ctx,
13871 &ReadResourceParams {
13872 uri: "ns/ns/file:///a".to_string(),
13873 meta: None,
13874 },
13875 state,
13876 None,
13877 None,
13878 )
13879 .expect("nested mounted resource is readable");
13880 let [
13881 LegacyResourceContent::Text {
13882 uri,
13883 text,
13884 additional,
13885 ..
13886 },
13887 ] = read.contents.as_slice()
13888 else {
13889 panic!("nested mounted resource must retain the exact legacy text shape");
13890 };
13891 assert_eq!(uri, "ns/ns/file:///a");
13892 assert_eq!(text, "content");
13893 assert!(additional.is_empty());
13894 }
13895
13896 #[test]
13897 fn nested_resource_template_mounts_translate_every_namespace_layer() {
13898 struct TemplatedResource;
13899
13900 impl ResourceHandler for TemplatedResource {
13901 fn definition(&self) -> Resource {
13902 Resource {
13903 uri: "db://placeholder".to_string(),
13904 name: "database".to_string(),
13905 description: None,
13906 mime_type: Some("text/plain".to_string()),
13907 icon: None,
13908 version: None,
13909 tags: vec![],
13910 }
13911 }
13912
13913 fn template(&self) -> Option<ResourceTemplate> {
13914 Some(ResourceTemplate {
13915 uri_template: "db://{table}".to_string(),
13916 name: "database".to_string(),
13917 description: None,
13918 mime_type: Some("text/plain".to_string()),
13919 icon: None,
13920 version: None,
13921 tags: vec![],
13922 })
13923 }
13924
13925 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
13926 unreachable!("templated reads use read_with_uri")
13927 }
13928
13929 fn read_with_uri(
13930 &self,
13931 _ctx: &McpContext,
13932 uri: &str,
13933 params: &UriParams,
13934 ) -> McpResult<Vec<ResourceContent>> {
13935 Ok(vec![ResourceContent {
13936 uri: uri.to_string(),
13937 mime_type: Some("text/plain".to_string()),
13938 text: params.get("table").cloned(),
13939 blob: None,
13940 }])
13941 }
13942 }
13943
13944 let mut source = Router::new();
13945 source.add_resource(TemplatedResource);
13946 let mut middle = Router::new();
13947 assert!(middle.mount_resources(source, Some("peer")).is_success());
13948 let mut mounted = Router::new();
13949 assert!(mounted.mount_resources(middle, Some("peer")).is_success());
13950
13951 let cx = Cx::for_testing();
13952 let state = SessionState::new();
13953 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
13954 let read = mounted
13955 .handle_resources_read(
13956 &request_ctx,
13957 &ReadResourceParams {
13958 uri: "peer/peer/db://users".to_string(),
13959 meta: None,
13960 },
13961 state,
13962 None,
13963 None,
13964 )
13965 .expect("mounted template is readable through its public URI");
13966 let [
13967 LegacyResourceContent::Text {
13968 uri,
13969 text,
13970 additional,
13971 ..
13972 },
13973 ] = read.contents.as_slice()
13974 else {
13975 panic!("mounted template must retain the exact legacy text shape");
13976 };
13977 assert_eq!(uri, "peer/peer/db://users");
13978 assert_eq!(text, "users");
13979 assert!(additional.is_empty());
13980 }
13981
13982 #[test]
13983 fn handle_resources_read_resolves_true_async_mounted_template_and_translates_uri() {
13984 struct AsyncTemplatedResource {
13985 observed: Arc<Mutex<Option<(String, String)>>>,
13986 }
13987
13988 impl ResourceHandler for AsyncTemplatedResource {
13989 fn definition(&self) -> Resource {
13990 Resource {
13991 uri: "async-db://placeholder".to_string(),
13992 name: "async-database".to_string(),
13993 description: None,
13994 mime_type: Some("text/plain".to_string()),
13995 icon: None,
13996 version: None,
13997 tags: vec![],
13998 }
13999 }
14000
14001 fn template(&self) -> Option<ResourceTemplate> {
14002 Some(ResourceTemplate {
14003 uri_template: "async-db://{table}".to_string(),
14004 name: "async-database".to_string(),
14005 description: None,
14006 mime_type: Some("text/plain".to_string()),
14007 icon: None,
14008 version: None,
14009 tags: vec![],
14010 })
14011 }
14012
14013 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
14014 unreachable!("router must dispatch templated resources through the async override")
14015 }
14016
14017 fn read_with_uri(
14018 &self,
14019 _ctx: &McpContext,
14020 _uri: &str,
14021 _params: &UriParams,
14022 ) -> McpResult<Vec<ResourceContent>> {
14023 unreachable!("router must not fall back to the synchronous templated read")
14024 }
14025
14026 fn read_async_with_uri<'a>(
14027 &'a self,
14028 _ctx: &'a McpContext,
14029 uri: &'a str,
14030 params: &'a UriParams,
14031 ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
14032 Box::pin(async move {
14033 let mut first_poll = true;
14034 std::future::poll_fn(move |waker| {
14035 if std::mem::take(&mut first_poll) {
14036 waker.waker().wake_by_ref();
14037 std::task::Poll::Pending
14038 } else {
14039 std::task::Poll::Ready(())
14040 }
14041 })
14042 .await;
14043 let Some(table) = params.get("table").cloned() else {
14044 return Outcome::Err(McpError::invalid_params(
14045 "mounted template did not resolve its table parameter",
14046 ));
14047 };
14048 *self.observed.lock().expect("observation mutex poisoned") =
14049 Some((uri.to_string(), table.clone()));
14050 Outcome::Ok(vec![ResourceContent {
14051 uri: uri.to_string(),
14052 mime_type: Some("text/plain".to_string()),
14053 text: Some(format!("async table {table}")),
14054 blob: None,
14055 }])
14056 })
14057 }
14058 }
14059
14060 let observed = Arc::new(Mutex::new(None));
14061 let mut source = Router::new();
14062 source.add_resource(AsyncTemplatedResource {
14063 observed: Arc::clone(&observed),
14064 });
14065 let mut mounted = Router::new();
14066 let mount_result = mounted.mount_resources(source, Some("peer"));
14067 assert!(mount_result.is_success());
14068 assert_eq!(mount_result.resource_templates, 1);
14069
14070 let cx = Cx::for_testing();
14071 let state = SessionState::new();
14072 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14073 let read = mounted
14074 .handle_resources_read(
14075 &request_ctx,
14076 &ReadResourceParams {
14077 uri: "peer/async-db://users".to_string(),
14078 meta: None,
14079 },
14080 state,
14081 None,
14082 None,
14083 )
14084 .expect("true-async mounted template is readable through its public URI");
14085
14086 assert_eq!(read.contents.len(), 1);
14087 let [
14088 LegacyResourceContent::Text {
14089 uri,
14090 text,
14091 additional,
14092 ..
14093 },
14094 ] = read.contents.as_slice()
14095 else {
14096 panic!("async mounted template must retain the exact legacy text shape");
14097 };
14098 assert_eq!(uri, "peer/async-db://users");
14099 assert_eq!(text, "async table users");
14100 assert!(additional.is_empty());
14101 assert_eq!(
14102 *observed.lock().expect("observation mutex poisoned"),
14103 Some(("async-db://users".to_string(), "users".to_string()))
14104 );
14105 }
14106
14107 #[test]
14108 fn mount_prompts_with_prefix() {
14109 let mut main = Router::new();
14110 let mut sub = Router::new();
14111 sub.add_prompt(NamedPrompt::new("greet"));
14112 let result = main.mount(sub, Some("ns"));
14113 assert_eq!(result.prompts, 1);
14114 assert!(main.get_prompt("ns/greet").is_some());
14115 }
14116
14117 #[test]
14118 fn mount_warns_on_conflict() {
14119 let mut main = Router::new();
14120 main.add_tool(NamedTool::new("t"))
14121 .expect("tool registration succeeds");
14122 let mut sub = Router::new();
14123 sub.add_tool(NamedTool::new("t"))
14124 .expect("tool registration succeeds");
14125 let result = main.mount(sub, None);
14126 assert_eq!(result.tools, 1);
14127 assert!(!result.warnings.is_empty());
14128 assert!(result.warnings[0].contains("already exists"));
14129 }
14130
14131 #[test]
14132 fn mount_rejects_invalid_prefix_without_mutating() {
14133 let mut main = Router::new();
14134 main.add_tool(NamedTool::new("original"))
14135 .expect("tool registration succeeds");
14136 let mut sub = Router::new();
14137 sub.add_tool(NamedTool::new("incoming"))
14138 .expect("tool registration succeeds");
14139 let result = main.mount(sub, Some("bad/prefix"));
14140 assert!(!result.is_success());
14141 assert_eq!(result.tools, 0);
14142 assert!(!result.warnings.is_empty());
14143 assert!(result.warnings[0].contains("slashes"));
14144 assert!(!result.errors[0].contains("bad/prefix"));
14145 assert_eq!(main.tools_count(), 1);
14146 assert!(main.get_tool("original").is_some());
14147 assert!(main.get_tool("bad/prefix/incoming").is_none());
14148 }
14149
14150 #[test]
14151 fn mount_with_behavior_honors_policy_for_every_component_kind() {
14152 for behavior in [
14153 crate::DuplicateBehavior::Warn,
14154 crate::DuplicateBehavior::Ignore,
14155 crate::DuplicateBehavior::Replace,
14156 crate::DuplicateBehavior::Error,
14157 ] {
14158 let mut main = marked_router("original");
14159 let mut sub = marked_router("incoming");
14160 if behavior == crate::DuplicateBehavior::Error {
14161 sub.add_tool(NamedTool::new("unique_tool"))
14162 .expect("tool registration succeeds");
14163 }
14164
14165 let result = main.mount_with_behavior(sub, None, behavior);
14166 let replaced = behavior == crate::DuplicateBehavior::Replace;
14167 let rejected = behavior == crate::DuplicateBehavior::Error;
14168 let expected_mounted = usize::from(replaced);
14169
14170 assert_eq!(result.is_success(), !rejected);
14171 assert_eq!(result.tools, expected_mounted);
14172 assert_eq!(result.resources, expected_mounted);
14173 assert_eq!(result.resource_templates, expected_mounted);
14174 assert_eq!(result.prompts, expected_mounted);
14175 assert_eq!(result.errors.len(), if rejected { 4 } else { 0 });
14176 assert_eq!(
14177 result.warnings.len(),
14178 if matches!(
14179 behavior,
14180 crate::DuplicateBehavior::Warn | crate::DuplicateBehavior::Replace
14181 ) {
14182 4
14183 } else {
14184 0
14185 }
14186 );
14187 assert_router_marker(&main, if replaced { "incoming" } else { "original" });
14188 assert!(main.get_tool("unique_tool").is_none());
14189
14190 for message in result.warnings.iter().chain(&result.errors) {
14191 assert!(!message.contains("duplicate_tool"));
14192 assert!(!message.contains("duplicate://resource"));
14193 assert!(!message.contains("mcp://duplicate-item/{item}"));
14194 assert!(!message.contains("duplicate_prompt"));
14195 }
14196 }
14197 }
14198
14199 #[test]
14200 fn behavior_aware_partial_mounts_preflight_error_atomically() {
14201 let mut tools = Router::new();
14202 tools
14203 .add_tool(NamedTool::with_tags("same", vec!["original".to_string()]))
14204 .expect("tool registration succeeds");
14205 let mut tool_source = Router::new();
14206 tool_source
14207 .add_tool(NamedTool::with_tags("same", vec!["incoming".to_string()]))
14208 .expect("tool registration succeeds");
14209 tool_source
14210 .add_tool(NamedTool::new("unique"))
14211 .expect("tool registration succeeds");
14212 let tool_result =
14213 tools.mount_tools_with_behavior(tool_source, None, crate::DuplicateBehavior::Error);
14214 assert!(!tool_result.is_success());
14215 assert_eq!(tool_result.tools, 0);
14216 assert_eq!(tools.tools_count(), 1);
14217 assert_eq!(
14218 tools.get_tool("same").unwrap().definition().tags,
14219 vec!["original".to_string()]
14220 );
14221 assert!(tools.get_tool("unique").is_none());
14222
14223 let mut resources = Router::new();
14224 resources.add_resource(NamedResource::with_tags(
14225 "same://resource",
14226 vec!["original".to_string()],
14227 ));
14228 let mut resource_source = Router::new();
14229 resource_source.add_resource(NamedResource::with_tags(
14230 "same://resource",
14231 vec!["incoming".to_string()],
14232 ));
14233 resource_source.add_resource_template(marked_template("unique://{item}", "incoming"));
14234 let resource_result = resources.mount_resources_with_behavior(
14235 resource_source,
14236 None,
14237 crate::DuplicateBehavior::Error,
14238 );
14239 assert!(!resource_result.is_success());
14240 assert_eq!(resource_result.resources, 0);
14241 assert_eq!(resource_result.resource_templates, 0);
14242 assert_eq!(resources.resources_count(), 1);
14243 assert_eq!(resources.resource_templates_count(), 0);
14244 assert_eq!(
14245 resources
14246 .get_resource("same://resource")
14247 .unwrap()
14248 .definition()
14249 .tags,
14250 vec!["original".to_string()]
14251 );
14252
14253 let mut prompts = Router::new();
14254 prompts.add_prompt(NamedPrompt::with_tags("same", vec!["original".to_string()]));
14255 let mut prompt_source = Router::new();
14256 prompt_source.add_prompt(NamedPrompt::with_tags("same", vec!["incoming".to_string()]));
14257 prompt_source.add_prompt(NamedPrompt::new("unique"));
14258 let prompt_result = prompts.mount_prompts_with_behavior(
14259 prompt_source,
14260 None,
14261 crate::DuplicateBehavior::Error,
14262 );
14263 assert!(!prompt_result.is_success());
14264 assert_eq!(prompt_result.prompts, 0);
14265 assert_eq!(prompts.prompts_count(), 1);
14266 assert_eq!(
14267 prompts.get_prompt("same").unwrap().definition().tags,
14268 vec!["original".to_string()]
14269 );
14270 assert!(prompts.get_prompt("unique").is_none());
14271 }
14272
14273 #[test]
14274 fn full_error_mount_is_atomic_across_component_kinds() {
14275 let mut main = Router::new();
14276 main.add_tool(NamedTool::with_tags(
14277 "conflict",
14278 vec!["original".to_string()],
14279 ))
14280 .expect("tool registration succeeds");
14281
14282 let mut sub = Router::new();
14283 sub.add_tool(NamedTool::with_tags(
14284 "conflict",
14285 vec!["incoming".to_string()],
14286 ))
14287 .expect("tool registration succeeds");
14288 sub.add_resource(NamedResource::new("unique://resource"));
14289 sub.add_resource_template(marked_template("unique://{item}", "incoming"));
14290 sub.add_prompt(NamedPrompt::new("unique_prompt"));
14291
14292 let result = main.mount_with_behavior(sub, None, crate::DuplicateBehavior::Error);
14293 assert!(!result.is_success());
14294 assert_eq!(result.errors.len(), 1);
14295 assert!(!result.has_components());
14296 assert_eq!(main.tools_count(), 1);
14297 assert_eq!(main.resources_count(), 0);
14298 assert_eq!(main.resource_templates_count(), 0);
14299 assert_eq!(main.prompts_count(), 0);
14300 assert_eq!(
14301 main.get_tool("conflict").unwrap().definition().tags,
14302 vec!["original".to_string()]
14303 );
14304 }
14305
14306 #[test]
14307 fn invalid_prefix_rejects_every_partial_mount_without_mutation() {
14308 let mut tools = Router::new();
14309 let mut tool_source = Router::new();
14310 tool_source
14311 .add_tool(NamedTool::new("tool"))
14312 .expect("tool registration succeeds");
14313 let tool_result = tools.mount_tools_with_behavior(
14314 tool_source,
14315 Some("peer/secret"),
14316 crate::DuplicateBehavior::Replace,
14317 );
14318 assert!(!tool_result.is_success());
14319 assert_eq!(tools.tools_count(), 0);
14320
14321 let mut resources = Router::new();
14322 let mut resource_source = Router::new();
14323 resource_source.add_resource(NamedResource::new("resource://value"));
14324 resource_source.add_resource_template(marked_template("template://{value}", "incoming"));
14325 let resource_result = resources.mount_resources_with_behavior(
14326 resource_source,
14327 Some("peer/secret"),
14328 crate::DuplicateBehavior::Replace,
14329 );
14330 assert!(!resource_result.is_success());
14331 assert_eq!(resources.resources_count(), 0);
14332 assert_eq!(resources.resource_templates_count(), 0);
14333
14334 let mut prompts = Router::new();
14335 let mut prompt_source = Router::new();
14336 prompt_source.add_prompt(NamedPrompt::new("prompt"));
14337 let prompt_result = prompts.mount_prompts_with_behavior(
14338 prompt_source,
14339 Some("peer/secret"),
14340 crate::DuplicateBehavior::Replace,
14341 );
14342 assert!(!prompt_result.is_success());
14343 assert_eq!(prompts.prompts_count(), 0);
14344
14345 for result in [tool_result, resource_result, prompt_result] {
14346 assert_eq!(result.errors.len(), 1);
14347 assert!(!result.errors[0].contains("peer/secret"));
14348 assert!(!result.has_components());
14349 }
14350 }
14351
14352 #[test]
14355 fn mount_tools_only() {
14356 let mut main = Router::new();
14357 let mut sub = Router::new();
14358 sub.add_tool(NamedTool::new("t1"))
14359 .expect("tool registration succeeds");
14360 sub.add_prompt(NamedPrompt::new("p1"));
14361 let result = main.mount_tools(sub, Some("ns"));
14362 assert_eq!(result.tools, 1);
14363 assert!(main.get_tool("ns/t1").is_some());
14364 assert_eq!(main.prompts_count(), 0); }
14366
14367 #[test]
14368 fn mount_prompts_only() {
14369 let mut main = Router::new();
14370 let mut sub = Router::new();
14371 sub.add_tool(NamedTool::new("t1"))
14372 .expect("tool registration succeeds");
14373 sub.add_prompt(NamedPrompt::new("p1"));
14374 let result = main.mount_prompts(sub, Some("ns"));
14375 assert_eq!(result.prompts, 1);
14376 assert!(main.get_prompt("ns/p1").is_some());
14377 assert_eq!(main.tools_count(), 0); }
14379
14380 #[test]
14383 fn handle_tools_list_no_pagination() {
14384 let mut r = Router::new();
14385 r.add_tool(NamedTool::new("a"))
14386 .expect("tool registration succeeds");
14387 r.add_tool(NamedTool::new("b"))
14388 .expect("tool registration succeeds");
14389 let cx = Cx::for_testing();
14390 let params = ListToolsParams {
14391 cursor: None,
14392 include_tags: None,
14393 exclude_tags: None,
14394 };
14395 let request_ctx = McpContext::new(cx, 1);
14396 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
14397 assert_eq!(result.tools.len(), 2);
14398 assert!(result.next_cursor.is_none());
14399 }
14400
14401 #[test]
14402 fn handle_tools_list_with_pagination() {
14403 let mut r = Router::new();
14404 r.set_list_page_size(Some(1));
14405 r.add_tool(NamedTool::new("a"))
14406 .expect("tool registration succeeds");
14407 r.add_tool(NamedTool::new("b"))
14408 .expect("tool registration succeeds");
14409 let cx = Cx::for_testing();
14410 let request_ctx = McpContext::new(cx, 1);
14411
14412 let params = ListToolsParams {
14414 cursor: None,
14415 include_tags: None,
14416 exclude_tags: None,
14417 };
14418 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
14419 assert_eq!(result.tools.len(), 1);
14420 assert_eq!(result.tools[0].name, "a");
14421 assert!(result.next_cursor.is_some());
14422
14423 let params = ListToolsParams {
14425 cursor: result.next_cursor,
14426 include_tags: None,
14427 exclude_tags: None,
14428 };
14429 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
14430 assert_eq!(result.tools.len(), 1);
14431 assert_eq!(result.tools[0].name, "b");
14432 assert!(result.next_cursor.is_none());
14433 }
14434
14435 #[test]
14436 fn handle_tools_list_with_tag_filter() {
14437 let mut r = Router::new();
14438 r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
14439 .expect("tool registration succeeds");
14440 r.add_tool(NamedTool::with_tags("b", vec!["web".to_string()]))
14441 .expect("tool registration succeeds");
14442 let cx = Cx::for_testing();
14443 let params = ListToolsParams {
14444 cursor: None,
14445 include_tags: Some(vec!["db".to_string()]),
14446 exclude_tags: None,
14447 };
14448 let request_ctx = McpContext::new(cx, 1);
14449 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
14450 assert_eq!(result.tools.len(), 1);
14451 assert_eq!(result.tools[0].name, "a");
14452 }
14453
14454 #[test]
14457 fn handle_resources_list_no_pagination() {
14458 let mut r = Router::new();
14459 r.add_resource(NamedResource::new("file:///a"));
14460 let cx = Cx::for_testing();
14461 let params = ListResourcesParams {
14462 cursor: None,
14463 include_tags: None,
14464 exclude_tags: None,
14465 };
14466 let request_ctx = McpContext::new(cx, 1);
14467 let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
14468 assert_eq!(result.resources.len(), 1);
14469 assert!(result.next_cursor.is_none());
14470 }
14471
14472 #[test]
14473 fn handle_resources_list_with_pagination() {
14474 let mut r = Router::new();
14475 r.set_list_page_size(Some(1));
14476 r.add_resource(NamedResource::new("file:///a"));
14477 r.add_resource(NamedResource::new("file:///b"));
14478 let cx = Cx::for_testing();
14479 let params = ListResourcesParams {
14480 cursor: None,
14481 include_tags: None,
14482 exclude_tags: None,
14483 };
14484 let request_ctx = McpContext::new(cx, 1);
14485 let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
14486 assert_eq!(result.resources.len(), 1);
14487 assert!(result.next_cursor.is_some());
14488 }
14489
14490 #[test]
14493 fn handle_prompts_list_no_pagination() {
14494 let mut r = Router::new();
14495 r.add_prompt(NamedPrompt::new("greet"));
14496 let cx = Cx::for_testing();
14497 let params = ListPromptsParams {
14498 cursor: None,
14499 include_tags: None,
14500 exclude_tags: None,
14501 };
14502 let request_ctx = McpContext::new(cx, 1);
14503 let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
14504 assert_eq!(result.prompts.len(), 1);
14505 assert!(result.next_cursor.is_none());
14506 }
14507
14508 #[test]
14511 fn handle_resource_templates_list_no_pagination() {
14512 let mut r = Router::new();
14513 r.add_resource_template(ResourceTemplate {
14514 uri_template: "db://{table}".to_string(),
14515 name: "db".to_string(),
14516 description: None,
14517 mime_type: None,
14518 icon: None,
14519 version: None,
14520 tags: vec![],
14521 });
14522 let cx = Cx::for_testing();
14523 let params = ListResourceTemplatesParams {
14524 cursor: None,
14525 include_tags: None,
14526 exclude_tags: None,
14527 };
14528 let request_ctx = McpContext::new(cx, 1);
14529 let result = r
14530 .handle_resource_templates_list(&request_ctx, params, None)
14531 .unwrap();
14532 assert_eq!(result.resource_templates.len(), 1);
14533 assert!(result.next_cursor.is_none());
14534 }
14535
14536 #[test]
14539 fn handle_initialize_returns_protocol_version() {
14540 let r = Router::new();
14541 let cx = Cx::for_testing();
14542 let mut session = Session::new(
14543 fastmcp_protocol::ServerInfo {
14544 name: "test".to_string(),
14545 version: "1.0".to_string(),
14546 },
14547 fastmcp_protocol::ServerCapabilities::default(),
14548 );
14549 let params = InitializeParams {
14550 protocol_version: PROTOCOL_VERSION.to_string(),
14551 capabilities: fastmcp_protocol::ClientCapabilities::default(),
14552 client_info: fastmcp_protocol::ClientInfo {
14553 name: "test-client".to_string(),
14554 version: "1.0".to_string(),
14555 },
14556 };
14557 let request_ctx = McpContext::new(cx, 1);
14558 let result = r
14559 .handle_initialize(
14560 &request_ctx,
14561 &mut session,
14562 params,
14563 Some("test instructions"),
14564 )
14565 .unwrap();
14566 assert_eq!(result.protocol_version, PROTOCOL_VERSION);
14567 assert_eq!(result.server_info.name, "test");
14568 assert_eq!(result.instructions.as_deref(), Some("test instructions"));
14569 }
14570
14571 #[test]
14572 fn handle_initialize_no_instructions() {
14573 let r = Router::new();
14574 let cx = Cx::for_testing();
14575 let mut session = Session::new(
14576 fastmcp_protocol::ServerInfo {
14577 name: "srv".to_string(),
14578 version: "0.1".to_string(),
14579 },
14580 fastmcp_protocol::ServerCapabilities::default(),
14581 );
14582 let params = InitializeParams {
14583 protocol_version: PROTOCOL_VERSION.to_string(),
14584 capabilities: fastmcp_protocol::ClientCapabilities::default(),
14585 client_info: fastmcp_protocol::ClientInfo {
14586 name: "c".to_string(),
14587 version: "0.1".to_string(),
14588 },
14589 };
14590 let request_ctx = McpContext::new(cx, 1);
14591 let result = r
14592 .handle_initialize(&request_ctx, &mut session, params, None)
14593 .unwrap();
14594 assert!(result.instructions.is_none());
14595 }
14596
14597 #[test]
14600 fn add_resource_behavior_warn_keeps_original() {
14601 let mut r = Router::new();
14602 r.add_resource(NamedResource::new("file:///a"));
14603 r.add_resource_with_behavior(
14604 NamedResource::new("file:///a"),
14605 crate::DuplicateBehavior::Warn,
14606 )
14607 .unwrap();
14608 assert_eq!(r.resources_count(), 1);
14609 }
14610
14611 #[test]
14612 fn add_resource_behavior_replace() {
14613 let mut r = Router::new();
14614 r.add_resource(NamedResource::new("file:///a"));
14615 r.add_resource_with_behavior(
14616 NamedResource::new("file:///a"),
14617 crate::DuplicateBehavior::Replace,
14618 )
14619 .unwrap();
14620 assert_eq!(r.resources_count(), 1);
14621 }
14622
14623 #[test]
14624 fn add_resource_behavior_new_resource_ok() {
14625 let mut r = Router::new();
14626 r.add_resource_with_behavior(
14627 NamedResource::new("file:///a"),
14628 crate::DuplicateBehavior::Error,
14629 )
14630 .unwrap();
14631 assert_eq!(r.resources_count(), 1);
14632 }
14633
14634 #[test]
14637 fn add_prompt_behavior_replace() {
14638 let mut r = Router::new();
14639 r.add_prompt(NamedPrompt::new("p"));
14640 r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Replace)
14641 .unwrap();
14642 assert_eq!(r.prompts_count(), 1);
14643 }
14644
14645 #[test]
14646 fn add_prompt_behavior_ignore() {
14647 let mut r = Router::new();
14648 r.add_prompt(NamedPrompt::new("p"));
14649 r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Ignore)
14650 .unwrap();
14651 assert_eq!(r.prompts_count(), 1);
14652 }
14653
14654 #[test]
14655 fn add_prompt_behavior_new_prompt_ok() {
14656 let mut r = Router::new();
14657 r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Error)
14658 .unwrap();
14659 assert_eq!(r.prompts_count(), 1);
14660 }
14661
14662 #[test]
14665 fn add_resource_replaces_on_duplicate() {
14666 let mut r = Router::new();
14667 r.add_resource(NamedResource::new("file:///a"));
14668 r.add_resource(NamedResource::new("file:///a"));
14669 assert_eq!(r.resources_count(), 1);
14670 assert_eq!(r.resources().len(), 1);
14671 }
14672
14673 #[test]
14674 fn add_prompt_replaces_on_duplicate() {
14675 let mut r = Router::new();
14676 r.add_prompt(NamedPrompt::new("p"));
14677 r.add_prompt(NamedPrompt::new("p"));
14678 assert_eq!(r.prompts_count(), 1);
14679 assert_eq!(r.prompts().len(), 1);
14680 }
14681
14682 #[test]
14685 fn resource_exists_for_template_match() {
14686 struct DbResource;
14687 impl ResourceHandler for DbResource {
14688 fn definition(&self) -> Resource {
14689 Resource {
14690 uri: "db://placeholder".to_string(),
14691 name: "db".to_string(),
14692 description: None,
14693 mime_type: Some("text/plain".to_string()),
14694 icon: None,
14695 version: None,
14696 tags: vec![],
14697 }
14698 }
14699 fn template(&self) -> Option<ResourceTemplate> {
14700 Some(ResourceTemplate {
14701 uri_template: "db://{table}".to_string(),
14702 name: "db".to_string(),
14703 description: None,
14704 mime_type: None,
14705 icon: None,
14706 version: None,
14707 tags: vec![],
14708 })
14709 }
14710 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<fastmcp_protocol::ResourceContent>> {
14711 Ok(vec![])
14712 }
14713 }
14714 let mut r = Router::new();
14715 r.add_resource(DbResource);
14716 assert!(r.resource_exists("db://users"));
14717 assert!(!r.resource_exists("file://other"));
14718 }
14719
14720 #[test]
14723 fn resources_filtered_by_tags() {
14724 let mut r = Router::new();
14725 r.add_resource(NamedResource::with_tags(
14726 "file:///a",
14727 vec!["internal".to_string()],
14728 ));
14729 r.add_resource(NamedResource::with_tags(
14730 "file:///b",
14731 vec!["public".to_string()],
14732 ));
14733 let include = vec!["public".to_string()];
14734 let filters = TagFilters::new(Some(&include), None);
14735 let res = r.resources_filtered(None, Some(&filters));
14736 assert_eq!(res.len(), 1);
14737 assert_eq!(res[0].uri, "file:///b");
14738 }
14739
14740 #[test]
14743 fn resource_templates_filtered_by_tags() {
14744 let mut r = Router::new();
14745 r.add_resource_template(ResourceTemplate {
14746 uri_template: "db://{table}".to_string(),
14747 name: "db".to_string(),
14748 description: None,
14749 mime_type: None,
14750 icon: None,
14751 version: None,
14752 tags: vec!["admin".to_string()],
14753 });
14754 r.add_resource_template(ResourceTemplate {
14755 uri_template: "cache://{key}".to_string(),
14756 name: "cache".to_string(),
14757 description: None,
14758 mime_type: None,
14759 icon: None,
14760 version: None,
14761 tags: vec!["public".to_string()],
14762 });
14763 let exclude = vec!["admin".to_string()];
14764 let filters = TagFilters::new(None, Some(&exclude));
14765 let tmpls = r.resource_templates_filtered(None, Some(&filters));
14766 assert_eq!(tmpls.len(), 1);
14767 assert_eq!(tmpls[0].name, "cache");
14768 }
14769
14770 #[test]
14773 fn handle_tools_list_with_session_state_filter() {
14774 let mut r = Router::new();
14775 r.add_tool(NamedTool::new("a"))
14776 .expect("tool registration succeeds");
14777 r.add_tool(NamedTool::new("b"))
14778 .expect("tool registration succeeds");
14779 let cx = Cx::for_testing();
14780 let state = SessionState::new();
14781 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
14782 state.set("fastmcp.disabled_tools", &disabled);
14783 let params = ListToolsParams {
14784 cursor: None,
14785 include_tags: None,
14786 exclude_tags: None,
14787 };
14788 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14789 let result = r
14790 .handle_tools_list(&request_ctx, params, Some(&state))
14791 .unwrap();
14792 assert_eq!(result.tools.len(), 1);
14793 assert_eq!(result.tools[0].name, "b");
14794 }
14795
14796 #[test]
14797 fn handle_tools_list_cursor_rejects_changed_session_visibility() {
14798 let mut router = Router::new();
14799 router.set_list_page_size(Some(1));
14800 router
14801 .add_tool(NamedTool::new("a"))
14802 .expect("tool registration succeeds");
14803 router
14804 .add_tool(NamedTool::new("b"))
14805 .expect("tool registration succeeds");
14806 let cx = Cx::for_testing();
14807 let state = SessionState::new();
14808 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14809 let first = router
14810 .handle_tools_list(
14811 &request_ctx,
14812 ListToolsParams {
14813 cursor: None,
14814 include_tags: None,
14815 exclude_tags: None,
14816 },
14817 Some(&state),
14818 )
14819 .expect("the first session-filtered page is admitted");
14820 assert_eq!(first.tools[0].name, "a");
14821 let cursor = first
14822 .next_cursor
14823 .expect("the first page has a continuation");
14824
14825 let continued = router
14826 .handle_tools_list(
14827 &request_ctx,
14828 ListToolsParams {
14829 cursor: Some(cursor.clone()),
14830 include_tags: None,
14831 exclude_tags: None,
14832 },
14833 Some(&state),
14834 )
14835 .expect("unchanged session visibility continues the exact-2024 catalog");
14836 assert_eq!(continued.tools[0].name, "b");
14837 assert!(continued.next_cursor.is_none());
14838
14839 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
14840 state.set("fastmcp.disabled_tools", &disabled);
14841 let stale = router
14842 .handle_tools_list(
14843 &request_ctx,
14844 ListToolsParams {
14845 cursor: Some(cursor),
14846 include_tags: None,
14847 exclude_tags: None,
14848 },
14849 Some(&state),
14850 )
14851 .expect_err("changing only session visibility rejects the continuation");
14852 assert_eq!(stale.code, McpErrorCode::InvalidParams);
14853 assert!(stale.message.contains("session visibility"));
14854 }
14855
14856 #[test]
14859 fn handle_resources_list_with_tag_filter() {
14860 let mut r = Router::new();
14861 r.add_resource(NamedResource::with_tags(
14862 "file:///a",
14863 vec!["db".to_string()],
14864 ));
14865 r.add_resource(NamedResource::with_tags(
14866 "file:///b",
14867 vec!["web".to_string()],
14868 ));
14869 let cx = Cx::for_testing();
14870 let params = ListResourcesParams {
14871 cursor: None,
14872 include_tags: Some(vec!["web".to_string()]),
14873 exclude_tags: None,
14874 };
14875 let request_ctx = McpContext::new(cx, 1);
14876 let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
14877 assert_eq!(result.resources.len(), 1);
14878 assert_eq!(result.resources[0].uri, "file:///b");
14879 }
14880
14881 #[test]
14884 fn handle_prompts_list_with_pagination() {
14885 let mut r = Router::new();
14886 r.set_list_page_size(Some(1));
14887 r.add_prompt(NamedPrompt::new("a"));
14888 r.add_prompt(NamedPrompt::new("b"));
14889 let cx = Cx::for_testing();
14890 let params = ListPromptsParams {
14891 cursor: None,
14892 include_tags: None,
14893 exclude_tags: None,
14894 };
14895 let request_ctx = McpContext::new(cx, 1);
14896 let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
14897 assert_eq!(result.prompts.len(), 1);
14898 assert_eq!(result.prompts[0].name, "a");
14899 assert!(result.next_cursor.is_some());
14900
14901 let params = ListPromptsParams {
14902 cursor: result.next_cursor,
14903 include_tags: None,
14904 exclude_tags: None,
14905 };
14906 let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
14907 assert_eq!(result.prompts.len(), 1);
14908 assert_eq!(result.prompts[0].name, "b");
14909 assert!(result.next_cursor.is_none());
14910 }
14911
14912 #[test]
14915 fn handle_prompts_list_with_tag_filter() {
14916 let mut r = Router::new();
14917 r.add_prompt(NamedPrompt::with_tags("a", vec!["internal".to_string()]));
14918 r.add_prompt(NamedPrompt::with_tags("b", vec!["public".to_string()]));
14919 let cx = Cx::for_testing();
14920 let params = ListPromptsParams {
14921 cursor: None,
14922 include_tags: None,
14923 exclude_tags: Some(vec!["internal".to_string()]),
14924 };
14925 let request_ctx = McpContext::new(cx, 1);
14926 let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
14927 assert_eq!(result.prompts.len(), 1);
14928 assert_eq!(result.prompts[0].name, "b");
14929 }
14930
14931 #[test]
14934 fn handle_resource_templates_list_with_pagination() {
14935 let mut r = Router::new();
14936 r.set_list_page_size(Some(1));
14937 r.add_resource_template(ResourceTemplate {
14938 uri_template: "db://{table}".to_string(),
14939 name: "db".to_string(),
14940 description: None,
14941 mime_type: None,
14942 icon: None,
14943 version: None,
14944 tags: vec![],
14945 });
14946 r.add_resource_template(ResourceTemplate {
14947 uri_template: "cache://{key}".to_string(),
14948 name: "cache".to_string(),
14949 description: None,
14950 mime_type: None,
14951 icon: None,
14952 version: None,
14953 tags: vec![],
14954 });
14955 let cx = Cx::for_testing();
14956 let request_ctx = McpContext::new(cx, 1);
14957 let params = ListResourceTemplatesParams {
14958 cursor: None,
14959 include_tags: None,
14960 exclude_tags: None,
14961 };
14962 let result = r
14963 .handle_resource_templates_list(&request_ctx, params, None)
14964 .unwrap();
14965 assert_eq!(result.resource_templates.len(), 1);
14966 assert!(result.next_cursor.is_some());
14967
14968 let params = ListResourceTemplatesParams {
14969 cursor: result.next_cursor,
14970 include_tags: None,
14971 exclude_tags: None,
14972 };
14973 let result = r
14974 .handle_resource_templates_list(&request_ctx, params, None)
14975 .unwrap();
14976 assert_eq!(result.resource_templates.len(), 1);
14977 assert!(result.next_cursor.is_none());
14978 }
14979
14980 #[test]
14983 fn handle_resource_templates_list_with_tag_filter() {
14984 let mut r = Router::new();
14985 r.add_resource_template(ResourceTemplate {
14986 uri_template: "db://{table}".to_string(),
14987 name: "db".to_string(),
14988 description: None,
14989 mime_type: None,
14990 icon: None,
14991 version: None,
14992 tags: vec!["admin".to_string()],
14993 });
14994 r.add_resource_template(ResourceTemplate {
14995 uri_template: "cache://{key}".to_string(),
14996 name: "cache".to_string(),
14997 description: None,
14998 mime_type: None,
14999 icon: None,
15000 version: None,
15001 tags: vec!["public".to_string()],
15002 });
15003 let cx = Cx::for_testing();
15004 let params = ListResourceTemplatesParams {
15005 cursor: None,
15006 include_tags: Some(vec!["public".to_string()]),
15007 exclude_tags: None,
15008 };
15009 let request_ctx = McpContext::new(cx, 1);
15010 let result = r
15011 .handle_resource_templates_list(&request_ctx, params, None)
15012 .unwrap();
15013 assert_eq!(result.resource_templates.len(), 1);
15014 assert_eq!(result.resource_templates[0].name, "cache");
15015 }
15016
15017 #[test]
15020 fn mount_resources_only() {
15021 let mut main = Router::new();
15022 let mut sub = Router::new();
15023 sub.add_resource(NamedResource::new("file:///a"));
15024 sub.add_tool(NamedTool::new("t1"))
15025 .expect("tool registration succeeds");
15026 sub.add_resource_template(ResourceTemplate {
15027 uri_template: "db://{t}".to_string(),
15028 name: "db".to_string(),
15029 description: None,
15030 mime_type: None,
15031 icon: None,
15032 version: None,
15033 tags: vec![],
15034 });
15035 let result = main.mount_resources(sub, Some("ns"));
15036 assert_eq!(result.resources, 1);
15037 assert_eq!(result.resource_templates, 1);
15038 assert!(main.get_resource("ns/file:///a").is_some());
15039 assert_eq!(main.tools_count(), 0); }
15041
15042 #[test]
15045 fn mount_result_with_resources_has_components() {
15046 let mut r = MountResult::default();
15047 r.resources = 1;
15048 assert!(r.has_components());
15049 }
15050
15051 #[test]
15052 fn mount_result_with_templates_has_components() {
15053 let mut r = MountResult::default();
15054 r.resource_templates = 1;
15055 assert!(r.has_components());
15056 }
15057
15058 #[test]
15059 fn mount_result_with_prompts_has_components() {
15060 let mut r = MountResult::default();
15061 r.prompts = 1;
15062 assert!(r.has_components());
15063 }
15064
15065 #[test]
15066 fn mount_result_is_success_with_warnings() {
15067 let mut r = MountResult::default();
15068 r.warnings.push("something".to_string());
15069 assert!(r.is_success());
15070 }
15071
15072 #[test]
15073 fn mount_result_reports_errors_as_failure() {
15074 let mut result = MountResult::default();
15075 result.errors.push("mount rejected".to_string());
15076 assert!(!result.is_success());
15077 assert!(!result.has_components());
15078 }
15079
15080 #[test]
15083 fn mount_all_component_types() {
15084 let mut main = Router::new();
15085 let mut sub = Router::new();
15086 sub.add_tool(NamedTool::new("t1"))
15087 .expect("tool registration succeeds");
15088 sub.add_resource(NamedResource::new("file:///r1"));
15089 sub.add_prompt(NamedPrompt::new("p1"));
15090 sub.add_resource_template(ResourceTemplate {
15091 uri_template: "db://{table}".to_string(),
15092 name: "db".to_string(),
15093 description: None,
15094 mime_type: None,
15095 icon: None,
15096 version: None,
15097 tags: vec![],
15098 });
15099 let result = main.mount(sub, Some("ns"));
15100 assert_eq!(result.tools, 1);
15101 assert_eq!(result.resources, 1);
15102 assert_eq!(result.prompts, 1);
15103 assert_eq!(result.resource_templates, 1);
15104 assert!(result.has_components());
15105 assert!(main.get_tool("ns/t1").is_some());
15106 assert!(main.get_resource("ns/file:///r1").is_some());
15107 assert!(main.get_prompt("ns/p1").is_some());
15108 }
15109
15110 #[test]
15113 fn mount_warns_on_resource_conflict() {
15114 let mut main = Router::new();
15115 main.add_resource(NamedResource::new("file:///a"));
15116 let mut sub = Router::new();
15117 sub.add_resource(NamedResource::new("file:///a"));
15118 let result = main.mount(sub, None);
15119 assert!(!result.warnings.is_empty());
15120 assert!(result.warnings[0].contains("Resource"));
15121 }
15122
15123 #[test]
15124 fn mount_warns_on_prompt_conflict() {
15125 let mut main = Router::new();
15126 main.add_prompt(NamedPrompt::new("p"));
15127 let mut sub = Router::new();
15128 sub.add_prompt(NamedPrompt::new("p"));
15129 let result = main.mount(sub, None);
15130 assert!(!result.warnings.is_empty());
15131 assert!(result.warnings[0].contains("Prompt"));
15132 }
15133
15134 #[test]
15137 fn tag_filters_clone() {
15138 let include = vec!["a".to_string()];
15139 let f = TagFilters::new(Some(&include), None);
15140 let cloned = f.clone();
15141 assert!(cloned.matches(&["a".to_string()]));
15142 assert!(!cloned.matches(&["b".to_string()]));
15143 }
15144
15145 #[test]
15148 fn handle_tools_list_pagination_with_tags() {
15149 let mut r = Router::new();
15150 r.set_list_page_size(Some(1));
15151 r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
15152 .expect("tool registration succeeds");
15153 r.add_tool(NamedTool::with_tags("b", vec!["db".to_string()]))
15154 .expect("tool registration succeeds");
15155 r.add_tool(NamedTool::with_tags("c", vec!["web".to_string()]))
15156 .expect("tool registration succeeds");
15157 let cx = Cx::for_testing();
15158 let request_ctx = McpContext::new(cx, 1);
15159
15160 let params = ListToolsParams {
15162 cursor: None,
15163 include_tags: Some(vec!["db".to_string()]),
15164 exclude_tags: None,
15165 };
15166 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
15167 assert_eq!(result.tools.len(), 1);
15168 assert_eq!(result.tools[0].name, "a");
15169 assert!(result.next_cursor.is_some());
15170
15171 let params = ListToolsParams {
15173 cursor: result.next_cursor,
15174 include_tags: Some(vec!["db".to_string()]),
15175 exclude_tags: None,
15176 };
15177 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
15178 assert_eq!(result.tools.len(), 1);
15179 assert_eq!(result.tools[0].name, "b");
15180 assert!(result.next_cursor.is_none());
15181 }
15182
15183 #[test]
15184 fn final_mounted_tools_preserve_admitted_order_before_cursor_pagination() {
15185 let mut router = mounted_tool_router();
15186 let cx = Cx::for_testing();
15187 let state = SessionState::new();
15188 let request_ctx = request_context(&cx, 164, Budget::INFINITE, &state);
15189
15190 let legacy = router
15191 .handle_tools_list(
15192 &request_ctx,
15193 ListToolsParams {
15194 cursor: None,
15195 include_tags: Some(vec!["visible".to_owned()]),
15196 exclude_tags: Some(vec!["excluded".to_owned()]),
15197 },
15198 None,
15199 )
15200 .expect("legacy filtering retains every registered tool");
15201 assert_eq!(
15202 legacy
15203 .tools
15204 .iter()
15205 .map(|tool| tool.name.as_str())
15206 .collect::<Vec<_>>(),
15207 vec!["peer/first", "peer/second"],
15208 "the legacy catalog retains mounted tools in source order"
15209 );
15210
15211 router.set_list_page_size(Some(1));
15212 let first_page = router
15213 .dispatch_stateless(
15214 &request_ctx,
15215 &final_tools_list_request(
15216 None,
15217 Some(vec!["visible"]),
15218 Some(vec!["excluded"]),
15219 164_i64,
15220 ),
15221 )
15222 .expect("the first final page contains the first admitted entry");
15223 assert_eq!(first_page["tools"][0]["name"], "peer/first");
15224 assert_eq!(first_page["tools"].as_array().map(Vec::len), Some(1));
15225 let cursor = first_page["nextCursor"]
15226 .as_str()
15227 .expect("the first admitted page has a continuation cursor");
15228 let include_tags = vec!["visible".to_owned()];
15229 let exclude_tags = vec!["excluded".to_owned()];
15230 let query = FinalCatalogQuery::from_tag_filters(Some(&include_tags), Some(&exclude_tags));
15231 assert_eq!(
15232 decode_final_catalog_cursor_offset(
15233 Some(cursor),
15234 FinalCatalogKind::Tools,
15235 router.final_catalog_revision,
15236 &query,
15237 None,
15238 2,
15239 )
15240 .expect("cursor is router-generated for this exact final catalog revision"),
15241 1,
15242 "the cursor advances across admitted entries"
15243 );
15244
15245 let query_mismatch = router
15246 .dispatch_stateless(
15247 &request_ctx,
15248 &final_tools_list_request(
15249 Some(cursor),
15250 Some(vec!["other"]),
15251 Some(vec!["excluded"]),
15252 165_i64,
15253 ),
15254 )
15255 .expect_err("changing only the final list filter rejects the continuation");
15256 assert_eq!(query_mismatch.code, McpErrorCode::InvalidParams);
15257 assert!(query_mismatch.message.contains("query filters"));
15258
15259 let second_page = router
15260 .dispatch_stateless(
15261 &request_ctx,
15262 &final_tools_list_request(
15263 Some(cursor),
15264 Some(vec!["visible"]),
15265 Some(vec!["excluded"]),
15266 166_i64,
15267 ),
15268 )
15269 .expect("the continuation page keeps admitted source order");
15270 assert_eq!(second_page["tools"][0]["name"], "peer/second");
15271 assert_eq!(second_page["tools"].as_array().map(Vec::len), Some(1));
15272 assert!(
15273 second_page.get("nextCursor").is_none(),
15274 "the second admitted entry terminates the filtered final sequence"
15275 );
15276 }
15277
15278 #[test]
15279 fn final_default_list_validates_every_supplied_catalog_cursor() {
15280 let mut router = Router::new();
15281 router
15282 .add_tool(NamedTool::new("default-cursor-first"))
15283 .expect("first final tool registers");
15284 router
15285 .add_tool(NamedTool::new("default-cursor-second"))
15286 .expect("second final tool registers");
15287 assert!(
15288 router.list_page_size.is_none(),
15289 "the default final list path has pagination disabled"
15290 );
15291
15292 let cx = Cx::for_testing();
15293 let state = SessionState::new();
15294 let request_ctx = request_context(&cx, 167, Budget::INFINITE, &state);
15295 let full = router
15296 .dispatch_stateless(
15297 &request_ctx,
15298 &final_tools_list_request(None, None, None, 167_i64),
15299 )
15300 .expect("a cursor-free default final list returns the full catalog");
15301 assert_eq!(full["tools"].as_array().map(Vec::len), Some(2));
15302 assert!(full.get("nextCursor").is_none());
15303
15304 let query = FinalCatalogQuery::from_tag_filters(None, None);
15305 let valid_cursor = encode_final_catalog_cursor(
15306 FinalCatalogKind::Tools,
15307 router.final_catalog_revision,
15308 &query,
15309 0,
15310 None,
15311 );
15312 let validated_full = router
15313 .dispatch_stateless(
15314 &request_ctx,
15315 &final_tools_list_request(Some(&valid_cursor), None, None, 168_i64),
15316 )
15317 .expect(
15318 "a valid cursor remains admitted while default listing returns the full catalog",
15319 );
15320 assert_eq!(validated_full["tools"].as_array().map(Vec::len), Some(2));
15321 assert!(validated_full.get("nextCursor").is_none());
15322
15323 let stale_revision = router
15324 .final_catalog_revision
15325 .checked_sub(1)
15326 .expect("registered tools advance the final catalog revision");
15327 let stale_cursor =
15328 encode_final_catalog_cursor(FinalCatalogKind::Tools, stale_revision, &query, 0, None);
15329 let stale = router
15330 .dispatch_stateless(
15331 &request_ctx,
15332 &final_tools_list_request(Some(&stale_cursor), None, None, 169_i64),
15333 )
15334 .expect_err("changing only the revision rejects a default-list cursor");
15335 assert_eq!(stale.code, McpErrorCode::InvalidParams);
15336 assert!(stale.message.contains("stale catalog revision"));
15337
15338 let wrong_kind_cursor = encode_final_catalog_cursor(
15339 FinalCatalogKind::Prompts,
15340 router.final_catalog_revision,
15341 &query,
15342 0,
15343 None,
15344 );
15345 let wrong_kind = router
15346 .dispatch_stateless(
15347 &request_ctx,
15348 &final_tools_list_request(Some(&wrong_kind_cursor), None, None, 170_i64),
15349 )
15350 .expect_err("changing only the kind rejects a default-list cursor");
15351 assert_eq!(wrong_kind.code, McpErrorCode::InvalidParams);
15352 assert!(wrong_kind.message.contains("another list method"));
15353
15354 let other_tags = vec!["other".to_owned()];
15355 let other_query = FinalCatalogQuery::from_tag_filters(Some(&other_tags), None);
15356 let wrong_query_cursor = encode_final_catalog_cursor(
15357 FinalCatalogKind::Tools,
15358 router.final_catalog_revision,
15359 &other_query,
15360 0,
15361 None,
15362 );
15363 let wrong_query = router
15364 .dispatch_stateless(
15365 &request_ctx,
15366 &final_tools_list_request(Some(&wrong_query_cursor), None, None, 171_i64),
15367 )
15368 .expect_err("changing only the filters rejects a default-list cursor");
15369 assert_eq!(wrong_query.code, McpErrorCode::InvalidParams);
15370 assert!(wrong_query.message.contains("query filters"));
15371
15372 let out_of_range_cursor = encode_final_catalog_cursor(
15373 FinalCatalogKind::Tools,
15374 router.final_catalog_revision,
15375 &query,
15376 2,
15377 None,
15378 );
15379 let out_of_range = router
15380 .dispatch_stateless(
15381 &request_ctx,
15382 &final_tools_list_request(Some(&out_of_range_cursor), None, None, 172_i64),
15383 )
15384 .expect_err("changing only the offset rejects a default-list cursor");
15385 assert_eq!(out_of_range.code, McpErrorCode::InvalidParams);
15386 assert!(
15387 out_of_range
15388 .message
15389 .contains("outside the requested catalog page")
15390 );
15391 }
15392
15393 #[test]
15394 fn final_resource_and_prompt_cursors_reject_stale_catalog_revisions() {
15395 fn final_list_request(method: &str, cursor: Option<&str>, id: i64) -> JsonRpcRequest {
15396 let mut params = serde_json::json!({
15397 "_meta": {
15398 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
15399 "io.modelcontextprotocol/clientCapabilities": {},
15400 },
15401 });
15402 if let Some(cursor) = cursor {
15403 params["cursor"] = serde_json::json!(cursor);
15404 }
15405 JsonRpcRequest::new(method, Some(params), id)
15406 }
15407
15408 let mut router = Router::new();
15409 router.set_list_page_size(Some(1));
15410 router.add_resource(NamedResource::new("file:///cursor-resource-a"));
15411 router.add_resource(NamedResource::new("file:///cursor-resource-b"));
15412 router.add_prompt(NamedPrompt::new("cursor-prompt-a"));
15413 router.add_prompt(NamedPrompt::new("cursor-prompt-b"));
15414
15415 let cx = Cx::for_testing();
15416 let state = SessionState::new();
15417 let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
15418
15419 let resource_first = router
15420 .dispatch_stateless(
15421 &request_ctx,
15422 &final_list_request("resources/list", None, 166_i64),
15423 )
15424 .expect("the first final resource page is emitted");
15425 let resource_cursor = resource_first["nextCursor"]
15426 .as_str()
15427 .expect("the first resource page has a continuation")
15428 .to_owned();
15429 let resource_second = router
15430 .dispatch_stateless(
15431 &request_ctx,
15432 &final_list_request("resources/list", Some(&resource_cursor), 167_i64),
15433 )
15434 .expect("an unchanged final resource catalog accepts its cursor");
15435 assert_eq!(
15436 resource_second["resources"][0]["uri"],
15437 "file:///cursor-resource-b"
15438 );
15439
15440 router.add_resource(NamedResource::new("file:///cursor-resource-c"));
15441 let resource_stale = router
15442 .dispatch_stateless(
15443 &request_ctx,
15444 &final_list_request("resources/list", Some(&resource_cursor), 168_i64),
15445 )
15446 .expect_err("adding only one catalog resource invalidates the old continuation");
15447 assert_eq!(resource_stale.code, McpErrorCode::InvalidParams);
15448 assert!(resource_stale.message.contains("stale catalog revision"));
15449
15450 let prompt_first = router
15451 .dispatch_stateless(
15452 &request_ctx,
15453 &final_list_request("prompts/list", None, 169_i64),
15454 )
15455 .expect("the first final prompt page is emitted");
15456 let prompt_cursor = prompt_first["nextCursor"]
15457 .as_str()
15458 .expect("the first prompt page has a continuation")
15459 .to_owned();
15460 let prompt_second = router
15461 .dispatch_stateless(
15462 &request_ctx,
15463 &final_list_request("prompts/list", Some(&prompt_cursor), 170_i64),
15464 )
15465 .expect("an unchanged final prompt catalog accepts its cursor");
15466 assert_eq!(prompt_second["prompts"][0]["name"], "cursor-prompt-b");
15467
15468 router.add_prompt(NamedPrompt::new("cursor-prompt-c"));
15469 let prompt_stale = router
15470 .dispatch_stateless(
15471 &request_ctx,
15472 &final_list_request("prompts/list", Some(&prompt_cursor), 171_i64),
15473 )
15474 .expect_err("adding only one catalog prompt invalidates the old continuation");
15475 assert_eq!(prompt_stale.code, McpErrorCode::InvalidParams);
15476 assert!(prompt_stale.message.contains("stale catalog revision"));
15477 }
15478
15479 #[test]
15480 fn final_mounted_tools_apply_tag_filters_in_admitted_order() {
15481 let router = mounted_tool_router();
15482 let cx = Cx::for_testing();
15483 let state = SessionState::new();
15484 let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
15485
15486 let visible = router
15487 .dispatch_stateless(
15488 &request_ctx,
15489 &final_tools_list_request(None, Some(vec!["visible"]), None, 166_i64),
15490 )
15491 .expect("the final include filter projects only admitted visible entries");
15492 assert_eq!(
15493 visible["tools"]
15494 .as_array()
15495 .expect("final tools are an array")
15496 .iter()
15497 .map(|tool| tool["name"].as_str().expect("tool name is a string"))
15498 .collect::<Vec<_>>(),
15499 vec!["peer/first", "peer/second", "peer/excluded"],
15500 "tag filtering preserves admitted insertion order"
15501 );
15502
15503 let other = router
15504 .dispatch_stateless(
15505 &request_ctx,
15506 &final_tools_list_request(None, Some(vec!["other"]), None, 167_i64),
15507 )
15508 .expect("the include filter is evaluated after final admission");
15509 assert_eq!(other["tools"][0]["name"], "peer/other");
15510 assert_eq!(other["tools"].as_array().map(Vec::len), Some(1));
15511 }
15512
15513 #[test]
15516 fn handle_resources_list_with_session_state_filter() {
15517 let mut r = Router::new();
15518 r.add_resource(NamedResource::new("file:///a"));
15519 r.add_resource(NamedResource::new("file:///b"));
15520 let cx = Cx::for_testing();
15521 let state = SessionState::new();
15522 let disabled: std::collections::HashSet<String> =
15523 ["file:///a".to_string()].into_iter().collect();
15524 state.set("fastmcp.disabled_resources", &disabled);
15525 let params = ListResourcesParams {
15526 cursor: None,
15527 include_tags: None,
15528 exclude_tags: None,
15529 };
15530 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
15531 let result = r
15532 .handle_resources_list(&request_ctx, params, Some(&state))
15533 .unwrap();
15534 assert_eq!(result.resources.len(), 1);
15535 assert_eq!(result.resources[0].uri, "file:///b");
15536 }
15537
15538 #[test]
15541 fn handle_prompts_list_with_session_state_filter() {
15542 let mut r = Router::new();
15543 r.add_prompt(NamedPrompt::new("a"));
15544 r.add_prompt(NamedPrompt::new("b"));
15545 let cx = Cx::for_testing();
15546 let state = SessionState::new();
15547 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
15548 state.set("fastmcp.disabled_prompts", &disabled);
15549 let params = ListPromptsParams {
15550 cursor: None,
15551 include_tags: None,
15552 exclude_tags: None,
15553 };
15554 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
15555 let result = r
15556 .handle_prompts_list(&request_ctx, params, Some(&state))
15557 .unwrap();
15558 assert_eq!(result.prompts.len(), 1);
15559 assert_eq!(result.prompts[0].name, "b");
15560 }
15561
15562 #[test]
15565 fn resource_templates_filtered_session_and_tags_combined() {
15566 let mut r = Router::new();
15567 r.add_resource_template(ResourceTemplate {
15568 uri_template: "db://{table}".to_string(),
15569 name: "db".to_string(),
15570 description: None,
15571 mime_type: None,
15572 icon: None,
15573 version: None,
15574 tags: vec!["admin".to_string()],
15575 });
15576 r.add_resource_template(ResourceTemplate {
15577 uri_template: "cache://{key}".to_string(),
15578 name: "cache".to_string(),
15579 description: None,
15580 mime_type: None,
15581 icon: None,
15582 version: None,
15583 tags: vec!["admin".to_string()],
15584 });
15585 r.add_resource_template(ResourceTemplate {
15586 uri_template: "log://{entry}".to_string(),
15587 name: "log".to_string(),
15588 description: None,
15589 mime_type: None,
15590 icon: None,
15591 version: None,
15592 tags: vec!["public".to_string()],
15593 });
15594 let state = SessionState::new();
15596 let disabled: std::collections::HashSet<String> =
15597 ["db://{table}".to_string()].into_iter().collect();
15598 state.set("fastmcp.disabled_resources", &disabled);
15599 let include = vec!["admin".to_string()];
15601 let filters = TagFilters::new(Some(&include), None);
15602 let tmpls = r.resource_templates_filtered(Some(&state), Some(&filters));
15603 assert_eq!(tmpls.len(), 1);
15605 assert_eq!(tmpls[0].name, "cache");
15606 }
15607
15608 #[test]
15611 fn mount_resource_template_warns_on_conflict() {
15612 let mut main = Router::new();
15613 main.add_resource_template(ResourceTemplate {
15614 uri_template: "db://{table}".to_string(),
15615 name: "db".to_string(),
15616 description: None,
15617 mime_type: None,
15618 icon: None,
15619 version: None,
15620 tags: vec![],
15621 });
15622 let mut sub = Router::new();
15623 sub.add_resource_template(ResourceTemplate {
15624 uri_template: "db://{table}".to_string(),
15625 name: "db2".to_string(),
15626 description: None,
15627 mime_type: None,
15628 icon: None,
15629 version: None,
15630 tags: vec![],
15631 });
15632 let result = main.mount(sub, None);
15633 assert!(!result.warnings.is_empty());
15634 assert!(result.warnings[0].contains("Resource template"));
15635 }
15636
15637 #[test]
15640 fn handle_tools_call_disabled_tool_returns_error() {
15641 let mut r = Router::new();
15642 r.add_tool(NamedTool::new("my_tool"))
15643 .expect("tool registration succeeds");
15644 let cx = Cx::for_testing();
15645 let budget = Budget::INFINITE;
15646 let state = SessionState::new();
15647 let disabled: std::collections::HashSet<String> =
15648 ["my_tool".to_string()].into_iter().collect();
15649 state.set("fastmcp.disabled_tools", &disabled);
15650 let params = CallToolParams {
15651 name: "my_tool".to_string(),
15652 arguments: None,
15653 meta: None,
15654 };
15655 let request_ctx = request_context(&cx, 1, budget, &state);
15656 let err = r
15657 .handle_tools_call(&request_ctx, params, state, None, None)
15658 .unwrap_err();
15659 assert!(err.message.contains("disabled"));
15660 }
15661
15662 #[test]
15665 fn handle_tools_call_success() {
15666 let mut r = Router::new();
15667 r.add_tool(NamedTool::new("echo"))
15668 .expect("tool registration succeeds");
15669 let cx = Cx::for_testing();
15670 let budget = Budget::INFINITE;
15671 let params = CallToolParams {
15672 name: "echo".to_string(),
15673 arguments: None,
15674 meta: None,
15675 };
15676 let state = SessionState::new();
15677 let request_ctx = request_context(&cx, 1, budget, &state);
15678 let result = r
15679 .handle_tools_call(&request_ctx, params, state, None, None)
15680 .unwrap();
15681 assert!(!result.is_error);
15682 assert!(!result.content.is_empty());
15683 }
15684
15685 #[test]
15688 fn handle_tools_call_not_found() {
15689 let r = Router::new();
15690 let cx = Cx::for_testing();
15691 let budget = Budget::INFINITE;
15692 let params = CallToolParams {
15693 name: "missing".to_string(),
15694 arguments: None,
15695 meta: None,
15696 };
15697 let state = SessionState::new();
15698 let request_ctx = request_context(&cx, 1, budget, &state);
15699 let err = r
15700 .handle_tools_call(&request_ctx, params, state, None, None)
15701 .unwrap_err();
15702 assert_eq!(err.code, McpErrorCode::MethodNotFound);
15705 assert!(!err.message.contains("missing"));
15706 }
15707
15708 #[test]
15711 fn handle_tools_call_zero_poll_balance_allows_handler_without_checkpoint() {
15712 let mut r = Router::new();
15713 r.add_tool(NamedTool::new("t"))
15714 .expect("tool registration succeeds");
15715 let cx = Cx::for_testing();
15716 let budget = Budget::unlimited().with_poll_quota(0);
15717 let params = CallToolParams {
15718 name: "t".to_string(),
15719 arguments: None,
15720 meta: None,
15721 };
15722 let state = SessionState::new();
15723 let request_ctx = request_context(&cx, 1, budget, &state);
15724 let result = r
15725 .handle_tools_call(&request_ctx, params, state, None, None)
15726 .expect("a zero balance is not a retroactive failure");
15727 assert!(!result.is_error);
15728 }
15729
15730 #[test]
15731 fn handle_tools_call_defers_pending_cancellation_inside_context_mask() {
15732 let mut router = Router::new();
15733 router
15734 .add_tool(NamedTool::new("t"))
15735 .expect("tool registration succeeds");
15736 let cx = Cx::for_testing();
15737 cx.set_cancel_requested(true);
15738 let state = SessionState::new();
15739 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
15740 let params = || CallToolParams {
15741 name: "t".to_string(),
15742 arguments: None,
15743 meta: None,
15744 };
15745
15746 let masked_result = request_ctx
15747 .masked(|| router.handle_tools_call(&request_ctx, params(), state.clone(), None, None))
15748 .expect("mask should be admitted");
15749 assert!(masked_result.is_ok());
15750
15751 let unmasked_error = router
15752 .handle_tools_call(&request_ctx, params(), state, None, None)
15753 .expect_err("pending cancellation should surface after mask exit");
15754 assert_eq!(unmasked_error.code, McpErrorCode::RequestCancelled);
15755 }
15756
15757 #[test]
15760 fn handle_resources_read_disabled_resource_returns_error() {
15761 let mut r = Router::new();
15762 r.add_resource(NamedResource::new("file:///secret"));
15763 let cx = Cx::for_testing();
15764 let budget = Budget::INFINITE;
15765 let state = SessionState::new();
15766 let disabled: std::collections::HashSet<String> =
15767 ["file:///secret".to_string()].into_iter().collect();
15768 state.set("fastmcp.disabled_resources", &disabled);
15769 let params = ReadResourceParams {
15770 uri: "file:///secret".to_string(),
15771 meta: None,
15772 };
15773 let request_ctx = request_context(&cx, 1, budget, &state);
15774 let err = r
15775 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
15776 .unwrap_err();
15777 assert!(err.message.contains("disabled"));
15778 }
15779
15780 #[test]
15783 fn handle_resources_read_success() {
15784 let mut r = Router::new();
15785 r.add_resource(NamedResource::new("file:///a"));
15786 let cx = Cx::for_testing();
15787 let budget = Budget::INFINITE;
15788 let params = ReadResourceParams {
15789 uri: "file:///a".to_string(),
15790 meta: None,
15791 };
15792 let state = SessionState::new();
15793 let request_ctx = request_context(&cx, 1, budget, &state);
15794 let result = r
15795 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
15796 .unwrap();
15797 assert_eq!(result.contents.len(), 1);
15798 let [
15799 LegacyResourceContent::Text {
15800 uri,
15801 text,
15802 additional,
15803 ..
15804 },
15805 ] = result.contents.as_slice()
15806 else {
15807 panic!("resource read must retain the exact legacy text shape");
15808 };
15809 assert_eq!(uri, "file:///a");
15810 assert_eq!(text, "content");
15811 assert!(additional.is_empty());
15812 }
15813
15814 #[test]
15817 fn handle_resources_read_not_found() {
15818 let r = Router::new();
15819 let cx = Cx::for_testing();
15820 let budget = Budget::INFINITE;
15821 let params = ReadResourceParams {
15822 uri: "file:///nonexistent".to_string(),
15823 meta: None,
15824 };
15825 let state = SessionState::new();
15826 let request_ctx = request_context(&cx, 1, budget, &state);
15827 let err = r
15828 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
15829 .unwrap_err();
15830 assert!(err.message.contains("nonexistent") || err.message.contains("not found"));
15831 }
15832
15833 #[test]
15836 fn handle_resources_read_zero_poll_balance_allows_handler_without_checkpoint() {
15837 let mut r = Router::new();
15838 r.add_resource(NamedResource::new("file:///a"));
15839 let cx = Cx::for_testing();
15840 let budget = Budget::unlimited().with_poll_quota(0);
15841 let params = ReadResourceParams {
15842 uri: "file:///a".to_string(),
15843 meta: None,
15844 };
15845 let state = SessionState::new();
15846 let request_ctx = request_context(&cx, 1, budget, &state);
15847 let result = r
15848 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
15849 .expect("a zero balance is not a retroactive failure");
15850 assert_eq!(result.contents.len(), 1);
15851 }
15852
15853 #[test]
15856 fn handle_prompts_get_disabled_prompt_returns_error() {
15857 let mut r = Router::new();
15858 r.add_prompt(NamedPrompt::new("secret_prompt"));
15859 let cx = Cx::for_testing();
15860 let budget = Budget::INFINITE;
15861 let state = SessionState::new();
15862 let disabled: std::collections::HashSet<String> =
15863 ["secret_prompt".to_string()].into_iter().collect();
15864 state.set("fastmcp.disabled_prompts", &disabled);
15865 let params = GetPromptParams {
15866 name: "secret_prompt".to_string(),
15867 arguments: None,
15868 meta: None,
15869 };
15870 let request_ctx = request_context(&cx, 1, budget, &state);
15871 let err = r
15872 .handle_prompts_get(&request_ctx, params, state, None, None)
15873 .unwrap_err();
15874 assert!(err.message.contains("disabled"));
15875 }
15876
15877 #[test]
15880 fn nested_get_prompt_returns_handler_text_and_refuses_missing() {
15881 let mut router = Router::new();
15882 router.add_prompt(NamedPrompt::new("greet"));
15883 let router = Arc::new(router);
15884 let cx = Cx::for_testing();
15885 let state = SessionState::new();
15886 let request_ctx = McpContext::with_state(cx, 1, state.clone()).with_prompt_caller(
15887 Arc::new(RouterPromptCaller::new(Arc::clone(&router), state)),
15888 );
15889
15890 let missing = block_on(request_ctx.get_prompt("missing", HashMap::new()))
15891 .expect_err("an unknown nested prompt must stay refused");
15892 assert_eq!(missing.code, McpErrorCode::PromptNotFound);
15893
15894 let result = block_on(request_ctx.get_prompt("greet", HashMap::new()))
15895 .expect("a registered nested prompt must complete");
15896 assert_eq!(result.description.as_deref(), Some("Prompt greet"));
15897 assert!(result.messages.is_empty());
15898 }
15899
15900 #[test]
15901 fn nested_get_prompt_from_legacy_surface_skips_required_argument_admission() {
15902 let final_calls = Arc::new(AtomicUsize::new(0));
15903 let legacy_calls = Arc::new(AtomicUsize::new(0));
15904 let mut router = Router::new();
15905 router.add_prompt(PromptArgumentBoundary {
15906 final_calls: Arc::clone(&final_calls),
15907 legacy_calls: Arc::clone(&legacy_calls),
15908 });
15909 let router = Arc::new(router);
15910 let cx = Cx::for_testing();
15911 let state = SessionState::new();
15912 let request_ctx = McpContext::with_state(cx, 1, state.clone()).with_prompt_caller(
15913 Arc::new(RouterPromptCaller::new(Arc::clone(&router), state)),
15914 );
15915
15916 let result = block_on(request_ctx.get_prompt("prompt-argument-boundary", HashMap::new()))
15917 .expect("legacy nested get_prompt retains unvalidated argument behavior");
15918 assert_eq!(
15919 result.messages[0].text.as_deref(),
15920 Some("legacy prompt-argument-boundary result")
15921 );
15922 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
15923 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
15924 }
15925
15926 #[test]
15927 fn nested_get_prompt_from_final_surface_admits_arguments_before_handler() {
15928 let final_calls = Arc::new(AtomicUsize::new(0));
15929 let legacy_calls = Arc::new(AtomicUsize::new(0));
15930 let mut router = Router::new();
15931 router.add_prompt(PromptArgumentBoundary {
15932 final_calls: Arc::clone(&final_calls),
15933 legacy_calls: Arc::clone(&legacy_calls),
15934 });
15935 let router = Arc::new(router);
15936 let cx = Cx::for_testing();
15937 let state = SessionState::new();
15938 let request_ctx = McpContext::with_state(cx, 1, state.clone())
15939 .with_final_request_surface(true)
15940 .with_prompt_caller(Arc::new(RouterPromptCaller::new(
15941 Arc::clone(&router),
15942 state,
15943 )));
15944
15945 let missing = block_on(request_ctx.get_prompt("prompt-argument-boundary", HashMap::new()))
15946 .expect_err("removing only the required nested argument is rejected");
15947 assert_eq!(missing.code, McpErrorCode::InvalidParams);
15948 assert_eq!(missing.message, "Missing required prompt argument");
15949 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
15950 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
15951
15952 let mut unknown = HashMap::new();
15953 unknown.insert("topic".to_owned(), "release".to_owned());
15954 unknown.insert("extra".to_owned(), "nope".to_owned());
15955 let unknown_error = block_on(request_ctx.get_prompt("prompt-argument-boundary", unknown))
15956 .expect_err("an undeclared nested argument is rejected before the handler");
15957 assert_eq!(unknown_error.code, McpErrorCode::InvalidParams);
15958 assert_eq!(unknown_error.message, "Unknown prompt argument");
15959 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
15960 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
15961
15962 let mut required = HashMap::new();
15963 required.insert("topic".to_owned(), "release".to_owned());
15964 let accepted = block_on(request_ctx.get_prompt("prompt-argument-boundary", required))
15965 .expect("the complete required nested argument is accepted");
15966 assert_eq!(
15967 accepted.messages[0].text.as_deref(),
15968 Some("final prompt-argument-boundary result")
15969 );
15970 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
15971 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
15972 }
15973
15974 fn handle_prompts_get_success() {
15975 let mut r = Router::new();
15976 r.add_prompt(NamedPrompt::new("greet"));
15977 let cx = Cx::for_testing();
15978 let budget = Budget::INFINITE;
15979 let params = GetPromptParams {
15980 name: "greet".to_string(),
15981 arguments: None,
15982 meta: None,
15983 };
15984 let state = SessionState::new();
15985 let request_ctx = request_context(&cx, 1, budget, &state);
15986 let result = r
15987 .handle_prompts_get(&request_ctx, params, state, None, None)
15988 .unwrap();
15989 assert!(result.description.is_some());
15990 }
15991
15992 #[test]
15995 fn handle_prompts_get_not_found() {
15996 let r = Router::new();
15997 let cx = Cx::for_testing();
15998 let budget = Budget::INFINITE;
15999 let params = GetPromptParams {
16000 name: "missing".to_string(),
16001 arguments: None,
16002 meta: None,
16003 };
16004 let state = SessionState::new();
16005 let request_ctx = request_context(&cx, 1, budget, &state);
16006 let err = r
16007 .handle_prompts_get(&request_ctx, params, state, None, None)
16008 .unwrap_err();
16009 assert!(err.message.contains("missing") || err.message.contains("not found"));
16010 }
16011
16012 #[test]
16015 fn handle_prompts_get_zero_poll_balance_allows_handler_without_checkpoint() {
16016 let mut r = Router::new();
16017 r.add_prompt(NamedPrompt::new("p"));
16018 let cx = Cx::for_testing();
16019 let budget = Budget::unlimited().with_poll_quota(0);
16020 let params = GetPromptParams {
16021 name: "p".to_string(),
16022 arguments: None,
16023 meta: None,
16024 };
16025 let state = SessionState::new();
16026 let request_ctx = request_context(&cx, 1, budget, &state);
16027 let result = r
16028 .handle_prompts_get(&request_ctx, params, state, None, None)
16029 .expect("a zero balance is not a retroactive failure");
16030 assert!(result.messages.is_empty());
16031 }
16032
16033 #[test]
16034 fn handler_budget_composition_uses_exact_earliest_deadline() {
16035 let now = Time::from_secs(100);
16036 let at = |seconds| Budget::new().with_deadline(Time::from_secs(seconds));
16037 let cases = [
16038 (Budget::INFINITE, Budget::INFINITE, None, None),
16039 (at(110), Budget::INFINITE, None, Some(Time::from_secs(110))),
16040 (Budget::INFINITE, at(115), None, Some(Time::from_secs(115))),
16041 (
16042 at(110),
16043 at(115),
16044 Some(Duration::from_secs(30)),
16045 Some(Time::from_secs(110)),
16046 ),
16047 (
16048 at(140),
16049 at(130),
16050 Some(Duration::from_secs(5)),
16051 Some(Time::from_secs(105)),
16052 ),
16053 (
16054 at(110),
16055 at(115),
16056 Some(Duration::ZERO),
16057 Some(Time::from_secs(110)),
16058 ),
16059 (
16060 at(90),
16061 at(115),
16062 Some(Duration::from_secs(5)),
16063 Some(Time::from_secs(90)),
16064 ),
16065 (
16066 Budget::INFINITE,
16067 Budget::new().with_deadline(Time::from_nanos(u64::MAX - 1)),
16068 Some(Duration::MAX),
16069 Some(Time::from_nanos(u64::MAX - 1)),
16070 ),
16071 ];
16072
16073 for (ambient, request, handler, expected) in cases {
16074 assert_eq!(
16075 compose_handler_budget(ambient, request, handler, now).deadline,
16076 expected
16077 );
16078 }
16079 }
16080
16081 #[test]
16082 fn alternating_tool_resource_recursion_uses_one_effective_depth() {
16083 let calls = Arc::new(AtomicU64::new(0));
16084 let mut router = Router::new();
16085 router
16086 .add_tool(AlternatingTool {
16087 calls: Arc::clone(&calls),
16088 })
16089 .expect("tool registration succeeds");
16090 router.add_resource(AlternatingResource {
16091 calls: Arc::clone(&calls),
16092 });
16093 let router = Arc::new(router);
16094 let cx = Cx::for_testing();
16095 let state = SessionState::new();
16096 let request_ctx = McpContext::with_state(cx, 1, state.clone())
16097 .with_tool_caller(Arc::new(RouterToolCaller::new(
16098 Arc::clone(&router),
16099 state.clone(),
16100 )))
16101 .with_resource_reader(Arc::new(RouterResourceReader::new(
16102 Arc::clone(&router),
16103 state.clone(),
16104 )));
16105
16106 let error = router
16107 .handle_tools_call(
16108 &request_ctx,
16109 CallToolParams {
16110 name: "alternating_tool".to_string(),
16111 arguments: None,
16112 meta: None,
16113 },
16114 state,
16115 None,
16116 None,
16117 )
16118 .expect_err("alternating recursion must stop at the shared depth limit");
16119
16120 assert_eq!(error.code, McpErrorCode::InternalError);
16121 assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16125 assert_eq!(calls.load(Ordering::Relaxed), 11);
16126 }
16127
16128 #[test]
16129 fn nested_tool_resource_call_shares_parent_cost_ledger() {
16130 let remaining_after_parent_debit = Arc::new(AtomicU64::new(u64::MAX));
16131 let remaining_after_nested_debit = Arc::new(AtomicU64::new(u64::MAX));
16132 let remaining_after_nested_read = Arc::new(AtomicU64::new(u64::MAX));
16133 let mut router = Router::new();
16134 router
16135 .add_tool(CostLedgerTool {
16136 remaining_after_parent_debit: Arc::clone(&remaining_after_parent_debit),
16137 remaining_after_nested_read: Arc::clone(&remaining_after_nested_read),
16138 })
16139 .expect("tool registration succeeds");
16140 router.add_resource(CostLedgerResource {
16141 remaining_after_nested_debit: Arc::clone(&remaining_after_nested_debit),
16142 });
16143
16144 let router = Arc::new(router);
16145 let state = SessionState::new();
16146 let cx = Cx::for_testing_with_budget(Budget::new().with_cost_quota(3));
16147 let request_ctx = McpContext::with_state(cx, 77, state.clone())
16148 .with_tool_caller(Arc::new(RouterToolCaller::new(
16149 Arc::clone(&router),
16150 state.clone(),
16151 )))
16152 .with_resource_reader(Arc::new(RouterResourceReader::new(router, state)));
16153
16154 let result = block_on(request_ctx.call_tool("cost_ledger_tool", serde_json::json!({})))
16155 .expect("parent and nested debits fit the shared cost quota");
16156
16157 assert!(!result.is_error);
16158 assert_eq!(remaining_after_parent_debit.load(Ordering::Relaxed), 2);
16159 assert_eq!(remaining_after_nested_debit.load(Ordering::Relaxed), 1);
16160 assert_eq!(remaining_after_nested_read.load(Ordering::Relaxed), 1);
16161 assert_eq!(request_ctx.budget().cost_quota, Some(1));
16162 }
16163
16164 #[test]
16165 fn nested_tool_calls_preserve_framework_terminal_errors() {
16166 let mut router = Router::new();
16167 router
16168 .add_tool(ErrorTool {
16169 name: "nested_cancelled",
16170 code: McpErrorCode::RequestCancelled,
16171 })
16172 .expect("tool registration succeeds");
16173 router
16174 .add_tool(ErrorTool {
16175 name: "nested_internal",
16176 code: McpErrorCode::InternalError,
16177 })
16178 .expect("tool registration succeeds");
16179 router
16180 .add_tool(ErrorTool {
16181 name: "nested_tool_failure",
16182 code: McpErrorCode::ToolExecutionError,
16183 })
16184 .expect("tool registration succeeds");
16185 let router = Arc::new(router);
16186 let cx = Cx::for_testing();
16187 let state = SessionState::new();
16188 let request_ctx = McpContext::with_state(cx, 88, state.clone())
16189 .with_tool_caller(Arc::new(RouterToolCaller::new(Arc::clone(&router), state)));
16190
16191 for (name, expected) in [
16192 ("nested_cancelled", McpErrorCode::RequestCancelled),
16193 ("nested_internal", McpErrorCode::InternalError),
16194 ] {
16195 let error = block_on(request_ctx.call_tool(name, serde_json::json!({})))
16196 .expect_err("framework terminal errors must remain outer failures");
16197 assert_eq!(error.code, expected);
16198 }
16199
16200 let tool_failure =
16201 block_on(request_ctx.call_tool("nested_tool_failure", serde_json::json!({})))
16202 .expect("ordinary tool failures remain protocol-level tool results");
16203 assert!(tool_failure.is_error);
16204 }
16205
16206 #[test]
16207 fn nested_call_tool_from_legacy_surface_uses_legacy_hook() {
16208 let final_calls = Arc::new(AtomicUsize::new(0));
16209 let legacy_calls = Arc::new(AtomicUsize::new(0));
16210 let mut router = Router::new();
16211 router
16212 .add_tool(DuplicateInvariantTool {
16213 label: "nested-legacy",
16214 schema_property: "allowed",
16215 legacy_calls: Arc::clone(&legacy_calls),
16216 final_calls: Arc::clone(&final_calls),
16217 })
16218 .expect("tool registration succeeds");
16219 let router = Arc::new(router);
16220 let cx = Cx::for_testing();
16221 let state = SessionState::new();
16222 let request_ctx = McpContext::with_state(cx, 1, state.clone())
16223 .with_tool_caller(Arc::new(RouterToolCaller::new(Arc::clone(&router), state)));
16224
16225 let rejected = block_on(request_ctx.call_tool(
16226 "duplicate-invariant-tool",
16227 serde_json::json!({"allowed": true, "extra": true}),
16228 ))
16229 .expect_err("legacy nested schema rejection remains an outer invalid-params error");
16230 assert_eq!(rejected.code, McpErrorCode::InvalidParams);
16231 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
16232 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
16233
16234 let result = block_on(request_ctx.call_tool(
16235 "duplicate-invariant-tool",
16236 serde_json::json!({"allowed": true}),
16237 ))
16238 .expect("legacy nested call_tool reaches the legacy hook");
16239 assert!(!result.is_error);
16240 assert_eq!(result.first_text(), Some("nested-legacy"));
16241 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
16242 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
16243 }
16244
16245 #[test]
16246 fn nested_call_tool_from_final_surface_validates_before_handler() {
16247 let final_calls = Arc::new(AtomicUsize::new(0));
16248 let legacy_calls = Arc::new(AtomicUsize::new(0));
16249 let mut router = Router::new();
16250 router
16251 .add_tool(DuplicateInvariantTool {
16252 label: "nested-final",
16253 schema_property: "allowed",
16254 legacy_calls: Arc::clone(&legacy_calls),
16255 final_calls: Arc::clone(&final_calls),
16256 })
16257 .expect("tool registration succeeds");
16258 let router = Arc::new(router);
16259 let cx = Cx::for_testing();
16260 let state = SessionState::new();
16261 let request_ctx = McpContext::with_state(cx, 1, state.clone())
16262 .with_final_request_surface(true)
16263 .with_tool_caller(Arc::new(RouterToolCaller::new(Arc::clone(&router), state)));
16264
16265 let rejected = block_on(request_ctx.call_tool(
16266 "duplicate-invariant-tool",
16267 serde_json::json!({"allowed": true, "extra": true}),
16268 ))
16269 .expect("schema rejection remains a tool-level error result");
16270 assert!(rejected.is_error);
16271 assert_eq!(
16272 rejected.first_text(),
16273 Some("Tool arguments do not match the declared input schema.")
16274 );
16275 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
16276 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
16277
16278 let accepted = block_on(request_ctx.call_tool(
16279 "duplicate-invariant-tool",
16280 serde_json::json!({"allowed": true}),
16281 ))
16282 .expect("the complete nested final arguments are accepted");
16283 assert!(!accepted.is_error);
16284 assert_eq!(accepted.first_text(), Some("nested-final"));
16285 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16286 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
16287 }
16288
16289 #[test]
16290 fn nested_read_resource_from_final_surface_uses_final_hook() {
16291 let final_calls = Arc::new(AtomicUsize::new(0));
16292 let legacy_calls = Arc::new(AtomicUsize::new(0));
16293 let mut router = Router::new();
16294 router.add_resource(EraBoundaryResource {
16295 legacy_calls: Arc::clone(&legacy_calls),
16296 final_calls: Arc::clone(&final_calls),
16297 });
16298 let router = Arc::new(router);
16299 let cx = Cx::for_testing();
16300 let state = SessionState::new();
16301 let request_ctx = McpContext::with_state(cx, 1, state.clone())
16302 .with_final_request_surface(true)
16303 .with_resource_reader(Arc::new(RouterResourceReader::new(
16304 Arc::clone(&router),
16305 state,
16306 )));
16307
16308 let accepted = block_on(request_ctx.read_resource("mcp://era-boundary"))
16309 .expect("nested final resource read reaches the final hook");
16310 assert_eq!(
16311 accepted.contents[0].text.as_deref(),
16312 Some("final resource result")
16313 );
16314 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16315 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
16316
16317 let missing = block_on(request_ctx.read_resource("mcp://missing"))
16318 .expect_err("an unknown nested final resource is invalid params");
16319 assert_eq!(missing.code, McpErrorCode::InvalidParams);
16320 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16321 }
16322
16323 #[test]
16324 fn nested_read_resource_from_legacy_surface_uses_legacy_hook() {
16325 let final_calls = Arc::new(AtomicUsize::new(0));
16326 let legacy_calls = Arc::new(AtomicUsize::new(0));
16327 let mut router = Router::new();
16328 router.add_resource(EraBoundaryResource {
16329 legacy_calls: Arc::clone(&legacy_calls),
16330 final_calls: Arc::clone(&final_calls),
16331 });
16332 let router = Arc::new(router);
16333 let cx = Cx::for_testing();
16334 let state = SessionState::new();
16335 let request_ctx = McpContext::with_state(cx, 1, state.clone()).with_resource_reader(
16336 Arc::new(RouterResourceReader::new(Arc::clone(&router), state)),
16337 );
16338
16339 let accepted = block_on(request_ctx.read_resource("mcp://era-boundary"))
16340 .expect("legacy nested resource read reaches the legacy hook");
16341 assert_eq!(
16342 accepted.contents[0].text.as_deref(),
16343 Some("legacy resource result")
16344 );
16345 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
16346 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
16347 }
16348
16349 #[test]
16350 fn manual_handler_timeout_is_read_exposed_and_enforced() {
16351 let observed_deadline = Arc::new(Mutex::new(None));
16352 let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
16353 let mut router = Router::new();
16354 router
16358 .add_tool(BudgetProbeTool {
16359 timeout: Some(Duration::from_millis(10)),
16360 delay: Duration::from_millis(100),
16361 observed_deadline: Arc::clone(&observed_deadline),
16362 timeout_read: Arc::clone(&timeout_read),
16363 })
16364 .expect("tool registration succeeds");
16365 let cx = Cx::for_testing();
16366 let state = SessionState::new();
16367 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
16368 let error = router
16369 .handle_tools_call(
16370 &request_ctx,
16371 CallToolParams {
16372 name: "budget_probe".to_string(),
16373 arguments: None,
16374 meta: None,
16375 },
16376 state,
16377 None,
16378 None,
16379 )
16380 .expect_err("the handler deadline must reject a late completion");
16381
16382 assert!(timeout_read.load(Ordering::Relaxed));
16383 assert!(
16384 observed_deadline
16385 .lock()
16386 .unwrap_or_else(std::sync::PoisonError::into_inner)
16387 .is_some()
16388 );
16389 assert_eq!(error.code, McpErrorCode::RequestCancelled);
16390 assert_eq!(error.message, "Request timeout exceeded");
16391 assert!(!cx.is_cancel_requested());
16392 }
16393
16394 #[test]
16395 fn admitted_definition_is_not_requeried_inside_handler_budget() {
16396 let definition_reads = Arc::new(AtomicU64::new(0));
16397 let called = Arc::new(std::sync::atomic::AtomicBool::new(false));
16398 let mut router = Router::new();
16399 router
16400 .add_tool(SlowDefinitionTool {
16401 definition_reads: Arc::clone(&definition_reads),
16402 called: Arc::clone(&called),
16403 })
16404 .expect("the definition snapshot is admitted once");
16405 let cx = Cx::for_testing();
16406 let state = SessionState::new();
16407 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
16408
16409 router
16410 .handle_tools_call(
16411 &request_ctx,
16412 CallToolParams {
16413 name: "slow_definition".to_string(),
16414 arguments: None,
16415 meta: None,
16416 },
16417 state,
16418 None,
16419 None,
16420 )
16421 .expect("dispatch uses the admitted snapshot and reaches the handler");
16422
16423 assert!(called.load(Ordering::Relaxed));
16424 assert_eq!(definition_reads.load(Ordering::Relaxed), 1);
16425 }
16426
16427 #[test]
16428 fn zero_handler_timeout_cannot_relax_request_deadline() {
16429 let observed_deadline = Arc::new(Mutex::new(None));
16430 let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
16431 let request_deadline = wall_now().saturating_add_nanos(5_000_000_000);
16432 let mut router = Router::new();
16433 router
16434 .add_tool(BudgetProbeTool {
16435 timeout: Some(Duration::ZERO),
16436 delay: Duration::ZERO,
16437 observed_deadline: Arc::clone(&observed_deadline),
16438 timeout_read: Arc::clone(&timeout_read),
16439 })
16440 .expect("tool registration succeeds");
16441 let cx = Cx::for_testing();
16442 let state = SessionState::new();
16443 let request_ctx = request_context(
16444 &cx,
16445 1,
16446 Budget::new().with_deadline(request_deadline),
16447 &state,
16448 );
16449 router
16450 .handle_tools_call(
16451 &request_ctx,
16452 CallToolParams {
16453 name: "budget_probe".to_string(),
16454 arguments: None,
16455 meta: None,
16456 },
16457 state,
16458 None,
16459 None,
16460 )
16461 .expect("zero adds no ceiling but preserves the request deadline");
16462
16463 assert!(timeout_read.load(Ordering::Relaxed));
16464 assert_eq!(
16465 *observed_deadline
16466 .lock()
16467 .unwrap_or_else(std::sync::PoisonError::into_inner),
16468 Some(request_deadline)
16469 );
16470 }
16471
16472 #[test]
16473 fn ambient_deadline_remains_visible_when_server_and_handler_are_looser() {
16474 let observed_deadline = Arc::new(Mutex::new(None));
16475 let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
16476 let ambient_deadline = wall_now().saturating_add_nanos(2_000_000_000);
16477 let request_deadline = ambient_deadline.saturating_add_nanos(2_000_000_000);
16478 let mut router = Router::new();
16479 router
16480 .add_tool(BudgetProbeTool {
16481 timeout: Some(Duration::from_secs(10)),
16482 delay: Duration::ZERO,
16483 observed_deadline: Arc::clone(&observed_deadline),
16484 timeout_read,
16485 })
16486 .expect("tool registration succeeds");
16487 let cx = Cx::for_testing_with_budget(Budget::new().with_deadline(ambient_deadline));
16488 let state = SessionState::new();
16489 let request_ctx = request_context(
16490 &cx,
16491 1,
16492 Budget::new().with_deadline(request_deadline),
16493 &state,
16494 );
16495 router
16496 .handle_tools_call(
16497 &request_ctx,
16498 CallToolParams {
16499 name: "budget_probe".to_string(),
16500 arguments: None,
16501 meta: None,
16502 },
16503 state,
16504 None,
16505 None,
16506 )
16507 .expect("looser inner limits must preserve the ambient deadline");
16508
16509 assert_eq!(
16510 *observed_deadline
16511 .lock()
16512 .unwrap_or_else(std::sync::PoisonError::into_inner),
16513 Some(ambient_deadline)
16514 );
16515 }
16516
16517 fn assert_sanitized_panic_tool(handler: impl ToolHandler + 'static, name: &str) {
16518 let mut router = Router::new();
16519 router
16520 .add_tool(handler)
16521 .expect("panic-test tool registration succeeds");
16522 let cx = Cx::for_testing();
16523 let state = SessionState::new();
16524 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
16525 let error = router
16526 .handle_tools_call(
16527 &request_ctx,
16528 CallToolParams {
16529 name: name.to_string(),
16530 arguments: None,
16531 meta: None,
16532 },
16533 state,
16534 None,
16535 None,
16536 )
16537 .expect_err("panic must terminate as a sanitized protocol error");
16538 let wire = serde_json::to_string(&error).expect("error serializes");
16539 assert_eq!(error.code, McpErrorCode::InternalError);
16540 assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16541 assert_eq!(error.data, None);
16542 assert!(!wire.contains(PANIC_CANARY));
16543 assert!(!wire.contains("Bearer"));
16544 assert!(!wire.contains("secret"));
16545 assert!(!wire.contains("peer-secret"));
16546 assert!(!wire.contains('\u{001b}'));
16547 assert!(wire.len() < 256);
16548 assert!(!cx.is_cancel_requested());
16549 }
16550
16551 #[test]
16552 fn unwinding_string_and_non_string_panics_have_one_fixed_peer_error() {
16553 assert_sanitized_panic_tool(
16554 UnwindingPanicTool {
16555 payload: "Bearer actual-secret".to_string(),
16556 non_string: false,
16557 },
16558 "panic_tool",
16559 );
16560 assert_sanitized_panic_tool(
16561 UnwindingPanicTool {
16562 payload: PANIC_CANARY.to_string(),
16563 non_string: true,
16564 },
16565 "panic_tool",
16566 );
16567 }
16568
16569 #[test]
16570 fn four_valued_panic_payload_is_never_rendered_for_peer() {
16571 assert_sanitized_panic_tool(
16572 OutcomePanicTool(format!("{PANIC_CANARY}{}", "y".repeat(64 * 1024))),
16573 "outcome_panic_tool",
16574 );
16575 }
16576
16577 #[test]
16578 fn opaque_internal_handler_errors_use_the_fixed_peer_contract() {
16579 let cx = Cx::for_testing();
16580
16581 let mut tool_router = Router::new();
16582 tool_router
16583 .add_tool(OpaqueInternalTool)
16584 .expect("tool registration succeeds");
16585 let tool_state = SessionState::new();
16586 let tool_ctx = request_context(&cx, 1, Budget::INFINITE, &tool_state);
16587 let tool_error = tool_router
16588 .handle_tools_call(
16589 &tool_ctx,
16590 CallToolParams {
16591 name: "opaque_internal_tool".to_string(),
16592 arguments: None,
16593 meta: None,
16594 },
16595 tool_state,
16596 None,
16597 None,
16598 )
16599 .expect_err("opaque internal tool errors must remain protocol failures");
16600
16601 let mut resource_router = Router::new();
16602 resource_router.add_resource(OpaqueInternalResource);
16603 let resource_state = SessionState::new();
16604 let resource_ctx = request_context(&cx, 2, Budget::INFINITE, &resource_state);
16605 let resource_error = resource_router
16606 .handle_resources_read(
16607 &resource_ctx,
16608 &ReadResourceParams {
16609 uri: "opaque://internal".to_string(),
16610 meta: None,
16611 },
16612 resource_state,
16613 None,
16614 None,
16615 )
16616 .expect_err("opaque internal resource errors must be sanitized");
16617
16618 let mut prompt_router = Router::new();
16619 prompt_router.add_prompt(OpaqueInternalPrompt);
16620 let prompt_state = SessionState::new();
16621 let prompt_ctx = request_context(&cx, 3, Budget::INFINITE, &prompt_state);
16622 let prompt_error = prompt_router
16623 .handle_prompts_get(
16624 &prompt_ctx,
16625 GetPromptParams {
16626 name: "opaque_internal_prompt".to_string(),
16627 arguments: None,
16628 meta: None,
16629 },
16630 prompt_state,
16631 None,
16632 None,
16633 )
16634 .expect_err("opaque internal prompt errors must be sanitized");
16635
16636 for error in [tool_error, resource_error, prompt_error] {
16637 let wire = serde_json::to_string(&error).expect("error serializes");
16638 assert_eq!(error.code, McpErrorCode::InternalError);
16639 assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16640 assert_eq!(error.data, None);
16641 assert!(!wire.contains(PANIC_CANARY));
16642 assert!(wire.len() < 256);
16643 }
16644 }
16645
16646 #[test]
16647 fn resource_and_prompt_panics_use_same_sanitized_contract() {
16648 let mut resource_router = Router::new();
16649 resource_router.add_resource(PanicResource);
16650 let resource_cx = Cx::for_testing();
16651 let resource_state = SessionState::new();
16652 let resource_ctx = request_context(&resource_cx, 1, Budget::INFINITE, &resource_state);
16653 let resource_error = resource_router
16654 .handle_resources_read(
16655 &resource_ctx,
16656 &ReadResourceParams {
16657 uri: "panic://resource".to_string(),
16658 meta: None,
16659 },
16660 resource_state,
16661 None,
16662 None,
16663 )
16664 .expect_err("resource panic must be sanitized");
16665 assert_eq!(resource_error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16666
16667 let mut prompt_router = Router::new();
16668 prompt_router.add_prompt(PanicPrompt);
16669 let prompt_cx = Cx::for_testing();
16670 let prompt_state = SessionState::new();
16671 let prompt_ctx = request_context(&prompt_cx, 1, Budget::INFINITE, &prompt_state);
16672 let prompt_error = prompt_router
16673 .handle_prompts_get(
16674 &prompt_ctx,
16675 GetPromptParams {
16676 name: "panic_prompt".to_string(),
16677 arguments: None,
16678 meta: None,
16679 },
16680 prompt_state,
16681 None,
16682 None,
16683 )
16684 .expect_err("prompt panic must be sanitized");
16685 assert_eq!(prompt_error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16686
16687 for error in [resource_error, prompt_error] {
16688 let wire = serde_json::to_string(&error).expect("error serializes");
16689 assert!(!wire.contains(PANIC_CANARY));
16690 assert!(wire.len() < 256);
16691 }
16692 }
16693
16694 #[test]
16695 fn admitted_tool_definition_is_snapshotted_while_other_list_panics_are_sanitized() {
16696 let cx = Cx::for_testing();
16697 let request_ctx = McpContext::new(cx, 1);
16698
16699 let mut tool_router = Router::new();
16700 tool_router
16701 .add_tool(DefinitionPanicTool(std::sync::atomic::AtomicBool::new(
16702 false,
16703 )))
16704 .expect("the definition hook is read exactly once during admission");
16705 let tools = tool_router
16706 .handle_tools_list(&request_ctx, ListToolsParams::default(), None)
16707 .expect("listing clones the immutable admitted definition snapshot");
16708 assert_eq!(tools.tools.len(), 1);
16709 assert_eq!(tools.tools[0].name, "definition_panic_tool");
16710
16711 let mut resource_router = Router::new();
16712 resource_router.add_resource(DefinitionPanicResource(std::sync::atomic::AtomicBool::new(
16713 false,
16714 )));
16715 let resource_error = resource_router
16716 .handle_resources_list(&request_ctx, ListResourcesParams::default(), None)
16717 .expect_err("resource definition panic must be sanitized");
16718
16719 let mut prompt_router = Router::new();
16720 prompt_router.add_prompt(DefinitionPanicPrompt(std::sync::atomic::AtomicBool::new(
16721 false,
16722 )));
16723 let prompt_error = prompt_router
16724 .handle_prompts_list(&request_ctx, ListPromptsParams::default(), None)
16725 .expect_err("prompt definition panic must be sanitized");
16726
16727 for error in [resource_error, prompt_error] {
16728 let wire = serde_json::to_string(&error).expect("error serializes");
16729 assert_eq!(error.code, McpErrorCode::InternalError);
16730 assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16731 assert_eq!(error.data, None);
16732 assert!(!wire.contains(PANIC_CANARY));
16733 assert!(wire.len() < 256);
16734 }
16735 }
16736
16737 #[test]
16740 fn add_resource_with_behavior_template_error_on_duplicate() {
16741 struct TmplResource;
16742 impl ResourceHandler for TmplResource {
16743 fn definition(&self) -> Resource {
16744 Resource {
16745 uri: "db://placeholder".to_string(),
16746 name: "db".to_string(),
16747 description: None,
16748 mime_type: None,
16749 icon: None,
16750 version: None,
16751 tags: vec![],
16752 }
16753 }
16754 fn template(&self) -> Option<ResourceTemplate> {
16755 Some(ResourceTemplate {
16756 uri_template: "db://{table}".to_string(),
16757 name: "db".to_string(),
16758 description: None,
16759 mime_type: None,
16760 icon: None,
16761 version: None,
16762 tags: vec![],
16763 })
16764 }
16765 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
16766 Ok(vec![])
16767 }
16768 }
16769 let mut r = Router::new();
16770 r.add_resource(TmplResource);
16771 let err = r
16772 .add_resource_with_behavior(TmplResource, crate::DuplicateBehavior::Error)
16773 .unwrap_err();
16774 assert!(err.message.contains("already exists"));
16775 }
16776
16777 #[test]
16780 fn add_resource_with_behavior_template_ignore_on_duplicate() {
16781 struct TmplResource2;
16782 impl ResourceHandler for TmplResource2 {
16783 fn definition(&self) -> Resource {
16784 Resource {
16785 uri: "cache://placeholder".to_string(),
16786 name: "cache".to_string(),
16787 description: None,
16788 mime_type: None,
16789 icon: None,
16790 version: None,
16791 tags: vec![],
16792 }
16793 }
16794 fn template(&self) -> Option<ResourceTemplate> {
16795 Some(ResourceTemplate {
16796 uri_template: "cache://{key}".to_string(),
16797 name: "cache".to_string(),
16798 description: None,
16799 mime_type: None,
16800 icon: None,
16801 version: None,
16802 tags: vec![],
16803 })
16804 }
16805 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
16806 Ok(vec![])
16807 }
16808 }
16809 let mut r = Router::new();
16810 r.add_resource(TmplResource2);
16811 r.add_resource_with_behavior(TmplResource2, crate::DuplicateBehavior::Ignore)
16812 .unwrap();
16813 assert_eq!(r.resource_templates_count(), 1);
16814 }
16815
16816 #[test]
16819 fn add_resource_with_behavior_template_warn_on_duplicate() {
16820 struct TmplResource3;
16821 impl ResourceHandler for TmplResource3 {
16822 fn definition(&self) -> Resource {
16823 Resource {
16824 uri: "log://placeholder".to_string(),
16825 name: "log".to_string(),
16826 description: None,
16827 mime_type: None,
16828 icon: None,
16829 version: None,
16830 tags: vec![],
16831 }
16832 }
16833 fn template(&self) -> Option<ResourceTemplate> {
16834 Some(ResourceTemplate {
16835 uri_template: "log://{entry}".to_string(),
16836 name: "log".to_string(),
16837 description: None,
16838 mime_type: None,
16839 icon: None,
16840 version: None,
16841 tags: vec![],
16842 })
16843 }
16844 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
16845 Ok(vec![])
16846 }
16847 }
16848 let mut r = Router::new();
16849 r.add_resource(TmplResource3);
16850 r.add_resource_with_behavior(TmplResource3, crate::DuplicateBehavior::Warn)
16851 .unwrap();
16852 assert_eq!(r.resource_templates_count(), 1);
16853 }
16854
16855 #[test]
16858 fn mount_tools_warns_on_tool_conflict() {
16859 let mut main = Router::new();
16860 main.add_tool(NamedTool::new("t"))
16861 .expect("tool registration succeeds");
16862 let mut sub = Router::new();
16863 sub.add_tool(NamedTool::new("t"))
16864 .expect("tool registration succeeds");
16865 let result = main.mount_tools(sub, None);
16866 assert!(!result.warnings.is_empty());
16867 assert!(result.warnings[0].contains("Tool"));
16868 }
16869
16870 #[test]
16873 fn mount_prompts_warns_on_prompt_conflict() {
16874 let mut main = Router::new();
16875 main.add_prompt(NamedPrompt::new("p"));
16876 let mut sub = Router::new();
16877 sub.add_prompt(NamedPrompt::new("p"));
16878 let result = main.mount_prompts(sub, None);
16879 assert!(!result.warnings.is_empty());
16880 assert!(result.warnings[0].contains("Prompt"));
16881 }
16882
16883 #[test]
16886 fn invalid_cursor_returns_error() {
16887 let mut r = Router::new();
16888 r.set_list_page_size(Some(1));
16889 r.add_tool(NamedTool::new("a"))
16890 .expect("tool registration succeeds");
16891 let cx = Cx::for_testing();
16892 let params = ListToolsParams {
16893 cursor: Some("not-valid-base64!!!".to_string()),
16894 include_tags: None,
16895 exclude_tags: None,
16896 };
16897 let request_ctx = McpContext::new(cx, 1);
16898 let err = r.handle_tools_list(&request_ctx, params, None).unwrap_err();
16899 assert!(err.message.contains("cursor") || err.message.contains("Invalid"));
16900 }
16901
16902 #[test]
16905 fn set_list_page_size_zero_disables_pagination() {
16906 let mut r = Router::new();
16907 r.set_list_page_size(Some(0));
16908 r.add_tool(NamedTool::new("a"))
16909 .expect("tool registration succeeds");
16910 r.add_tool(NamedTool::new("b"))
16911 .expect("tool registration succeeds");
16912 let cx = Cx::for_testing();
16913 let params = ListToolsParams {
16914 cursor: None,
16915 include_tags: None,
16916 exclude_tags: None,
16917 };
16918 let request_ctx = McpContext::new(cx, 1);
16919 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
16920 assert_eq!(result.tools.len(), 2);
16922 assert!(result.next_cursor.is_none());
16923 }
16924
16925 #[test]
16928 fn strict_input_validation_toggle() {
16929 let mut r = Router::new();
16930 assert!(!r.strict_input_validation());
16931 r.set_strict_input_validation(true);
16932 assert!(r.strict_input_validation());
16933 r.set_strict_input_validation(false);
16934 assert!(!r.strict_input_validation());
16935 }
16936
16937 #[test]
16940 fn handle_tools_call_cancelled_cx_returns_error() {
16941 let mut r = Router::new();
16942 r.add_tool(NamedTool::new("t"))
16943 .expect("tool registration succeeds");
16944 let cx = Cx::for_testing();
16945 cx.set_cancel_requested(true);
16946 let budget = Budget::INFINITE;
16947 let params = CallToolParams {
16948 name: "t".to_string(),
16949 arguments: None,
16950 meta: None,
16951 };
16952 let state = SessionState::new();
16953 let request_ctx = request_context(&cx, 1, budget, &state);
16954 let err = r
16955 .handle_tools_call(&request_ctx, params, state, None, None)
16956 .unwrap_err();
16957 assert_eq!(err.code, McpErrorCode::RequestCancelled);
16958 }
16959
16960 #[test]
16961 fn handle_resources_read_cancelled_cx_returns_error() {
16962 let mut r = Router::new();
16963 r.add_resource(NamedResource::new("file:///a.txt"));
16964 let cx = Cx::for_testing();
16965 cx.set_cancel_requested(true);
16966 let budget = Budget::INFINITE;
16967 let params = ReadResourceParams {
16968 uri: "file:///a.txt".to_string(),
16969 meta: None,
16970 };
16971 let state = SessionState::new();
16972 let request_ctx = request_context(&cx, 1, budget, &state);
16973 let err = r
16974 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
16975 .unwrap_err();
16976 assert_eq!(err.code, McpErrorCode::RequestCancelled);
16977 }
16978
16979 #[test]
16980 fn handle_prompts_get_cancelled_cx_returns_error() {
16981 let mut r = Router::new();
16982 r.add_prompt(NamedPrompt::new("p"));
16983 let cx = Cx::for_testing();
16984 cx.set_cancel_requested(true);
16985 let budget = Budget::INFINITE;
16986 let params = GetPromptParams {
16987 name: "p".to_string(),
16988 arguments: None,
16989 meta: None,
16990 };
16991 let state = SessionState::new();
16992 let request_ctx = request_context(&cx, 1, budget, &state);
16993 let err = r
16994 .handle_prompts_get(&request_ctx, params, state, None, None)
16995 .unwrap_err();
16996 assert_eq!(err.code, McpErrorCode::RequestCancelled);
16997 }
16998
16999 #[test]
17000 fn completion_handler_dispatches_exact_legacy_and_final_contracts() {
17001 let mut router = Router::new();
17002 assert!(!router.has_completion_handler());
17003 router.add_completion_handler(EchoCompletion);
17004 router.add_prompt(PromptArgumentBoundary {
17005 final_calls: Arc::new(AtomicUsize::new(0)),
17006 legacy_calls: Arc::new(AtomicUsize::new(0)),
17007 });
17008 assert!(router.has_completion_handler());
17009 assert!(
17010 router
17011 .server_discovery_behavior_registry()
17012 .contains(ServerBehavior::CompletionComplete),
17013 "discovery advertises completion only after the handler is installed"
17014 );
17015
17016 let cx = Cx::for_testing();
17017 let state = SessionState::new();
17018 let request_ctx = request_context(&cx, 87, Budget::INFINITE, &state);
17019 let legacy_request = JsonRpcRequest::new(
17020 COMPLETION_COMPLETE,
17021 Some(serde_json::json!({
17022 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17023 "argument": {"name": "topic", "value": "sta"},
17024 })),
17025 87_i64,
17026 );
17027 let legacy = router
17028 .dispatch_legacy_completion(&request_ctx, &legacy_request)
17029 .expect("the exact legacy request reaches the registered completion handler");
17030 assert!(
17031 legacy.get("resultType").is_none(),
17032 "the exact legacy completion result remains discriminator-free"
17033 );
17034 assert_eq!(
17035 legacy["completion"]["values"],
17036 serde_json::json!(["staging"])
17037 );
17038
17039 let modern = router
17040 .dispatch_stateless(
17041 &request_ctx,
17042 &JsonRpcRequest::new(
17043 COMPLETION_COMPLETE,
17044 Some(serde_json::json!({
17045 "_meta": {
17046 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17047 "io.modelcontextprotocol/clientCapabilities": {},
17048 },
17049 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17050 "argument": {"name": "topic", "value": "sta"},
17051 })),
17052 88_i64,
17053 ),
17054 )
17055 .expect("the final request reaches the same registered completion handler");
17056 assert_eq!(
17057 modern.get("resultType"),
17058 Some(&serde_json::json!("complete"))
17059 );
17060 assert_eq!(modern.get("completion"), legacy.get("completion"));
17061 }
17062
17063 #[test]
17064 fn final_completion_routes_to_the_registered_prompt_or_resource_provider() {
17065 let prompt_calls = Arc::new(AtomicUsize::new(0));
17066 let resource_calls = Arc::new(AtomicUsize::new(0));
17067 let fallback_calls = Arc::new(AtomicUsize::new(0));
17068 let mut router = Router::new();
17069 router.add_completion_handler(ProviderCompletion {
17070 value: "fallback-provider",
17071 final_calls: Arc::clone(&fallback_calls),
17072 });
17073 router.add_prompt(PromptArgumentBoundary {
17074 final_calls: Arc::new(AtomicUsize::new(0)),
17075 legacy_calls: Arc::new(AtomicUsize::new(0)),
17076 });
17077 router.add_resource_template(marked_template("resource://first/{id}", "first"));
17078 router.add_resource_template(marked_template("resource://second/{id}", "second"));
17079 router.add_prompt_completion_handler(
17080 "prompt-argument-boundary",
17081 ProviderCompletion {
17082 value: "prompt-provider",
17083 final_calls: Arc::clone(&prompt_calls),
17084 },
17085 );
17086 router.add_resource_template_completion_handler(
17087 "resource://first/{id}",
17088 ProviderCompletion {
17089 value: "resource-provider",
17090 final_calls: Arc::clone(&resource_calls),
17091 },
17092 );
17093
17094 assert!(
17095 router
17096 .server_discovery_behavior_registry()
17097 .contains(ServerBehavior::CompletionComplete),
17098 "a registered final completion provider enables discovery"
17099 );
17100
17101 let cx = Cx::for_testing();
17102 let state = SessionState::new();
17103 let request_ctx = request_context(&cx, 187, Budget::INFINITE, &state);
17104 let metadata = serde_json::json!({
17105 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17106 "io.modelcontextprotocol/clientCapabilities": {},
17107 });
17108 let prompt_request = JsonRpcRequest::new(
17109 COMPLETION_COMPLETE,
17110 Some(serde_json::json!({
17111 "_meta": metadata.clone(),
17112 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17113 "argument": {"name": "topic", "value": "pro"},
17114 })),
17115 187_i64,
17116 );
17117 let resource_request = JsonRpcRequest::new(
17118 COMPLETION_COMPLETE,
17119 Some(serde_json::json!({
17120 "_meta": metadata,
17121 "ref": {"type": "ref/resource", "uri": "resource://first/{id}"},
17122 "argument": {"name": "id", "value": "pro"},
17123 })),
17124 188_i64,
17125 );
17126
17127 let prompt = router
17128 .dispatch_stateless(&request_ctx, &prompt_request)
17129 .expect("the registered prompt provider handles its exact target");
17130 let resource = router
17131 .dispatch_stateless(&request_ctx, &resource_request)
17132 .expect("the registered resource provider handles its exact target");
17133 assert_eq!(
17134 prompt["completion"]["values"],
17135 serde_json::json!(["prompt-provider"])
17136 );
17137 assert_eq!(
17138 resource["completion"]["values"],
17139 serde_json::json!(["resource-provider"])
17140 );
17141 assert_eq!(prompt_calls.load(Ordering::SeqCst), 1);
17142 assert_eq!(resource_calls.load(Ordering::SeqCst), 1);
17143 assert_eq!(
17144 fallback_calls.load(Ordering::SeqCst),
17145 0,
17146 "a target-specific provider takes precedence over the installed fallback"
17147 );
17148
17149 let mut unregistered_provider = resource_request.clone();
17150 unregistered_provider
17151 .params
17152 .as_mut()
17153 .and_then(serde_json::Value::as_object_mut)
17154 .and_then(|params| params.get_mut("ref"))
17155 .and_then(serde_json::Value::as_object_mut)
17156 .expect("completion reference is an object")
17157 .insert(
17158 "uri".to_owned(),
17159 serde_json::json!("resource://second/{id}"),
17160 );
17161 assert_eq!(resource_request.method, unregistered_provider.method);
17162 assert_eq!(resource_request.id, unregistered_provider.id);
17163 assert_eq!(
17164 resource_request
17165 .params
17166 .as_ref()
17167 .and_then(|params| params.get("argument")),
17168 unregistered_provider
17169 .params
17170 .as_ref()
17171 .and_then(|params| params.get("argument")),
17172 "the referenced resource template is the sole planted dimension"
17173 );
17174 let fallback = router
17175 .dispatch_stateless(&request_ctx, &unregistered_provider)
17176 .expect("an admitted target without a provider-specific handler reaches the fallback");
17177 assert_eq!(
17178 fallback["completion"]["values"],
17179 serde_json::json!(["fallback-provider"])
17180 );
17181 assert_eq!(prompt_calls.load(Ordering::SeqCst), 1);
17182 assert_eq!(resource_calls.load(Ordering::SeqCst), 1);
17183 assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
17184 }
17185
17186 #[test]
17187 fn modern_connection_visibility_blocks_final_completion_before_provider_invocation() {
17188 let final_calls = Arc::new(AtomicUsize::new(0));
17189 let mut router = Router::new();
17190 router.add_completion_handler(CountingCompletion {
17191 final_calls: Arc::clone(&final_calls),
17192 });
17193 router.add_prompt(PromptArgumentBoundary {
17194 final_calls: Arc::new(AtomicUsize::new(0)),
17195 legacy_calls: Arc::new(AtomicUsize::new(0)),
17196 });
17197 const TEMPLATE_URI: &str = "resource://completion-visibility/{id}";
17198 router.add_resource_template(marked_template(TEMPLATE_URI, "completion-visibility"));
17199
17200 let prompt_catalog_before = serde_json::to_vec(&router.prompts())
17201 .expect("prompt catalog serializes before completion dispatch");
17202 let template_catalog_before = serde_json::to_vec(&router.resource_templates())
17203 .expect("resource-template catalog serializes before completion dispatch");
17204 let prompt_request = JsonRpcRequest::new(
17205 COMPLETION_COMPLETE,
17206 Some(serde_json::json!({
17207 "_meta": {
17208 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17209 "io.modelcontextprotocol/clientCapabilities": {},
17210 },
17211 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17212 "argument": {"name": "topic", "value": "sta"},
17213 })),
17214 1_871_i64,
17215 );
17216 let resource_request = JsonRpcRequest::new(
17217 COMPLETION_COMPLETE,
17218 Some(serde_json::json!({
17219 "_meta": {
17220 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17221 "io.modelcontextprotocol/clientCapabilities": {},
17222 },
17223 "ref": {"type": "ref/resource", "uri": TEMPLATE_URI},
17224 "argument": {"name": "id", "value": "sta"},
17225 })),
17226 1_872_i64,
17227 );
17228 let prompt_request_bytes =
17229 serde_json::to_vec(&prompt_request).expect("prompt completion request serializes");
17230 let resource_request_bytes = serde_json::to_vec(&resource_request)
17231 .expect("resource-template completion request serializes");
17232
17233 let cx = Cx::for_testing();
17234 let allowed_connection = ModernConnection::new();
17235 let allowed_inbound = InboundRequestContext::with_modern_connection(
17236 cx.clone(),
17237 1_871,
17238 InboundRequestTransport::Stdio,
17239 &allowed_connection,
17240 );
17241 let allowed_context = allowed_inbound.request_context();
17242 for request in [&prompt_request, &resource_request] {
17243 let result = router
17244 .dispatch_stateless(&allowed_context, request)
17245 .expect("a visible completion target reaches the fallback provider");
17246 assert_eq!(
17247 result["completion"]["values"],
17248 serde_json::json!(["staging"])
17249 );
17250 }
17251 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
17252
17253 let denied_connection = ModernConnection::new();
17254 let denied_inbound = InboundRequestContext::with_modern_connection(
17255 cx,
17256 1_871,
17257 InboundRequestTransport::Stdio,
17258 &denied_connection,
17259 );
17260 let denied_context = denied_inbound.request_context();
17261 assert!(denied_context.disable_prompt("prompt-argument-boundary"));
17262 assert!(denied_context.disable_resource(TEMPLATE_URI));
17263
17264 assert_eq!(
17265 serde_json::to_vec(&prompt_request)
17266 .expect("prompt completion request remains serializable"),
17267 prompt_request_bytes,
17268 "connection visibility is the sole planted prompt-completion dimension"
17269 );
17270 let hidden_prompt = router
17271 .dispatch_stateless(&denied_context, &prompt_request)
17272 .expect_err("a hidden prompt cannot be used as a completion reference");
17273 assert_eq!(hidden_prompt.code, McpErrorCode::InvalidParams);
17274 assert_eq!(
17275 hidden_prompt.message,
17276 "completion prompt reference is not registered"
17277 );
17278
17279 assert_eq!(
17280 serde_json::to_vec(&resource_request)
17281 .expect("resource-template completion request remains serializable"),
17282 resource_request_bytes,
17283 "connection visibility is the sole planted resource-template-completion dimension"
17284 );
17285 let hidden_resource = router
17286 .dispatch_stateless(&denied_context, &resource_request)
17287 .expect_err("a hidden resource template cannot reach the fallback provider");
17288 assert_eq!(hidden_resource.code, McpErrorCode::InvalidParams);
17289 assert_eq!(
17290 hidden_resource.message,
17291 "completion resource reference is not registered"
17292 );
17293 assert_eq!(
17294 final_calls.load(Ordering::SeqCst),
17295 2,
17296 "refused completion references must not invoke the fallback provider"
17297 );
17298 assert_eq!(
17299 serde_json::to_vec(&router.prompts())
17300 .expect("prompt catalog serializes after completion refusal"),
17301 prompt_catalog_before,
17302 "completion refusal cannot mutate the prompt catalog"
17303 );
17304 assert_eq!(
17305 serde_json::to_vec(&router.resource_templates())
17306 .expect("resource-template catalog serializes after completion refusal"),
17307 template_catalog_before,
17308 "completion refusal cannot mutate the resource-template catalog"
17309 );
17310 }
17311
17312 #[test]
17313 fn explicit_legacy_completion_is_not_discovered_or_dispatched_as_final() {
17314 let mut router = Router::new();
17315 router.add_legacy_completion_handler(EchoCompletion);
17316 router.add_prompt(NamedPrompt::new("deploy"));
17317 let cx = Cx::for_testing();
17318 let state = SessionState::new();
17319 let request_ctx = request_context(&cx, 184, Budget::INFINITE, &state);
17320 let request = JsonRpcRequest::new(
17321 COMPLETION_COMPLETE,
17322 Some(serde_json::json!({
17323 "ref": {"type": "ref/prompt", "name": "deploy"},
17324 "argument": {"name": "environment", "value": "sta"},
17325 })),
17326 184_i64,
17327 );
17328 assert!(
17329 router
17330 .dispatch_legacy_completion(&request_ctx, &request)
17331 .is_ok()
17332 );
17333
17334 let mut final_request = request.clone();
17335 final_request
17336 .params
17337 .as_mut()
17338 .and_then(serde_json::Value::as_object_mut)
17339 .expect("completion parameters are an object")
17340 .insert(
17341 "_meta".to_owned(),
17342 serde_json::json!({
17343 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17344 "io.modelcontextprotocol/clientCapabilities": {},
17345 }),
17346 );
17347 let error = router
17348 .dispatch_stateless(&request_ctx, &final_request)
17349 .expect_err("only the selected protocol era changes completion availability");
17350 assert_eq!(error.code, McpErrorCode::MethodNotFound);
17351 assert!(
17352 !router
17353 .server_discovery_behavior_registry()
17354 .contains(ServerBehavior::CompletionComplete)
17355 );
17356 }
17357
17358 #[test]
17359 fn final_completion_resource_template_reference_and_argument_are_validated_before_handler() {
17360 let final_calls = Arc::new(AtomicUsize::new(0));
17361 let mut router = Router::new();
17362 router.add_completion_handler(CountingCompletion {
17363 final_calls: Arc::clone(&final_calls),
17364 });
17365 router.add_resource(NamedResource::new("resource://static"));
17366 router.add_resource_template(marked_template("resource://{id}", "registered"));
17367 router
17368 .add_legacy_resource_template(marked_template("resource://{legacy_id}", "legacy-only"));
17369 let cx = Cx::for_testing();
17370 let state = SessionState::new();
17371 let request_ctx = request_context(&cx, 188, Budget::INFINITE, &state);
17372 let baseline = JsonRpcRequest::new(
17373 COMPLETION_COMPLETE,
17374 Some(serde_json::json!({
17375 "_meta": {
17376 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17377 "io.modelcontextprotocol/clientCapabilities": {},
17378 },
17379 "ref": {"type": "ref/resource", "uri": "resource://{id}"},
17380 "argument": {"name": "id", "value": "sta"},
17381 })),
17382 188_i64,
17383 );
17384 let templates_before = serde_json::to_vec(&router.resource_templates())
17385 .expect("resource-template catalog serializes");
17386 let template_accepted = router
17387 .dispatch_stateless(&request_ctx, &baseline)
17388 .expect("a registered final resource-template reference is accepted");
17389 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17390
17391 let mut static_reference = baseline.clone();
17392 static_reference
17393 .params
17394 .as_mut()
17395 .and_then(serde_json::Value::as_object_mut)
17396 .and_then(|params| params.get_mut("ref"))
17397 .and_then(serde_json::Value::as_object_mut)
17398 .expect("completion reference is an object")
17399 .insert("uri".to_owned(), serde_json::json!("resource://static"));
17400 let static_error = router
17401 .dispatch_stateless(&request_ctx, &static_reference)
17402 .expect_err("a static resource is not a final completion-template target");
17403 assert_eq!(static_error.code, McpErrorCode::InvalidParams);
17404 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17405
17406 let mut legacy_template = baseline.clone();
17407 legacy_template
17408 .params
17409 .as_mut()
17410 .and_then(serde_json::Value::as_object_mut)
17411 .and_then(|params| params.get_mut("ref"))
17412 .and_then(serde_json::Value::as_object_mut)
17413 .expect("completion reference is an object")
17414 .insert(
17415 "uri".to_owned(),
17416 serde_json::json!("resource://{legacy_id}"),
17417 );
17418 assert_eq!(baseline.method, legacy_template.method);
17419 assert_eq!(baseline.id, legacy_template.id);
17420 assert_eq!(
17421 baseline
17422 .params
17423 .as_ref()
17424 .and_then(|params| params.get("argument")),
17425 legacy_template
17426 .params
17427 .as_ref()
17428 .and_then(|params| params.get("argument")),
17429 "the target URI is the sole planted visibility dimension"
17430 );
17431 let legacy_template_error = router
17432 .dispatch_stateless(&request_ctx, &legacy_template)
17433 .expect_err("an exact-2024-only template is not final-visible");
17434 assert_eq!(legacy_template_error.code, McpErrorCode::InvalidParams);
17435 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17436
17437 let mut unknown_argument = baseline.clone();
17438 unknown_argument
17439 .params
17440 .as_mut()
17441 .and_then(serde_json::Value::as_object_mut)
17442 .and_then(|params| params.get_mut("argument"))
17443 .and_then(serde_json::Value::as_object_mut)
17444 .expect("completion argument is an object")
17445 .insert("name".to_owned(), serde_json::json!("unknown"));
17446 assert_eq!(baseline.method, unknown_argument.method);
17447 assert_eq!(baseline.id, unknown_argument.id);
17448 assert_eq!(
17449 baseline
17450 .params
17451 .as_ref()
17452 .and_then(|params| params.get("ref")),
17453 unknown_argument
17454 .params
17455 .as_ref()
17456 .and_then(|params| params.get("ref")),
17457 "the argument name is the sole planted validation dimension"
17458 );
17459 let unknown_argument_error = router
17460 .dispatch_stateless(&request_ctx, &unknown_argument)
17461 .expect_err("an undeclared template argument is rejected before the handler");
17462 assert_eq!(unknown_argument_error.code, McpErrorCode::InvalidParams);
17463 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17464 assert_eq!(
17465 serde_json::to_vec(&router.resource_templates())
17466 .expect("resource-template catalog serializes after refusal"),
17467 templates_before,
17468 "completion reference refusal cannot mutate the registered catalog"
17469 );
17470 assert_eq!(
17471 router
17472 .dispatch_stateless(&request_ctx, &baseline)
17473 .expect("the registered reference remains accepted after refusal"),
17474 template_accepted,
17475 "rejected target or argument changes cannot alter the accepted completion"
17476 );
17477 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
17478 }
17479
17480 #[test]
17481 fn final_completion_prompt_reference_and_argument_are_validated_before_handler() {
17482 let final_calls = Arc::new(AtomicUsize::new(0));
17483 let mut router = Router::new();
17484 router.add_completion_handler(CountingCompletion {
17485 final_calls: Arc::clone(&final_calls),
17486 });
17487 router.add_prompt(PromptArgumentBoundary {
17488 final_calls: Arc::new(AtomicUsize::new(0)),
17489 legacy_calls: Arc::new(AtomicUsize::new(0)),
17490 });
17491 router.add_legacy_prompt(NamedPrompt::new("legacy-completion-prompt"));
17492 let cx = Cx::for_testing();
17493 let state = SessionState::new();
17494 let request_ctx = request_context(&cx, 189, Budget::INFINITE, &state);
17495 let baseline = JsonRpcRequest::new(
17496 COMPLETION_COMPLETE,
17497 Some(serde_json::json!({
17498 "_meta": {
17499 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17500 "io.modelcontextprotocol/clientCapabilities": {},
17501 },
17502 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17503 "argument": {"name": "topic", "value": "sta"},
17504 })),
17505 189_i64,
17506 );
17507 let accepted = router
17508 .dispatch_stateless(&request_ctx, &baseline)
17509 .expect("a final-visible prompt and its declared argument are accepted");
17510 assert_eq!(accepted["resultType"], "complete");
17511 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17512
17513 let mut legacy_prompt = baseline.clone();
17514 legacy_prompt
17515 .params
17516 .as_mut()
17517 .and_then(serde_json::Value::as_object_mut)
17518 .and_then(|params| params.get_mut("ref"))
17519 .and_then(serde_json::Value::as_object_mut)
17520 .expect("completion reference is an object")
17521 .insert(
17522 "name".to_owned(),
17523 serde_json::json!("legacy-completion-prompt"),
17524 );
17525 assert_eq!(baseline.method, legacy_prompt.method);
17526 assert_eq!(baseline.id, legacy_prompt.id);
17527 assert_eq!(
17528 baseline
17529 .params
17530 .as_ref()
17531 .and_then(|params| params.get("argument")),
17532 legacy_prompt
17533 .params
17534 .as_ref()
17535 .and_then(|params| params.get("argument")),
17536 "the prompt name is the sole planted visibility dimension"
17537 );
17538 let legacy_prompt_error = router
17539 .dispatch_stateless(&request_ctx, &legacy_prompt)
17540 .expect_err("an exact-2024-only prompt is not final-visible");
17541 assert_eq!(legacy_prompt_error.code, McpErrorCode::InvalidParams);
17542 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17543
17544 let mut unknown_argument = baseline.clone();
17545 unknown_argument
17546 .params
17547 .as_mut()
17548 .and_then(serde_json::Value::as_object_mut)
17549 .and_then(|params| params.get_mut("argument"))
17550 .and_then(serde_json::Value::as_object_mut)
17551 .expect("completion argument is an object")
17552 .insert("name".to_owned(), serde_json::json!("unknown"));
17553 assert_eq!(baseline.method, unknown_argument.method);
17554 assert_eq!(baseline.id, unknown_argument.id);
17555 assert_eq!(
17556 baseline
17557 .params
17558 .as_ref()
17559 .and_then(|params| params.get("ref")),
17560 unknown_argument
17561 .params
17562 .as_ref()
17563 .and_then(|params| params.get("ref")),
17564 "the argument name is the sole planted validation dimension"
17565 );
17566 let unknown_argument_error = router
17567 .dispatch_stateless(&request_ctx, &unknown_argument)
17568 .expect_err("an undeclared prompt argument is rejected before the handler");
17569 assert_eq!(unknown_argument_error.code, McpErrorCode::InvalidParams);
17570 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17571
17572 let legacy = router
17573 .dispatch_legacy_completion(
17574 &request_ctx,
17575 &JsonRpcRequest::new(
17576 COMPLETION_COMPLETE,
17577 Some(serde_json::json!({
17578 "ref": {"type": "ref/prompt", "name": "legacy-completion-prompt"},
17579 "argument": {"name": "unknown", "value": "sta"},
17580 })),
17581 190_i64,
17582 ),
17583 )
17584 .expect("exact-2024 completion retains its unvalidated target argument behavior");
17585 assert!(legacy.get("resultType").is_none());
17586 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17587 }
17588
17589 #[test]
17590 fn final_completion_rejects_invalid_local_handler_results() {
17591 let final_calls = Arc::new(AtomicUsize::new(0));
17592 let mut router = Router::new();
17593 router.add_completion_handler(CompletionValueBoundary {
17594 final_calls: Arc::clone(&final_calls),
17595 });
17596 router.add_prompt(PromptArgumentBoundary {
17597 final_calls: Arc::new(AtomicUsize::new(0)),
17598 legacy_calls: Arc::new(AtomicUsize::new(0)),
17599 });
17600 let cx = Cx::for_testing();
17601 let state = SessionState::new();
17602 let request_ctx = request_context(&cx, 191, Budget::INFINITE, &state);
17603 let baseline = JsonRpcRequest::new(
17604 COMPLETION_COMPLETE,
17605 Some(serde_json::json!({
17606 "_meta": {
17607 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17608 "io.modelcontextprotocol/clientCapabilities": {},
17609 },
17610 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17611 "argument": {"name": "topic", "value": "at-bound"},
17612 })),
17613 191_i64,
17614 );
17615 let accepted = router
17616 .dispatch_stateless(&request_ctx, &baseline)
17617 .expect("a local handler result at the 100-value limit is accepted");
17618 assert_eq!(
17619 accepted["completion"]["values"].as_array().map(Vec::len),
17620 Some(fastmcp_protocol::MAX_COMPLETION_VALUES)
17621 );
17622 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17623
17624 let mut one_over = baseline.clone();
17625 one_over
17626 .params
17627 .as_mut()
17628 .and_then(serde_json::Value::as_object_mut)
17629 .and_then(|params| params.get_mut("argument"))
17630 .and_then(serde_json::Value::as_object_mut)
17631 .expect("completion argument is an object")
17632 .insert("value".to_owned(), serde_json::json!("one-over"));
17633 assert_eq!(baseline.method, one_over.method);
17634 assert_eq!(baseline.id, one_over.id);
17635 assert_eq!(
17636 baseline
17637 .params
17638 .as_ref()
17639 .and_then(|params| params.get("ref")),
17640 one_over
17641 .params
17642 .as_ref()
17643 .and_then(|params| params.get("ref")),
17644 "the handler result boundary is the sole planted dimension"
17645 );
17646 let error = router
17647 .dispatch_stateless(&request_ctx, &one_over)
17648 .expect_err("a local handler cannot return a 101st completion value");
17649 assert_eq!(error.code, McpErrorCode::InternalError);
17650 assert_eq!(
17651 error.message,
17652 "completion handler returned more than 100 values"
17653 );
17654 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
17655
17656 let mut negative_total = baseline;
17657 negative_total
17658 .params
17659 .as_mut()
17660 .and_then(serde_json::Value::as_object_mut)
17661 .and_then(|params| params.get_mut("argument"))
17662 .and_then(serde_json::Value::as_object_mut)
17663 .expect("completion argument is an object")
17664 .insert("value".to_owned(), serde_json::json!("negative-total"));
17665 let error = router
17666 .dispatch_stateless(&request_ctx, &negative_total)
17667 .expect_err("a local handler cannot return a negative final completion total");
17668 assert_eq!(error.code, McpErrorCode::InternalError);
17669 assert_eq!(
17670 error.message,
17671 "final completion total must be a nonnegative JSON integer"
17672 );
17673 assert_eq!(final_calls.load(Ordering::SeqCst), 3);
17674 }
17675
17676 #[test]
17677 fn resource_template_registration_is_final_visible_via_protocol_rfc6570_matcher() {
17678 struct AmbiguousTemplateResource;
17679
17680 impl ResourceHandler for AmbiguousTemplateResource {
17681 fn definition(&self) -> Resource {
17682 Resource {
17683 uri: "mcp://resource/ambiguous".to_owned(),
17684 name: "ambiguous-template".to_owned(),
17685 description: None,
17686 mime_type: None,
17687 icon: None,
17688 version: None,
17689 tags: Vec::new(),
17690 }
17691 }
17692
17693 fn template(&self) -> Option<ResourceTemplate> {
17694 Some(marked_template(
17695 "mcp://resource/{first}{second}",
17696 "ambiguous",
17697 ))
17698 }
17699
17700 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
17701 Ok(Vec::new())
17702 }
17703 }
17704
17705 let mut router = Router::new();
17706 let read_calls = Arc::new(AtomicUsize::new(0));
17707 let completion_calls = Arc::new(AtomicUsize::new(0));
17708 router.add_completion_handler(CountingCompletion {
17709 final_calls: Arc::clone(&completion_calls),
17710 });
17711 router
17712 .add_resource_with_behavior(
17713 ReversibleLevelFourTemplateResource {
17714 read_calls: Arc::clone(&read_calls),
17715 },
17716 crate::DuplicateBehavior::Replace,
17717 )
17718 .expect("a reversible level-four template is admitted");
17719 let cx = Cx::for_testing();
17720 let state = SessionState::new();
17721 let request_ctx = request_context(&cx, 189, Budget::INFINITE, &state);
17722 let legacy_error = router
17723 .handle_resources_read(
17724 &request_ctx,
17725 &fastmcp_protocol::ReadResourceParams {
17726 uri: "mcp://resource/books/manifest?revision=stable".to_owned(),
17727 meta: None,
17728 },
17729 state,
17730 None,
17731 None,
17732 )
17733 .expect_err("scalar explode remains unavailable to exact-2024 routing");
17734 assert_eq!(legacy_error.code, McpErrorCode::ResourceNotFound);
17735 assert_eq!(read_calls.load(Ordering::SeqCst), 0);
17736
17737 let final_metadata = serde_json::json!({
17738 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17739 "io.modelcontextprotocol/clientCapabilities": {},
17740 });
17741 let templates = router
17742 .dispatch_stateless(
17743 &request_ctx,
17744 &JsonRpcRequest::new(
17745 "resources/templates/list",
17746 Some(serde_json::json!({"_meta": final_metadata.clone()})),
17747 189_i64,
17748 ),
17749 )
17750 .expect("the admitted template is final-visible");
17751 assert_eq!(templates["resultType"], "complete");
17752 assert_eq!(
17753 templates["resourceTemplates"][0]["uriTemplate"],
17754 "mcp://resource{/collection*}/manifest{?revision*}"
17755 );
17756
17757 let final_read = router
17758 .dispatch_stateless(
17759 &request_ctx,
17760 &JsonRpcRequest::new(
17761 "resources/read",
17762 Some(serde_json::json!({
17763 "_meta": final_metadata.clone(),
17764 "uri": "mcp://resource/books%2Ffiction/manifest?revision=stable",
17765 })),
17766 189_i64,
17767 ),
17768 )
17769 .expect("the final route uses the same reversible matcher");
17770 assert_eq!(final_read["resultType"], "complete");
17771 assert_eq!(
17772 final_read["contents"][0]["text"], "books/fiction:stable",
17773 "the final route decodes the scalar capture exactly once"
17774 );
17775 assert_eq!(read_calls.load(Ordering::SeqCst), 1);
17776
17777 let completion = router
17778 .dispatch_stateless(
17779 &request_ctx,
17780 &JsonRpcRequest::new(
17781 COMPLETION_COMPLETE,
17782 Some(serde_json::json!({
17783 "_meta": final_metadata,
17784 "ref": {
17785 "type": "ref/resource",
17786 "uri": "mcp://resource{/collection*}/manifest{?revision*}",
17787 },
17788 "argument": {"name": "revision", "value": "sta"},
17789 })),
17790 189_i64,
17791 ),
17792 )
17793 .expect("the final completion target exposes protocol-derived variables");
17794 assert_eq!(completion["resultType"], "complete");
17795 assert_eq!(
17796 completion["completion"]["values"],
17797 serde_json::json!(["staging"])
17798 );
17799 assert_eq!(completion_calls.load(Ordering::SeqCst), 1);
17800
17801 let template_count = router.resource_templates_count();
17802 let error = router
17803 .add_resource_with_behavior(
17804 AmbiguousTemplateResource,
17805 crate::DuplicateBehavior::Replace,
17806 )
17807 .expect_err("changing only the adjacent scalar boundary remains ambiguous");
17808 assert_eq!(error.code, McpErrorCode::InvalidParams);
17809 assert_eq!(
17810 router.resource_templates_count(),
17811 template_count,
17812 "rejected template admission cannot mutate the registered catalog"
17813 );
17814 }
17815
17816 #[test]
17817 fn final_resource_registration_rejects_template_language_collisions_atomically() {
17818 let mut template_router = Router::new();
17819 template_router
17820 .add_resource_template_with_behavior(
17821 marked_template("mcp://resource/{first}", "first"),
17822 crate::DuplicateBehavior::Replace,
17823 )
17824 .expect("the first reversible template is admitted");
17825 let templates_before = serde_json::to_vec(&template_router.resource_templates())
17826 .expect("the admitted template catalog serializes");
17827
17828 let error = template_router
17829 .add_resource_template_with_behavior(
17830 marked_template("mcp://resource/{second}", "second"),
17831 crate::DuplicateBehavior::Replace,
17832 )
17833 .expect_err("renaming only the capture must not create a tie-broken route");
17834 assert_eq!(error.code, McpErrorCode::InvalidParams);
17835 assert_eq!(
17836 serde_json::to_vec(&template_router.resource_templates())
17837 .expect("a rejected collision leaves the template catalog serializable"),
17838 templates_before,
17839 "template-template collision rejection leaves the catalog unchanged"
17840 );
17841
17842 let mut exact_router = Router::new();
17843 exact_router
17844 .add_resource_with_behavior(
17845 NamedResource::new("mcp://resource/books"),
17846 crate::DuplicateBehavior::Replace,
17847 )
17848 .expect("the exact final resource is admitted");
17849 let exact_count = exact_router.resources_count();
17850 let error = exact_router
17851 .add_resource_template_with_behavior(
17852 marked_template("mcp://resource/{id}", "template"),
17853 crate::DuplicateBehavior::Replace,
17854 )
17855 .expect_err("a template cannot shadow an exact final resource");
17856 assert_eq!(error.code, McpErrorCode::InvalidParams);
17857 assert_eq!(exact_router.resources_count(), exact_count);
17858 assert_eq!(exact_router.resource_templates_count(), 0);
17859
17860 let mut reverse_router = Router::new();
17861 reverse_router
17862 .add_resource_template_with_behavior(
17863 marked_template("mcp://resource/{id}", "template"),
17864 crate::DuplicateBehavior::Replace,
17865 )
17866 .expect("the reversible template is admitted before the planted exact collision");
17867 let templates_before = serde_json::to_vec(&reverse_router.resource_templates())
17868 .expect("the admitted template catalog serializes");
17869 let error = reverse_router
17870 .add_resource_with_behavior(
17871 NamedResource::new("mcp://resource/books"),
17872 crate::DuplicateBehavior::Replace,
17873 )
17874 .expect_err("an exact final resource cannot be hidden behind a template");
17875 assert_eq!(error.code, McpErrorCode::InvalidParams);
17876 assert_eq!(reverse_router.resources_count(), 0);
17877 assert_eq!(
17878 serde_json::to_vec(&reverse_router.resource_templates())
17879 .expect("the template catalog remains serializable after refusal"),
17880 templates_before,
17881 "exact-resource collision rejection leaves the existing template unchanged"
17882 );
17883
17884 let mut disjoint_router = Router::new();
17885 disjoint_router
17886 .add_resource_template_with_behavior(
17887 marked_template("mcp://alpha/{id}", "alpha"),
17888 crate::DuplicateBehavior::Replace,
17889 )
17890 .expect("a disjoint template is admitted");
17891 disjoint_router
17892 .add_resource_template_with_behavior(
17893 marked_template("mcp://beta/{id}", "beta"),
17894 crate::DuplicateBehavior::Replace,
17895 )
17896 .expect("a template with a conflicting literal prefix remains independent");
17897 assert_eq!(disjoint_router.resource_templates_count(), 2);
17898 }
17899
17900 #[test]
17901 fn final_resource_mount_rejects_cross_router_language_collisions_atomically() {
17902 let exact_uri = "mcp://resource/books";
17903
17904 let mut exact_destination = Router::new();
17905 exact_destination
17906 .add_resource_with_behavior(
17907 NamedResource::new(exact_uri),
17908 crate::DuplicateBehavior::Replace,
17909 )
17910 .expect("the exact final resource is admitted before mounting");
17911 let exact_before = serde_json::to_vec(&exact_destination.resources())
17912 .expect("the exact destination catalog serializes");
17913 let mut template_source = Router::new();
17914 template_source
17915 .add_resource_template_with_behavior(
17916 marked_template("mcp://resource/{id}", "mounted-template"),
17917 crate::DuplicateBehavior::Replace,
17918 )
17919 .expect("the independently admitted source template is valid");
17920
17921 let result = exact_destination.mount_resources(template_source, None);
17922 assert!(!result.is_success());
17923 assert_eq!(
17924 serde_json::to_vec(&exact_destination.resources())
17925 .expect("a rejected mount preserves the destination catalog"),
17926 exact_before,
17927 "template-after-exact mount rejection leaves every destination resource unchanged"
17928 );
17929 assert_eq!(exact_destination.resource_templates_count(), 0);
17930
17931 let mut template_destination = Router::new();
17932 template_destination
17933 .add_resource_template_with_behavior(
17934 marked_template("mcp://resource/{id}", "destination-template"),
17935 crate::DuplicateBehavior::Replace,
17936 )
17937 .expect("the final template is admitted before mounting");
17938 let templates_before = serde_json::to_vec(&template_destination.resource_templates())
17939 .expect("the template destination catalog serializes");
17940 let mut exact_source = Router::new();
17941 exact_source
17942 .add_resource_with_behavior(
17943 NamedResource::new(exact_uri),
17944 crate::DuplicateBehavior::Replace,
17945 )
17946 .expect("the independently admitted source resource is valid");
17947
17948 let result = template_destination.mount_resources(exact_source, None);
17949 assert!(!result.is_success());
17950 assert_eq!(template_destination.resources_count(), 0);
17951 assert_eq!(
17952 serde_json::to_vec(&template_destination.resource_templates())
17953 .expect("a rejected mount preserves the destination template catalog"),
17954 templates_before,
17955 "exact-after-template mount rejection leaves every destination template unchanged"
17956 );
17957
17958 let mut first_template_destination = Router::new();
17959 first_template_destination
17960 .add_resource_template_with_behavior(
17961 marked_template("mcp://resource/{first}", "first-template"),
17962 crate::DuplicateBehavior::Replace,
17963 )
17964 .expect("the first final template is admitted");
17965 let templates_before = serde_json::to_vec(&first_template_destination.resource_templates())
17966 .expect("the first template destination catalog serializes");
17967 let mut second_template_source = Router::new();
17968 second_template_source
17969 .add_resource_template_with_behavior(
17970 marked_template("mcp://resource/{second}", "second-template"),
17971 crate::DuplicateBehavior::Replace,
17972 )
17973 .expect("the independently admitted second template is valid");
17974
17975 let result = first_template_destination.mount_resources(second_template_source, None);
17976 assert!(!result.is_success());
17977 assert_eq!(
17978 serde_json::to_vec(&first_template_destination.resource_templates())
17979 .expect("a rejected mount preserves the original template catalog"),
17980 templates_before,
17981 "template-language overlap cannot make mount order a dispatch authority"
17982 );
17983 }
17984
17985 #[test]
17986 fn empty_prefix_mount_matches_unprefixed_final_route_projection() {
17987 const EXACT_URI: &str = "mcp://resource/books";
17988
17989 for prefix in [None, Some("")] {
17990 let mut destination = Router::new();
17991 destination
17992 .add_resource_with_behavior(
17993 NamedResource::new(EXACT_URI),
17994 crate::DuplicateBehavior::Replace,
17995 )
17996 .expect("the destination exact final resource is admitted");
17997 let resources_before = serde_json::to_vec(&destination.resources())
17998 .expect("the destination catalog serializes before the mount");
17999 let mut source = Router::new();
18000 source
18001 .add_resource_template_with_behavior(
18002 marked_template("mcp://resource/{id}", "one-variable-template"),
18003 crate::DuplicateBehavior::Replace,
18004 )
18005 .expect("the source one-variable final template is admitted");
18006
18007 let result = destination.mount_resources(source, prefix);
18008
18009 assert!(
18010 !result.is_success(),
18011 "an unchanged-key mount must reject the final route collision for {prefix:?}"
18012 );
18013 assert!(
18014 result
18015 .errors
18016 .iter()
18017 .any(|error| error.contains("collides with an exact final resource")),
18018 "the rejection must retain the exact/template collision reason for {prefix:?}"
18019 );
18020 assert_eq!(
18021 serde_json::to_vec(&destination.resources())
18022 .expect("the rejected mount leaves the destination catalog serializable"),
18023 resources_before,
18024 "the one-variable collision leaves the destination unchanged for {prefix:?}"
18025 );
18026 assert_eq!(destination.resource_templates_count(), 0);
18027 assert!(destination.final_resources.contains_key(EXACT_URI));
18028
18029 let mut template_destination = Router::new();
18030 template_destination
18031 .add_resource_template_with_behavior(
18032 marked_template("mcp://resource/{id}", "one-variable-destination-template"),
18033 crate::DuplicateBehavior::Replace,
18034 )
18035 .expect("the destination one-variable final template is admitted");
18036 let templates_before = serde_json::to_vec(&template_destination.resource_templates())
18037 .expect("the destination template catalog serializes before the mount");
18038 let mut exact_source = Router::new();
18039 exact_source
18040 .add_resource_with_behavior(
18041 NamedResource::new(EXACT_URI),
18042 crate::DuplicateBehavior::Replace,
18043 )
18044 .expect("the source exact final resource is admitted");
18045
18046 let result = template_destination.mount_resources(exact_source, prefix);
18047
18048 assert!(
18049 !result.is_success(),
18050 "the reverse unchanged-key collision must reject for {prefix:?}"
18051 );
18052 assert!(
18053 result
18054 .errors
18055 .iter()
18056 .any(|error| error.contains("collides with an exact final resource")),
18057 "the reverse rejection must retain the exact/template collision reason for {prefix:?}"
18058 );
18059 assert_eq!(
18060 serde_json::to_vec(&template_destination.resource_templates())
18061 .expect("the rejected reverse mount leaves the template catalog serializable"),
18062 templates_before,
18063 "the reverse one-variable collision leaves the destination unchanged for {prefix:?}"
18064 );
18065 assert_eq!(template_destination.resources_count(), 0);
18066 assert!(
18067 template_destination.resource_templates["mcp://resource/{id}"]
18068 .final_definition
18069 .is_some()
18070 );
18071 }
18072
18073 for prefix in [None, Some("")] {
18074 let mut destination = Router::new();
18075 destination
18076 .add_resource_template_with_behavior(
18077 marked_template("mcp://alpha/{id}", "destination-disjoint"),
18078 crate::DuplicateBehavior::Replace,
18079 )
18080 .expect("the destination disjoint final template is admitted");
18081 let mut source = Router::new();
18082 source
18083 .add_resource_template_with_behavior(
18084 marked_template("mcp://beta/{id}", "source-disjoint"),
18085 crate::DuplicateBehavior::Replace,
18086 )
18087 .expect("the source disjoint final template is admitted");
18088
18089 let result = destination.mount_resources(source, prefix);
18090
18091 assert!(
18092 result.is_success(),
18093 "a disjoint unchanged-key mount remains admissible for {prefix:?}"
18094 );
18095 assert_eq!(destination.resource_templates_count(), 2);
18096 assert!(
18097 destination.resource_templates["mcp://beta/{id}"]
18098 .final_definition
18099 .is_some(),
18100 "an empty prefix cannot downgrade a disjoint final template for {prefix:?}"
18101 );
18102 }
18103
18104 for prefix in [None, Some("")] {
18105 let mut destination = Router::new();
18106 destination
18107 .add_final_resource_with_behavior(
18108 NamedResource::new(EXACT_URI),
18109 crate::DuplicateBehavior::Replace,
18110 )
18111 .expect("the destination final-only exact resource is admitted");
18112 let mut source = Router::new();
18113 source
18114 .add_legacy_resource_template_with_behavior(
18115 marked_template("mcp://resource/{id}", "legacy-template"),
18116 crate::DuplicateBehavior::Replace,
18117 )
18118 .expect("the source legacy-only template is admitted");
18119
18120 let result = destination.mount_resources(source, prefix);
18121
18122 assert!(
18123 result.is_success(),
18124 "a legacy-only template remains isolated for {prefix:?}"
18125 );
18126 assert!(destination.final_resources.contains_key(EXACT_URI));
18127 assert!(
18128 destination.resource_templates["mcp://resource/{id}"]
18129 .final_definition
18130 .is_none(),
18131 "legacy-only template mounting cannot enter final routing for {prefix:?}"
18132 );
18133 }
18134 }
18135
18136 #[test]
18137 fn legacy_only_template_mount_remains_final_isolated() {
18138 let exact_uri = "mcp://resource/books";
18139 let mut destination = Router::new();
18140 destination
18141 .add_final_resource_with_behavior(
18142 NamedResource::new(exact_uri),
18143 crate::DuplicateBehavior::Replace,
18144 )
18145 .expect("the final-only exact resource is admitted");
18146 let mut legacy_source = Router::new();
18147 legacy_source
18148 .add_legacy_resource_template_with_behavior(
18149 marked_template("mcp://resource/{id}", "legacy-template"),
18150 crate::DuplicateBehavior::Replace,
18151 )
18152 .expect("the frozen exact-2024 template is admitted in its own catalog");
18153
18154 let result = destination.mount_resources(legacy_source, None);
18155 assert!(result.is_success());
18156 assert_eq!(destination.resources_count(), 1);
18157 assert_eq!(destination.resource_templates_count(), 1);
18158 assert!(destination.final_resources.contains_key(exact_uri));
18159 assert!(
18160 destination.resource_templates["mcp://resource/{id}"]
18161 .final_definition
18162 .is_none(),
18163 "a legacy-only template remains absent from final dispatch after mounting"
18164 );
18165 }
18166
18167 #[test]
18168 fn legacy_resource_template_is_inert_on_final_list_read_and_completion() {
18169 let mut router = Router::new();
18170 let read_calls = Arc::new(AtomicUsize::new(0));
18171 let completion_calls = Arc::new(AtomicUsize::new(0));
18172 router.add_completion_handler(CountingCompletion {
18173 final_calls: Arc::clone(&completion_calls),
18174 });
18175 router
18176 .add_legacy_resource_with_behavior(
18177 LegacyTemplateResource {
18178 read_calls: Arc::clone(&read_calls),
18179 },
18180 crate::DuplicateBehavior::Replace,
18181 )
18182 .expect("the exact-2024 template is admitted for its own route");
18183 let cx = Cx::for_testing();
18184 let state = SessionState::new();
18185 let request_ctx = request_context(&cx, 190, Budget::INFINITE, &state);
18186 let uri = "mcp://resource/books/manifest?revision=stable";
18187 let legacy_read = router
18188 .handle_resources_read(
18189 &request_ctx,
18190 &ReadResourceParams {
18191 uri: uri.to_owned(),
18192 meta: None,
18193 },
18194 state,
18195 None,
18196 None,
18197 )
18198 .expect("the exact legacy route retains the registered template");
18199 let legacy_wire =
18200 serde_json::to_value(legacy_read).expect("legacy resource result serializes");
18201 assert_eq!(legacy_wire["contents"][0]["text"], "books:stable");
18202 assert!(legacy_wire.get("resultType").is_none());
18203 assert_eq!(read_calls.load(Ordering::SeqCst), 1);
18204
18205 let final_metadata = serde_json::json!({
18206 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18207 "io.modelcontextprotocol/clientCapabilities": {},
18208 });
18209 let templates = router
18210 .dispatch_stateless(
18211 &request_ctx,
18212 &JsonRpcRequest::new(
18213 "resources/templates/list",
18214 Some(serde_json::json!({"_meta": final_metadata.clone()})),
18215 190_i64,
18216 ),
18217 )
18218 .expect("final template discovery remains valid with only exact-2024 templates");
18219 assert_eq!(templates["resultType"], "complete");
18220 assert_eq!(templates["resourceTemplates"], serde_json::json!([]));
18221
18222 let final_read = router
18223 .dispatch_stateless(
18224 &request_ctx,
18225 &JsonRpcRequest::new(
18226 "resources/read",
18227 Some(serde_json::json!({
18228 "_meta": final_metadata.clone(),
18229 "uri": uri,
18230 })),
18231 190_i64,
18232 ),
18233 )
18234 .expect_err("changing only the dispatch era cannot invoke a legacy-only template");
18235 assert_eq!(final_read.code, McpErrorCode::InvalidParams);
18236 assert_eq!(read_calls.load(Ordering::SeqCst), 1);
18237
18238 let completion = router
18239 .dispatch_stateless(
18240 &request_ctx,
18241 &JsonRpcRequest::new(
18242 COMPLETION_COMPLETE,
18243 Some(serde_json::json!({
18244 "_meta": final_metadata,
18245 "ref": {
18246 "type": "ref/resource",
18247 "uri": "mcp://resource/{collection}/manifest?revision={revision}",
18248 },
18249 "argument": {"name": "revision", "value": "sta"},
18250 })),
18251 190_i64,
18252 ),
18253 )
18254 .expect_err("a legacy-only template is not a final completion target");
18255 assert_eq!(completion.code, McpErrorCode::InvalidParams);
18256 assert_eq!(completion_calls.load(Ordering::SeqCst), 0);
18257 }
18258
18259 #[test]
18260 fn exact_2024_resource_template_admission_rejects_final_only_star_and_multi_variable_forms() {
18261 let mut legacy_router = Router::new();
18262 legacy_router
18263 .add_legacy_resource_with_behavior(
18264 LegacyTemplateResource {
18265 read_calls: Arc::new(AtomicUsize::new(0)),
18266 },
18267 crate::DuplicateBehavior::Replace,
18268 )
18269 .expect("the frozen {name} legacy grammar remains admitted");
18270 let catalog_before = serde_json::to_vec(&legacy_router.resource_templates())
18271 .expect("the admitted legacy catalog serializes");
18272
18273 let star_error = legacy_router
18274 .add_legacy_resource_with_behavior(
18275 ReversibleLevelFourTemplateResource {
18276 read_calls: Arc::new(AtomicUsize::new(0)),
18277 },
18278 crate::DuplicateBehavior::Replace,
18279 )
18280 .expect_err("changing only a legacy variable to scalar explode is final-only");
18281 assert_eq!(star_error.code, McpErrorCode::InvalidParams);
18282 assert_eq!(
18283 serde_json::to_vec(&legacy_router.resource_templates())
18284 .expect("rejected star admission leaves the catalog serializable"),
18285 catalog_before,
18286 "legacy rejection cannot mutate the existing catalog"
18287 );
18288
18289 let multi_variable_error = legacy_router
18290 .add_legacy_resource_template_with_behavior(
18291 marked_template("mcp://resource{?collection*,revision*}", "final-only-multi"),
18292 crate::DuplicateBehavior::Replace,
18293 )
18294 .expect_err("changing only to a named multi-variable expression is final-only");
18295 assert_eq!(multi_variable_error.code, McpErrorCode::InvalidParams);
18296 assert_eq!(
18297 serde_json::to_vec(&legacy_router.resource_templates())
18298 .expect("rejected multi-variable admission leaves the catalog serializable"),
18299 catalog_before,
18300 "the near-identical final syntax cannot alter an exact-2024 catalog"
18301 );
18302
18303 let mut final_router = Router::new();
18304 final_router
18305 .add_final_resource_with_behavior(
18306 ReversibleLevelFourTemplateResource {
18307 read_calls: Arc::new(AtomicUsize::new(0)),
18308 },
18309 crate::DuplicateBehavior::Replace,
18310 )
18311 .expect("the scalar-explode form remains admitted on the final route");
18312 final_router
18313 .add_resource_template_with_behavior(
18314 marked_template("mcp://resource{?collection*,revision*}", "final-multi"),
18315 crate::DuplicateBehavior::Replace,
18316 )
18317 .expect("the named multi-variable form remains admitted on the final route");
18318 assert_eq!(final_router.resource_templates_count(), 2);
18319 }
18320
18321 #[test]
18322 fn frozen_exact_2024_matcher_preserves_plus_capture_and_percent_decoding() {
18323 let matcher = admit_legacy_resource_template("legacy://resource/{+path}")
18324 .expect("the frozen legacy grammar retains {+name}");
18325 let params = matcher
18326 .matches("legacy://resource/books%2Ffiction")
18327 .expect("legacy plus capture matches a percent-encoded path");
18328 assert_eq!(
18329 params.get("path").map(String::as_str),
18330 Some("books/fiction")
18331 );
18332
18333 assert!(
18334 admit_legacy_resource_template("legacy://resource/{+path*}").is_err(),
18335 "changing only the scalar modifier keeps the new syntax final-only"
18336 );
18337 }
18338
18339 #[test]
18340 fn resource_template_admission_rejects_bare_literal_percent_without_catalog_mutation() {
18341 let accepted = marked_template("mcp://percent/reports%2Fdaily", "percent-template");
18342 let rejected = marked_template("mcp://percent/reports%Qdaily", "percent-template");
18343 let mut router = Router::new();
18344
18345 router
18346 .add_resource_template_with_behavior(accepted, crate::DuplicateBehavior::Replace)
18347 .expect("a complete literal percent triplet is admitted");
18348 let catalog_before = serde_json::to_vec(&router.resource_templates())
18349 .expect("accepted resource-template catalog serializes");
18350
18351 let error = router
18352 .add_resource_template_with_behavior(rejected, crate::DuplicateBehavior::Replace)
18353 .expect_err("changing only the percent triplet to a bare percent is refused");
18354 assert_eq!(error.code, McpErrorCode::InvalidParams);
18355 assert_eq!(
18356 serde_json::to_vec(&router.resource_templates())
18357 .expect("rejected admission leaves the catalog serializable"),
18358 catalog_before,
18359 "rejected literal syntax cannot rewrite the advertised template"
18360 );
18361 }
18362
18363 #[test]
18364 fn resource_resolution_skips_cross_era_static_shadows_for_matching_templates() {
18365 struct ShadowTemplate {
18366 label: &'static str,
18367 }
18368
18369 impl ResourceHandler for ShadowTemplate {
18370 fn definition(&self) -> Resource {
18371 Resource {
18372 uri: "mcp://shadow/template".to_owned(),
18373 name: self.label.to_owned(),
18374 description: None,
18375 mime_type: Some("text/plain".to_owned()),
18376 icon: None,
18377 version: None,
18378 tags: Vec::new(),
18379 }
18380 }
18381
18382 fn template(&self) -> Option<ResourceTemplate> {
18383 Some(marked_template("mcp://shadow/{id}", self.label))
18384 }
18385
18386 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
18387 unreachable!("templated reads receive their URI parameters")
18388 }
18389
18390 fn read_with_uri(
18391 &self,
18392 _ctx: &McpContext,
18393 uri: &str,
18394 _params: &UriParams,
18395 ) -> McpResult<Vec<ResourceContent>> {
18396 Ok(vec![ResourceContent {
18397 uri: uri.to_owned(),
18398 mime_type: Some("text/plain".to_owned()),
18399 text: Some(self.label.to_owned()),
18400 blob: None,
18401 }])
18402 }
18403 }
18404
18405 let cx = Cx::for_testing();
18406 let state = SessionState::new();
18407 let request_ctx = request_context(&cx, 190, Budget::INFINITE, &state);
18408 let uri = "mcp://shadow/item";
18409
18410 let mut legacy_router = Router::new();
18411 legacy_router
18412 .add_legacy_resource_with_behavior(
18413 ShadowTemplate {
18414 label: "legacy-template",
18415 },
18416 crate::DuplicateBehavior::Replace,
18417 )
18418 .expect("legacy template registers");
18419 legacy_router
18420 .add_final_resource_with_behavior(
18421 NamedResource::new(uri),
18422 crate::DuplicateBehavior::Replace,
18423 )
18424 .expect("final-only static resource registers");
18425 let legacy_read = legacy_router
18426 .handle_resources_read(
18427 &request_ctx,
18428 &ReadResourceParams {
18429 uri: uri.to_owned(),
18430 meta: None,
18431 },
18432 state.clone(),
18433 None,
18434 None,
18435 )
18436 .expect("a final-only static URI cannot hide a listed legacy template");
18437 assert_eq!(
18438 serde_json::to_value(legacy_read).expect("legacy result serializes")["contents"][0]["text"],
18439 "legacy-template"
18440 );
18441
18442 let mut final_router = Router::new();
18443 final_router
18444 .add_final_resource_with_behavior(
18445 ShadowTemplate {
18446 label: "final-template",
18447 },
18448 crate::DuplicateBehavior::Replace,
18449 )
18450 .expect("final template registers");
18451 final_router
18452 .add_legacy_resource_with_behavior(
18453 NamedResource::new(uri),
18454 crate::DuplicateBehavior::Replace,
18455 )
18456 .expect("legacy-only static resource registers");
18457 let final_read = final_router
18458 .dispatch_stateless(
18459 &request_ctx,
18460 &JsonRpcRequest::new(
18461 "resources/read",
18462 Some(serde_json::json!({
18463 "_meta": {
18464 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18465 "io.modelcontextprotocol/clientCapabilities": {},
18466 },
18467 "uri": uri,
18468 })),
18469 190_i64,
18470 ),
18471 )
18472 .expect("a legacy-only static URI cannot hide a listed final template");
18473 assert_eq!(final_read["contents"][0]["text"], "final-template");
18474 }
18475
18476 #[test]
18477 fn completion_handler_rejects_one_field_final_metadata_in_legacy_request() {
18478 let mut router = Router::new();
18479 router.add_completion_handler(EchoCompletion);
18480 let cx = Cx::for_testing();
18481 let state = SessionState::new();
18482 let request_ctx = request_context(&cx, 89, Budget::INFINITE, &state);
18483 let baseline = JsonRpcRequest::new(
18484 COMPLETION_COMPLETE,
18485 Some(serde_json::json!({
18486 "ref": {"type": "ref/prompt", "name": "deploy"},
18487 "argument": {"name": "environment", "value": "sta"},
18488 })),
18489 89_i64,
18490 );
18491 let mut planted = baseline.clone();
18492 planted
18493 .params
18494 .as_mut()
18495 .and_then(serde_json::Value::as_object_mut)
18496 .expect("completion parameters are an object")
18497 .insert(
18498 "_meta".to_string(),
18499 serde_json::json!({
18500 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18501 }),
18502 );
18503
18504 assert_eq!(baseline.method, planted.method);
18505 assert_eq!(baseline.id, planted.id);
18506 assert_eq!(
18507 baseline
18508 .params
18509 .as_ref()
18510 .and_then(|params| params.get("ref")),
18511 planted.params.as_ref().and_then(|params| params.get("ref")),
18512 "the final metadata object is the sole planted dimension"
18513 );
18514 let catalog_before = router.has_completion_handler();
18515 let planted_before = serde_json::to_vec(&planted).expect("planted request serializes");
18516
18517 let baseline_result = router
18518 .dispatch_legacy_completion(&request_ctx, &baseline)
18519 .expect("the baseline legacy completion request is accepted");
18520 let error = router
18521 .dispatch_legacy_completion(&request_ctx, &planted)
18522 .expect_err("only final metadata is refused in the exact legacy request");
18523 assert_eq!(error.code, McpErrorCode::InvalidParams);
18524 assert_eq!(
18525 serde_json::to_vec(&planted).expect("rejected request serializes"),
18526 planted_before,
18527 "cross-era rejection cannot mutate caller-owned completion parameters"
18528 );
18529 assert_eq!(
18530 router.has_completion_handler(),
18531 catalog_before,
18532 "cross-era rejection cannot alter the installed completion handler"
18533 );
18534 assert_eq!(
18535 router
18536 .dispatch_legacy_completion(&request_ctx, &baseline)
18537 .expect("the baseline remains accepted after the planted rejection"),
18538 baseline_result,
18539 "the one-field rejection cannot alter the accepted legacy completion result"
18540 );
18541 }
18542
18543 #[test]
18544 fn macro_tool_dispatches_exact_legacy_and_final_complete_results() {
18545 let _counter_guard = MACRO_DUAL_ERA_TOOL_LOCK
18546 .lock()
18547 .unwrap_or_else(std::sync::PoisonError::into_inner);
18548 let mut router = Router::new();
18549 MACRO_DUAL_ERA_TOOL_CALLS.store(0, Ordering::SeqCst);
18550 router
18551 .add_tool(MacroDualEraTool)
18552 .expect("macro tool registration succeeds");
18553 let cx = Cx::for_testing();
18554 let state = SessionState::new();
18555 let request_ctx = request_context(&cx, 91, Budget::INFINITE, &state);
18556
18557 let legacy = router
18558 .handle_tools_call(
18559 &request_ctx,
18560 CallToolParams {
18561 name: "macro_dual_era_tool".to_string(),
18562 arguments: Some(serde_json::json!({})),
18563 meta: None,
18564 },
18565 state,
18566 None,
18567 None,
18568 )
18569 .expect("the legacy adapter still invokes the registered handler");
18570 let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
18571 assert!(
18572 legacy_wire.get("resultType").is_none(),
18573 "the exact legacy result shape remains unchanged"
18574 );
18575 assert_eq!(legacy_wire["content"][0]["text"], "macro final tool result");
18576
18577 let modern = router
18578 .dispatch_stateless(
18579 &request_ctx,
18580 &JsonRpcRequest::new(
18581 "tools/call",
18582 Some(serde_json::json!({
18583 "_meta": {
18584 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18585 "io.modelcontextprotocol/clientCapabilities": {},
18586 },
18587 "name": "macro_dual_era_tool",
18588 "arguments": {},
18589 })),
18590 91_i64,
18591 ),
18592 )
18593 .expect("the modern router invokes the same installed handler");
18594
18595 assert_eq!(
18596 modern.get("resultType"),
18597 Some(&serde_json::json!("complete"))
18598 );
18599 assert_eq!(modern.get("content"), legacy_wire.get("content"));
18600 assert_eq!(modern.get("isError"), legacy_wire.get("isError"));
18601 assert!(modern.get("serverInfo").is_none());
18602 assert_eq!(
18603 modern["structuredContent"],
18604 serde_json::json!({"weather": "clear"})
18605 );
18606 assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 2);
18607 }
18608
18609 #[test]
18610 fn final_tools_call_input_schema_failure_is_bounded_tool_error_without_handler_call() {
18611 let final_calls = Arc::new(AtomicUsize::new(0));
18612 let legacy_calls = Arc::new(AtomicUsize::new(0));
18613 let mut router = Router::new();
18614 router
18615 .add_tool(SchemaBoundaryTool {
18616 final_calls: Arc::clone(&final_calls),
18617 legacy_calls: Arc::clone(&legacy_calls),
18618 output_matches_schema: true,
18619 output_is_error: false,
18620 output_has_unevaluated_property: false,
18621 invalid_final_input_schema: false,
18622 missing_final_input_object_type: false,
18623 invalid_final_output_schema: false,
18624 })
18625 .expect("schema-boundary tool registration succeeds");
18626 let cx = Cx::for_testing();
18627 let state = SessionState::new();
18628 let request_ctx = request_context(&cx, 150, Budget::INFINITE, &state);
18629
18630 let rejected = router
18631 .dispatch_stateless(
18632 &request_ctx,
18633 &final_tools_call_request(
18634 "schema-boundary-tool",
18635 serde_json::json!({"value": 7}),
18636 150_i64,
18637 ),
18638 )
18639 .expect("registered final tool input failures are tool results");
18640 assert_eq!(rejected["resultType"], "complete");
18641 assert_eq!(rejected["isError"], true);
18642 assert_eq!(
18643 rejected["content"][0]["text"],
18644 "Tool arguments do not match the declared input schema."
18645 );
18646 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
18647 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
18648
18649 let accepted = router
18650 .dispatch_stateless(
18651 &request_ctx,
18652 &final_tools_call_request(
18653 "schema-boundary-tool",
18654 serde_json::json!({"value": "accepted"}),
18655 151_i64,
18656 ),
18657 )
18658 .expect("changing only the input value to match the schema is accepted");
18659 assert_eq!(accepted["resultType"], "complete");
18660 assert!(accepted.get("isError").is_none());
18661 assert_eq!(
18662 accepted["structuredContent"],
18663 serde_json::json!({"accepted": true})
18664 );
18665 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18666 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
18667 }
18668
18669 #[test]
18670 fn final_unknown_tool_is_invalid_params_while_legacy_unknown_tool_is_unchanged() {
18671 let router = Router::new();
18672 let cx = Cx::for_testing();
18673 let state = SessionState::new();
18674 let request_ctx = request_context(&cx, 152, Budget::INFINITE, &state);
18675
18676 let modern_error = router
18677 .dispatch_stateless(
18678 &request_ctx,
18679 &final_tools_call_request("unknown-tool", serde_json::json!({}), 152_i64),
18680 )
18681 .expect_err("an unknown final tool is an invalid-params protocol error");
18682 assert_eq!(modern_error.code, McpErrorCode::InvalidParams);
18683
18684 let legacy_error = router
18685 .handle_tools_call(
18686 &request_ctx,
18687 CallToolParams {
18688 name: "unknown-tool".to_owned(),
18689 arguments: Some(serde_json::json!({})),
18690 meta: None,
18691 },
18692 state,
18693 None,
18694 None,
18695 )
18696 .expect_err("the exact legacy unknown-tool result remains method-not-found");
18697 assert_eq!(legacy_error.code, McpErrorCode::MethodNotFound);
18698 }
18699
18700 #[test]
18701 fn final_tool_output_schema_is_checked_before_success_and_legacy_is_unchanged() {
18702 let final_calls = Arc::new(AtomicUsize::new(0));
18703 let legacy_calls = Arc::new(AtomicUsize::new(0));
18704 let mut router = Router::new();
18705 router
18706 .add_tool(SchemaBoundaryTool {
18707 final_calls: Arc::clone(&final_calls),
18708 legacy_calls: Arc::clone(&legacy_calls),
18709 output_matches_schema: true,
18710 output_is_error: false,
18711 output_has_unevaluated_property: false,
18712 invalid_final_input_schema: false,
18713 missing_final_input_object_type: false,
18714 invalid_final_output_schema: false,
18715 })
18716 .expect("schema-boundary tool registration succeeds");
18717 let cx = Cx::for_testing();
18718 let state = SessionState::new();
18719 let request_ctx = request_context(&cx, 153, Budget::INFINITE, &state);
18720
18721 let accepted = router
18722 .dispatch_stateless(
18723 &request_ctx,
18724 &final_tools_call_request(
18725 "schema-boundary-tool",
18726 serde_json::json!({"value": "accepted"}),
18727 153_i64,
18728 ),
18729 )
18730 .expect("a complete result matching the declared output schema is emitted");
18731 assert_eq!(
18732 accepted["structuredContent"],
18733 serde_json::json!({"accepted": true})
18734 );
18735 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18736
18737 let legacy = router
18738 .handle_tools_call(
18739 &request_ctx,
18740 CallToolParams {
18741 name: "schema-boundary-tool".to_owned(),
18742 arguments: Some(serde_json::json!({"value": "accepted"})),
18743 meta: None,
18744 },
18745 state,
18746 None,
18747 None,
18748 )
18749 .expect("the legacy tool path does not apply final output-schema validation");
18750 assert!(!legacy.is_error);
18751 let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
18752 assert_eq!(
18753 legacy_wire["content"][0]["text"],
18754 "legacy schema-boundary result"
18755 );
18756 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
18757
18758 let rejected_final_calls = Arc::new(AtomicUsize::new(0));
18759 let rejected_legacy_calls = Arc::new(AtomicUsize::new(0));
18760 let mut rejected_router = Router::new();
18761 rejected_router
18762 .add_tool(SchemaBoundaryTool {
18763 final_calls: Arc::clone(&rejected_final_calls),
18764 legacy_calls: Arc::clone(&rejected_legacy_calls),
18765 output_matches_schema: false,
18766 output_is_error: false,
18767 output_has_unevaluated_property: false,
18768 invalid_final_input_schema: false,
18769 missing_final_input_object_type: false,
18770 invalid_final_output_schema: false,
18771 })
18772 .expect("schema-boundary tool registration succeeds");
18773 let rejected = rejected_router
18774 .dispatch_stateless(
18775 &request_ctx,
18776 &final_tools_call_request(
18777 "schema-boundary-tool",
18778 serde_json::json!({"value": "accepted"}),
18779 154_i64,
18780 ),
18781 )
18782 .expect_err("a complete result failing the declared output schema is not emitted");
18783 assert_eq!(rejected.code, McpErrorCode::InternalError);
18784 assert_eq!(
18785 rejected.message,
18786 "tool output does not match the declared output schema"
18787 );
18788 assert_eq!(rejected_final_calls.load(Ordering::SeqCst), 1);
18789 assert_eq!(rejected_legacy_calls.load(Ordering::SeqCst), 0);
18790 }
18791
18792 #[test]
18793 fn final_tool_output_schema_applies_to_complete_error_payloads() {
18794 let accepted_calls = Arc::new(AtomicUsize::new(0));
18795 let mut accepted_router = Router::new();
18796 accepted_router
18797 .add_tool(SchemaBoundaryTool {
18798 final_calls: Arc::clone(&accepted_calls),
18799 legacy_calls: Arc::new(AtomicUsize::new(0)),
18800 output_matches_schema: true,
18801 output_is_error: true,
18802 output_has_unevaluated_property: false,
18803 invalid_final_input_schema: false,
18804 missing_final_input_object_type: false,
18805 invalid_final_output_schema: false,
18806 })
18807 .expect("schema-boundary tool registration succeeds");
18808 let cx = Cx::for_testing();
18809 let state = SessionState::new();
18810 let request_ctx = request_context(&cx, 154, Budget::INFINITE, &state);
18811
18812 let accepted = accepted_router
18813 .dispatch_stateless(
18814 &request_ctx,
18815 &final_tools_call_request(
18816 "schema-boundary-tool",
18817 serde_json::json!({"value": "accepted"}),
18818 154_i64,
18819 ),
18820 )
18821 .expect("a schema-conforming complete error payload is emitted");
18822 assert_eq!(accepted["resultType"], "complete");
18823 assert_eq!(accepted["isError"], true);
18824 assert_eq!(
18825 accepted["structuredContent"],
18826 serde_json::json!({"accepted": true})
18827 );
18828 assert_eq!(accepted_calls.load(Ordering::SeqCst), 1);
18829
18830 let rejected_calls = Arc::new(AtomicUsize::new(0));
18831 let mut rejected_router = Router::new();
18832 rejected_router
18833 .add_tool(SchemaBoundaryTool {
18834 final_calls: Arc::clone(&rejected_calls),
18835 legacy_calls: Arc::new(AtomicUsize::new(0)),
18836 output_matches_schema: false,
18837 output_is_error: true,
18838 output_has_unevaluated_property: false,
18839 invalid_final_input_schema: false,
18840 missing_final_input_object_type: false,
18841 invalid_final_output_schema: false,
18842 })
18843 .expect("schema-boundary tool registration succeeds");
18844
18845 let rejected = rejected_router
18846 .dispatch_stateless(
18847 &request_ctx,
18848 &final_tools_call_request(
18849 "schema-boundary-tool",
18850 serde_json::json!({"value": "accepted"}),
18851 155_i64,
18852 ),
18853 )
18854 .expect_err("a nonconforming complete error payload is not emitted");
18855 assert_eq!(rejected.code, McpErrorCode::InternalError);
18856 assert_eq!(
18857 rejected.message,
18858 "tool output does not match the declared output schema"
18859 );
18860 assert_eq!(rejected_calls.load(Ordering::SeqCst), 1);
18861 }
18862
18863 #[test]
18864 fn final_tool_admitted_schemas_enforce_unevaluated_properties_on_shipped_path() {
18865 let input_final_calls = Arc::new(AtomicUsize::new(0));
18866 let input_legacy_calls = Arc::new(AtomicUsize::new(0));
18867 let mut input_router = Router::new();
18868 input_router
18869 .add_tool(SchemaBoundaryTool {
18870 final_calls: Arc::clone(&input_final_calls),
18871 legacy_calls: Arc::clone(&input_legacy_calls),
18872 output_matches_schema: true,
18873 output_is_error: false,
18874 output_has_unevaluated_property: false,
18875 invalid_final_input_schema: false,
18876 missing_final_input_object_type: false,
18877 invalid_final_output_schema: false,
18878 })
18879 .expect("schema-boundary tool registration succeeds");
18880 let cx = Cx::for_testing();
18881 let state = SessionState::new();
18882 let request_ctx = request_context(&cx, 155, Budget::INFINITE, &state);
18883
18884 let rejected_input = input_router
18885 .dispatch_stateless(
18886 &request_ctx,
18887 &final_tools_call_request(
18888 "schema-boundary-tool",
18889 serde_json::json!({"value": "accepted", "unexpected": true}),
18890 155_i64,
18891 ),
18892 )
18893 .expect("unevaluated final input properties return the bounded tool error result");
18894 assert_eq!(rejected_input["resultType"], "complete");
18895 assert_eq!(rejected_input["isError"], true);
18896 assert_eq!(input_final_calls.load(Ordering::SeqCst), 0);
18897 assert_eq!(input_legacy_calls.load(Ordering::SeqCst), 0);
18898
18899 let accepted_input = input_router
18900 .dispatch_stateless(
18901 &request_ctx,
18902 &final_tools_call_request(
18903 "schema-boundary-tool",
18904 serde_json::json!({"value": "accepted"}),
18905 156_i64,
18906 ),
18907 )
18908 .expect("removing only the unevaluated input property reaches the handler");
18909 assert_eq!(accepted_input["resultType"], "complete");
18910 assert_eq!(input_final_calls.load(Ordering::SeqCst), 1);
18911
18912 let output_final_calls = Arc::new(AtomicUsize::new(0));
18913 let output_legacy_calls = Arc::new(AtomicUsize::new(0));
18914 let mut output_router = Router::new();
18915 output_router
18916 .add_tool(SchemaBoundaryTool {
18917 final_calls: Arc::clone(&output_final_calls),
18918 legacy_calls: Arc::clone(&output_legacy_calls),
18919 output_matches_schema: true,
18920 output_is_error: false,
18921 output_has_unevaluated_property: true,
18922 invalid_final_input_schema: false,
18923 missing_final_input_object_type: false,
18924 invalid_final_output_schema: false,
18925 })
18926 .expect("schema-boundary tool registration succeeds");
18927
18928 let rejected_output = output_router
18929 .dispatch_stateless(
18930 &request_ctx,
18931 &final_tools_call_request(
18932 "schema-boundary-tool",
18933 serde_json::json!({"value": "accepted"}),
18934 157_i64,
18935 ),
18936 )
18937 .expect_err("an unevaluated final output property is not emitted as success");
18938 assert_eq!(rejected_output.code, McpErrorCode::InternalError);
18939 assert_eq!(
18940 rejected_output.message,
18941 "tool output does not match the declared output schema"
18942 );
18943 assert_eq!(output_final_calls.load(Ordering::SeqCst), 1);
18944 assert_eq!(output_legacy_calls.load(Ordering::SeqCst), 0);
18945 }
18946
18947 #[test]
18948 fn valid_final_tool_replace_updates_both_catalogs_without_reordering() {
18949 let original_legacy_calls = Arc::new(AtomicUsize::new(0));
18950 let original_final_calls = Arc::new(AtomicUsize::new(0));
18951 let replacement_legacy_calls = Arc::new(AtomicUsize::new(0));
18952 let replacement_final_calls = Arc::new(AtomicUsize::new(0));
18953 let mut router = Router::new();
18954 router
18955 .add_tool(NamedTool::new("before"))
18956 .expect("tool registration succeeds");
18957 router
18958 .add_tool(AdmittedSchemaReplacementTool {
18959 legacy_calls: Arc::clone(&original_legacy_calls),
18960 final_calls: Arc::clone(&original_final_calls),
18961 legacy_label: "original",
18962 output_schema: serde_json::json!({"type": "string"}),
18963 structured_content: Some(serde_json::json!("original")),
18964 })
18965 .expect("original tool registration succeeds");
18966 router
18967 .add_tool(NamedTool::new("after"))
18968 .expect("tool registration succeeds");
18969 let order_before = router
18970 .tools()
18971 .into_iter()
18972 .map(|tool| tool.name)
18973 .collect::<Vec<_>>();
18974
18975 router
18976 .add_tool_with_behavior(
18977 AdmittedSchemaReplacementTool {
18978 legacy_calls: Arc::clone(&replacement_legacy_calls),
18979 final_calls: Arc::clone(&replacement_final_calls),
18980 legacy_label: "replacement",
18981 output_schema: serde_json::json!({"type": "boolean"}),
18982 structured_content: Some(serde_json::json!(true)),
18983 },
18984 crate::DuplicateBehavior::Replace,
18985 )
18986 .expect("a fully admitted replacement commits both catalog views");
18987 assert_eq!(
18988 router
18989 .tools()
18990 .into_iter()
18991 .map(|tool| tool.name)
18992 .collect::<Vec<_>>(),
18993 order_before,
18994 "replacement retains the original registration position"
18995 );
18996
18997 let cx = Cx::for_testing();
18998 let state = SessionState::new();
18999 let request_ctx = request_context(&cx, 158, Budget::INFINITE, &state);
19000 let legacy = router
19001 .handle_tools_call(
19002 &request_ctx,
19003 CallToolParams {
19004 name: "admitted-schema-replacement-tool".to_owned(),
19005 arguments: Some(serde_json::json!({})),
19006 meta: None,
19007 },
19008 state,
19009 None,
19010 None,
19011 )
19012 .expect("the replacement is installed for legacy dispatch");
19013 let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
19014 assert_eq!(legacy_wire["content"][0]["text"], "replacement");
19015 assert_eq!(replacement_legacy_calls.load(Ordering::SeqCst), 1);
19016 assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 0);
19017
19018 let modern = router
19019 .dispatch_stateless(
19020 &request_ctx,
19021 &final_tools_call_request(
19022 "admitted-schema-replacement-tool",
19023 serde_json::json!({}),
19024 159_i64,
19025 ),
19026 )
19027 .expect("the replacement is installed for modern dispatch");
19028 assert_eq!(modern["structuredContent"], serde_json::json!(true));
19029 assert_eq!(replacement_final_calls.load(Ordering::SeqCst), 1);
19030 assert_eq!(original_final_calls.load(Ordering::SeqCst), 0);
19031
19032 let modern_catalog = router
19033 .dispatch_stateless(
19034 &request_ctx,
19035 &final_tools_list_request(None, None, None, 160_i64),
19036 )
19037 .expect("the admitted replacement remains visible to the modern catalog");
19038 assert_eq!(
19039 modern_catalog["tools"]
19040 .as_array()
19041 .expect("modern tools remain an array")
19042 .iter()
19043 .map(|tool| tool["name"].as_str().expect("tool name is a string"))
19044 .collect::<Vec<_>>(),
19045 vec!["before", "admitted-schema-replacement-tool", "after"]
19046 );
19047 assert_eq!(
19048 modern_catalog["tools"][1]["outputSchema"]["type"],
19049 "boolean"
19050 );
19051 }
19052
19053 #[test]
19054 fn invalid_final_schema_new_tool_leaves_both_catalogs_unchanged() {
19055 let mut router = Router::new();
19056 router
19057 .add_tool(NamedTool::new("existing"))
19058 .expect("baseline tool registration succeeds");
19059 let cx = Cx::for_testing();
19060 let state = SessionState::new();
19061 let request_ctx = request_context(&cx, 161, Budget::INFINITE, &state);
19062 let legacy_before =
19063 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
19064 let modern_before = router
19065 .dispatch_stateless(
19066 &request_ctx,
19067 &final_tools_list_request(None, None, None, 161_i64),
19068 )
19069 .expect("baseline modern catalog is available");
19070
19071 let error = router
19072 .add_tool_with_behavior(
19073 InvalidFinalSchemaNamedTool::with_tags("new-invalid", Vec::new()),
19074 crate::DuplicateBehavior::Error,
19075 )
19076 .expect_err("a new normal tool with a scalar outputSchema is rejected");
19077 assert_eq!(error.code, McpErrorCode::InternalError);
19078 assert_eq!(
19079 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
19080 legacy_before,
19081 "failed admission cannot add a legacy-only entry"
19082 );
19083 assert_eq!(
19084 router
19085 .dispatch_stateless(
19086 &request_ctx,
19087 &final_tools_list_request(None, None, None, 162_i64),
19088 )
19089 .expect("modern catalog remains available"),
19090 modern_before,
19091 "failed admission cannot alter the modern catalog"
19092 );
19093 assert!(router.get_tool("new-invalid").is_none());
19094 }
19095
19096 #[test]
19097 fn only_tokenized_upstream_schema_registration_bypasses_local_validation() {
19098 let cx = Cx::for_testing();
19099 let state = SessionState::new();
19100 let request_ctx = request_context(&cx, 1601, Budget::INFINITE, &state);
19101 let mut registered_proxy_router = Router::new();
19102 registered_proxy_router
19103 .add_tool(UpstreamScalarSchemaTool {
19104 registered_proxy: true,
19105 })
19106 .expect("an upstream-owned scalar schema is retained without local admission");
19107
19108 let response = registered_proxy_router
19109 .dispatch_stateless(
19110 &request_ctx,
19111 &final_tools_call_request(
19112 "upstream-scalar-schema-tool",
19113 serde_json::json!({}),
19114 1601_i64,
19115 ),
19116 )
19117 .expect("upstream-owned structured content is not locally revalidated");
19118 assert_eq!(
19119 response["structuredContent"],
19120 serde_json::json!({"upstream": true})
19121 );
19122
19123 let mut forged_router = Router::new();
19124 let error = forged_router
19125 .add_tool(UpstreamScalarSchemaTool {
19126 registered_proxy: false,
19127 })
19128 .expect_err("a forgeable authority label cannot bypass local schema admission");
19129 assert_eq!(error.code, McpErrorCode::InternalError);
19130 assert!(
19131 forged_router
19132 .get_tool("upstream-scalar-schema-tool")
19133 .is_none()
19134 );
19135 }
19136
19137 #[test]
19138 fn tokenized_upstream_schema_still_honors_strict_input_validation() {
19139 let cx = Cx::for_testing();
19140 let state = SessionState::new();
19141 let request_ctx = request_context(&cx, 1602, Budget::INFINITE, &state);
19142 let mut router = Router::new();
19143 router
19144 .add_tool(UpstreamScalarSchemaTool {
19145 registered_proxy: true,
19146 })
19147 .expect("an upstream-owned object schema is retained without local admission");
19148 router.set_strict_input_validation(true);
19149
19150 let refused = router
19151 .dispatch_stateless(
19152 &request_ctx,
19153 &final_tools_call_request(
19154 "upstream-scalar-schema-tool",
19155 serde_json::json!({"extra": 1}),
19156 1602_i64,
19157 ),
19158 )
19159 .expect("gateway strict mode returns a complete tools/call result");
19160 assert_eq!(refused["resultType"], "complete");
19161 assert_eq!(refused["isError"], true);
19162 assert_eq!(
19163 refused["content"][0]["text"],
19164 "Tool arguments do not match the declared input schema."
19165 );
19166
19167 let admitted = router
19168 .dispatch_stateless(
19169 &request_ctx,
19170 &final_tools_call_request(
19171 "upstream-scalar-schema-tool",
19172 serde_json::json!({}),
19173 1603_i64,
19174 ),
19175 )
19176 .expect("declared empty object arguments still reach the proxy handler");
19177 assert_eq!(
19178 admitted["structuredContent"],
19179 serde_json::json!({"upstream": true})
19180 );
19181
19182 router.set_strict_input_validation(false);
19183 let extra = router
19184 .dispatch_stateless(
19185 &request_ctx,
19186 &final_tools_call_request(
19187 "upstream-scalar-schema-tool",
19188 serde_json::json!({"extra": 1}),
19189 1604_i64,
19190 ),
19191 )
19192 .expect("changing only the strict flag must admit the extra property");
19193 assert_eq!(
19194 extra["structuredContent"],
19195 serde_json::json!({"upstream": true})
19196 );
19197 }
19198
19199 #[test]
19200 fn normal_registration_rejects_missing_input_schema_type_without_catalog_mutation() {
19201 let mut router = Router::new();
19202 router
19203 .add_tool(NamedTool::new("existing"))
19204 .expect("baseline tool registration succeeds");
19205 let legacy_before =
19206 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
19207
19208 let error = router
19209 .add_tool_with_behavior(
19210 SchemaBoundaryTool {
19211 final_calls: Arc::new(AtomicUsize::new(0)),
19212 legacy_calls: Arc::new(AtomicUsize::new(0)),
19213 output_matches_schema: true,
19214 output_is_error: false,
19215 output_has_unevaluated_property: false,
19216 invalid_final_input_schema: false,
19217 missing_final_input_object_type: true,
19218 invalid_final_output_schema: false,
19219 },
19220 crate::DuplicateBehavior::Error,
19221 )
19222 .expect_err("a normal inputSchema must declare type object");
19223 assert_eq!(error.code, McpErrorCode::InternalError);
19224 assert_eq!(
19225 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
19226 legacy_before
19227 );
19228 assert!(router.get_tool("schema-boundary-tool").is_none());
19229 }
19230
19231 #[test]
19232 fn final_tool_scalar_and_null_structured_content_are_present_and_validated() {
19233 let cx = Cx::for_testing();
19234 let state = SessionState::new();
19235 let request_ctx = request_context(&cx, 163, Budget::INFINITE, &state);
19236
19237 let mut scalar_router = Router::new();
19238 scalar_router
19239 .add_tool(AdmittedSchemaReplacementTool {
19240 legacy_calls: Arc::new(AtomicUsize::new(0)),
19241 final_calls: Arc::new(AtomicUsize::new(0)),
19242 legacy_label: "scalar",
19243 output_schema: serde_json::json!({"type": "string"}),
19244 structured_content: Some(serde_json::json!("")),
19245 })
19246 .expect("scalar-output tool registration succeeds");
19247 let scalar = scalar_router
19248 .dispatch_stateless(
19249 &request_ctx,
19250 &final_tools_call_request(
19251 "admitted-schema-replacement-tool",
19252 serde_json::json!({}),
19253 163_i64,
19254 ),
19255 )
19256 .expect("an object-valued schema document may describe a scalar result");
19257 assert_eq!(
19258 scalar.get("structuredContent"),
19259 Some(&serde_json::json!("")),
19260 "an empty string is present structured content, not an omitted value"
19261 );
19262
19263 let mut null_router = Router::new();
19264 null_router
19265 .add_tool(AdmittedSchemaReplacementTool {
19266 legacy_calls: Arc::new(AtomicUsize::new(0)),
19267 final_calls: Arc::new(AtomicUsize::new(0)),
19268 legacy_label: "null",
19269 output_schema: serde_json::json!({"type": "null"}),
19270 structured_content: Some(serde_json::Value::Null),
19271 })
19272 .expect("null-output tool registration succeeds");
19273 let null = null_router
19274 .dispatch_stateless(
19275 &request_ctx,
19276 &final_tools_call_request(
19277 "admitted-schema-replacement-tool",
19278 serde_json::json!({}),
19279 164_i64,
19280 ),
19281 )
19282 .expect("present JSON null is validated against a null output schema");
19283 assert_eq!(
19284 null.get("structuredContent"),
19285 Some(&serde_json::Value::Null),
19286 "explicit JSON null remains present on the server-emission path"
19287 );
19288 }
19289
19290 #[test]
19291 fn final_tool_declared_output_schema_rejects_absent_structured_content() {
19292 let mut router = Router::new();
19293 router
19294 .add_tool(AdmittedSchemaReplacementTool {
19295 legacy_calls: Arc::new(AtomicUsize::new(0)),
19296 final_calls: Arc::new(AtomicUsize::new(0)),
19297 legacy_label: "missing",
19298 output_schema: serde_json::json!({"type": "string"}),
19299 structured_content: None,
19300 })
19301 .expect("mapped tool registration succeeds");
19302 let cx = Cx::for_testing();
19303 let state = SessionState::new();
19304 let request_ctx = request_context(&cx, 165, Budget::INFINITE, &state);
19305
19306 let error = router
19307 .dispatch_stateless(
19308 &request_ctx,
19309 &final_tools_call_request(
19310 "admitted-schema-replacement-tool",
19311 serde_json::json!({}),
19312 165_i64,
19313 ),
19314 )
19315 .expect_err("a declared output schema requires structured content on complete output");
19316 assert_eq!(error.code, McpErrorCode::InternalError);
19317 assert_eq!(
19318 error.message,
19319 "tool output is missing structuredContent required by the declared output schema"
19320 );
19321 }
19322
19323 #[test]
19324 fn final_tool_error_mapper_covers_input_validation_and_handler_errors() {
19325 let calls = Arc::new(AtomicUsize::new(0));
19326 let mut router = Router::new();
19327 router
19328 .add_tool(ErrorMappedTool {
19329 mode: ErrorMapperMode::Complete,
19330 calls: Arc::clone(&calls),
19331 })
19332 .expect("both bounded mapper branches satisfy outputSchema");
19333 let cx = Cx::for_testing();
19334 let state = SessionState::new();
19335 let request_ctx = request_context(&cx, 169, Budget::INFINITE, &state);
19336
19337 let input_error = router
19338 .dispatch_stateless(
19339 &request_ctx,
19340 &final_tools_call_request(
19341 "error-mapped-tool",
19342 serde_json::json!({"value": 7}),
19343 169_i64,
19344 ),
19345 )
19346 .expect("input rejection is a schema-valid complete tool error");
19347 assert_eq!(input_error["isError"], true);
19348 assert_eq!(
19349 input_error["structuredContent"],
19350 serde_json::json!({"error": "input-validation"})
19351 );
19352 assert_eq!(calls.load(Ordering::SeqCst), 0);
19353
19354 let handler_error = router
19355 .dispatch_stateless(
19356 &request_ctx,
19357 &final_tools_call_request(
19358 "error-mapped-tool",
19359 serde_json::json!({"value": "accepted"}),
19360 170_i64,
19361 ),
19362 )
19363 .expect("handler rejection is a schema-valid complete tool error");
19364 assert_eq!(handler_error["isError"], true);
19365 assert_eq!(
19366 handler_error["structuredContent"],
19367 serde_json::json!({"error": "handler"})
19368 );
19369 assert_eq!(calls.load(Ordering::SeqCst), 1);
19370 }
19371
19372 #[test]
19373 fn incomplete_invalid_or_oversized_tool_error_mapper_is_rejected_atomically() {
19374 for (mode, expected_message) in [
19375 (
19376 ErrorMapperMode::MissingHandler,
19377 "tool declares outputSchema without a complete tool-error structured-content mapper",
19378 ),
19379 (
19380 ErrorMapperMode::InvalidHandler,
19381 "tool error structured-content mapper does not satisfy outputSchema",
19382 ),
19383 (
19384 ErrorMapperMode::OversizedHandler,
19385 "tool error structured-content mapper exceeded the registration limit",
19386 ),
19387 ] {
19388 let calls = Arc::new(AtomicUsize::new(0));
19389 let mut router = Router::new();
19390 router
19391 .add_tool(NamedTool::new("existing"))
19392 .expect("baseline tool admission succeeds");
19393 let cx = Cx::for_testing();
19394 let state = SessionState::new();
19395 let request_ctx = request_context(&cx, 171, Budget::INFINITE, &state);
19396 let legacy_before =
19397 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
19398 let modern_before = router
19399 .dispatch_stateless(
19400 &request_ctx,
19401 &final_tools_list_request(None, None, None, 171_i64),
19402 )
19403 .expect("modern catalog is available");
19404
19405 let error = router
19406 .add_tool(ErrorMappedTool {
19407 mode,
19408 calls: Arc::clone(&calls),
19409 })
19410 .expect_err("public add_tool exposes mapper admission failure");
19411 assert_eq!(error.code, McpErrorCode::InternalError);
19412 assert_eq!(error.message, expected_message);
19413 assert_eq!(
19414 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
19415 legacy_before
19416 );
19417 assert_eq!(
19418 router
19419 .dispatch_stateless(
19420 &request_ctx,
19421 &final_tools_list_request(None, None, None, 172_i64),
19422 )
19423 .expect("modern catalog remains available"),
19424 modern_before
19425 );
19426 assert!(router.get_tool("error-mapped-tool").is_none());
19427 assert_eq!(calls.load(Ordering::SeqCst), 0);
19428 }
19429 }
19430
19431 #[test]
19432 fn normal_registration_rejects_null_output_schema_without_catalog_mutation() {
19433 let mut router = Router::new();
19434 let error = router
19435 .add_tool_with_behavior(
19436 AdmittedSchemaReplacementTool {
19437 legacy_calls: Arc::new(AtomicUsize::new(0)),
19438 final_calls: Arc::new(AtomicUsize::new(0)),
19439 legacy_label: "null-schema",
19440 output_schema: serde_json::Value::Null,
19441 structured_content: None,
19442 },
19443 crate::DuplicateBehavior::Error,
19444 )
19445 .expect_err("the outputSchema wire field itself must be an object");
19446 assert_eq!(error.code, McpErrorCode::InternalError);
19447 assert!(router.tools().is_empty());
19448 assert!(
19449 router
19450 .get_tool("admitted-schema-replacement-tool")
19451 .is_none()
19452 );
19453 }
19454
19455 #[test]
19456 fn invalid_final_schema_replace_leaves_handlers_and_catalogs_unchanged() {
19457 let original_legacy_calls = Arc::new(AtomicUsize::new(0));
19458 let original_final_calls = Arc::new(AtomicUsize::new(0));
19459 let mut router = Router::new();
19460 router
19461 .add_tool(AdmittedSchemaReplacementTool {
19462 legacy_calls: Arc::clone(&original_legacy_calls),
19463 final_calls: Arc::clone(&original_final_calls),
19464 legacy_label: "original",
19465 output_schema: serde_json::json!({"type": "string"}),
19466 structured_content: Some(serde_json::json!("original")),
19467 })
19468 .expect("original tool registration succeeds");
19469 let cx = Cx::for_testing();
19470 let state = SessionState::new();
19471 let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
19472 let legacy_before =
19473 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
19474 let modern_before = router
19475 .dispatch_stateless(
19476 &request_ctx,
19477 &final_tools_list_request(None, None, None, 166_i64),
19478 )
19479 .expect("baseline modern catalog is available");
19480
19481 let error = router
19482 .add_tool_with_behavior(
19483 InvalidFinalSchemaNamedTool::with_tags(
19484 "admitted-schema-replacement-tool",
19485 Vec::new(),
19486 ),
19487 crate::DuplicateBehavior::Replace,
19488 )
19489 .expect_err("changing only the candidate outputSchema rejects replacement");
19490 assert_eq!(error.code, McpErrorCode::InternalError);
19491 assert_eq!(
19492 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
19493 legacy_before,
19494 "a rejected replacement cannot replace the legacy handler"
19495 );
19496 assert_eq!(
19497 router
19498 .dispatch_stateless(
19499 &request_ctx,
19500 &final_tools_list_request(None, None, None, 167_i64),
19501 )
19502 .expect("modern catalog remains available"),
19503 modern_before,
19504 "a rejected replacement cannot remove the admitted modern entry"
19505 );
19506
19507 let legacy = router
19508 .handle_tools_call(
19509 &request_ctx,
19510 CallToolParams {
19511 name: "admitted-schema-replacement-tool".to_owned(),
19512 arguments: Some(serde_json::json!({})),
19513 meta: None,
19514 },
19515 state,
19516 None,
19517 None,
19518 )
19519 .expect("the original legacy handler remains installed");
19520 let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
19521 assert_eq!(legacy_wire["content"][0]["text"], "original");
19522 assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 1);
19523
19524 let modern = router
19525 .dispatch_stateless(
19526 &request_ctx,
19527 &final_tools_call_request(
19528 "admitted-schema-replacement-tool",
19529 serde_json::json!({}),
19530 168_i64,
19531 ),
19532 )
19533 .expect("the original modern handler remains installed");
19534 assert_eq!(modern["structuredContent"], serde_json::json!("original"));
19535 assert_eq!(original_final_calls.load(Ordering::SeqCst), 1);
19536 }
19537
19538 #[test]
19539 fn final_tool_and_prompt_argument_nulls_are_rejected_without_erasing_absence() {
19540 let tool_final_calls = Arc::new(AtomicUsize::new(0));
19541 let prompt_final_calls = Arc::new(AtomicUsize::new(0));
19542 let mut router = Router::new();
19543 router
19544 .add_tool(InputRequiredTool {
19545 legacy_calls: Arc::new(AtomicUsize::new(0)),
19546 final_calls: Arc::clone(&tool_final_calls),
19547 })
19548 .expect("tool registration succeeds");
19549 router.add_prompt(DirectFinalPrompt {
19550 final_calls: Arc::clone(&prompt_final_calls),
19551 });
19552 let cx = Cx::for_testing();
19553 let connection = ModernConnection::new();
19554 let inbound = InboundRequestContext::with_modern_connection(
19555 cx,
19556 160,
19557 InboundRequestTransport::Memory,
19558 &connection,
19559 );
19560 let request_ctx = inbound.request_context();
19561
19562 let absent_tool_arguments = JsonRpcRequest::new(
19563 "tools/call",
19564 Some(serde_json::json!({
19565 "_meta": {
19566 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19567 "io.modelcontextprotocol/clientCapabilities": {},
19568 },
19569 "name": "input-required-tool",
19570 })),
19571 160_i64,
19572 );
19573 let mut null_tool_arguments = absent_tool_arguments.clone();
19574 null_tool_arguments
19575 .params
19576 .as_mut()
19577 .and_then(serde_json::Value::as_object_mut)
19578 .expect("final tool parameters are an object")
19579 .insert("arguments".to_owned(), serde_json::Value::Null);
19580
19581 let CoreRequest::Final(FinalCoreRequest::ToolsCall(absent_tool_params)) =
19582 CoreRequest::decode(
19583 ProtocolEra::Modern2026,
19584 "tools/call",
19585 absent_tool_arguments.params.as_ref(),
19586 )
19587 .expect("absent final tool arguments decode")
19588 else {
19589 panic!("the final tool parameter shape is selected");
19590 };
19591 assert!(absent_tool_params.arguments.is_absent());
19592 assert!(
19596 CoreRequest::decode(
19597 ProtocolEra::Modern2026,
19598 "tools/call",
19599 null_tool_arguments.params.as_ref(),
19600 )
19601 .is_err(),
19602 "explicit-null final tool arguments are rejected at decode"
19603 );
19604
19605 let absent_tool_result = router
19606 .dispatch_stateless(&request_ctx, &absent_tool_arguments)
19607 .expect("absent final tool arguments default to an empty object");
19608 assert_eq!(absent_tool_result["resultType"], "input_required");
19609 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19610 let null_tool_error = router
19611 .dispatch_stateless(&request_ctx, &null_tool_arguments)
19612 .expect_err("explicit-null final tool arguments are rejected");
19613 assert_eq!(null_tool_error.code, McpErrorCode::InvalidParams);
19614 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19615
19616 let absent_prompt_arguments = direct_final_prompt_request(161_i64);
19617 let mut null_prompt_arguments = absent_prompt_arguments.clone();
19618 null_prompt_arguments
19619 .params
19620 .as_mut()
19621 .and_then(serde_json::Value::as_object_mut)
19622 .expect("final prompt parameters are an object")
19623 .insert("arguments".to_owned(), serde_json::Value::Null);
19624
19625 let CoreRequest::Final(FinalCoreRequest::PromptsGet(absent_prompt_params)) =
19626 CoreRequest::decode(
19627 ProtocolEra::Modern2026,
19628 "prompts/get",
19629 absent_prompt_arguments.params.as_ref(),
19630 )
19631 .expect("absent final prompt arguments decode")
19632 else {
19633 panic!("the final prompt parameter shape is selected");
19634 };
19635 assert!(absent_prompt_params.arguments.is_absent());
19636 assert!(
19638 CoreRequest::decode(
19639 ProtocolEra::Modern2026,
19640 "prompts/get",
19641 null_prompt_arguments.params.as_ref(),
19642 )
19643 .is_err(),
19644 "explicit-null final prompt arguments are rejected at decode"
19645 );
19646
19647 let absent_prompt_result = router
19648 .dispatch_stateless(&request_ctx, &absent_prompt_arguments)
19649 .expect("absent final prompt arguments default to an empty map");
19650 assert_eq!(absent_prompt_result["resultType"], "complete");
19651 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
19652 let null_prompt_error = router
19653 .dispatch_stateless(&request_ctx, &null_prompt_arguments)
19654 .expect_err("explicit-null final prompt arguments are rejected");
19655 assert_eq!(null_prompt_error.code, McpErrorCode::InvalidParams);
19656 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
19657 }
19658
19659 #[cfg(feature = "tasks")]
19660 #[test]
19661 fn final_task_capable_tool_creates_work_bound_task_after_capability_and_service_admission() {
19662 let final_calls = Arc::new(AtomicUsize::new(0));
19663 let store = Arc::new(InMemoryFinalTaskStore::default());
19664 let runtime = task_runtime_for_router(Arc::clone(&store));
19665 let service_runner = runtime
19666 .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
19667 .expect("a bounded application-owned task service is installed");
19668 let service_cx = Cx::for_testing();
19669 let mut running_service = Box::pin(service_runner.run(&service_cx));
19670 let mut task_cx = std::task::Context::from_waker(std::task::Waker::noop());
19671 assert!(matches!(
19672 Future::poll(running_service.as_mut(), &mut task_cx),
19673 Poll::Pending
19674 ));
19675 let mut router = Router::new();
19676 router.set_final_task_runtime(Some(runtime));
19677 router
19678 .add_tool(TaskCapableRouterTool {
19679 final_calls: Arc::clone(&final_calls),
19680 })
19681 .expect("tool registration succeeds");
19682 let cx = Cx::for_testing();
19683 let state = SessionState::new();
19684 let request_ctx = request_context(&cx, 160, Budget::INFINITE, &state);
19685
19686 let result = router
19687 .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(160_i64))
19688 .expect("the admitted task-capable outcome creates a work-bound task");
19689 assert_eq!(result["resultType"], "task");
19690 assert_eq!(result["status"], "working");
19691 assert_eq!(result["statusMessage"], "router task created");
19692 assert!(
19693 result["taskId"].as_str().is_some(),
19694 "the created task has a final task identifier"
19695 );
19696 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19697 assert_eq!(store.task_count(), 1);
19698 }
19699
19700 #[cfg(feature = "tasks")]
19701 #[test]
19702 fn final_task_declaration_gates_only_the_create_task_outcome() {
19703 let final_calls = Arc::new(AtomicUsize::new(0));
19704 let store = Arc::new(InMemoryFinalTaskStore::default());
19705 let mut router = Router::new();
19706 router
19707 .add_tool(ConditionalTaskCapableRouterTool {
19708 final_calls: Arc::clone(&final_calls),
19709 })
19710 .expect("conditional task-capable tool registration succeeds");
19711 let cx = Cx::for_testing();
19712 let state = SessionState::new();
19713 let request_ctx = request_context(&cx, 164, Budget::INFINITE, &state);
19714
19715 let complete = final_tools_call_request(
19716 "conditional-task-capable-router-tool",
19717 serde_json::json!({ "createTask": false }),
19718 164_i64,
19719 );
19720 let task = final_tools_call_request(
19721 "conditional-task-capable-router-tool",
19722 serde_json::json!({ "createTask": true }),
19723 165_i64,
19724 );
19725 assert_eq!(complete.method, task.method);
19726 assert_eq!(
19727 complete
19728 .params
19729 .as_ref()
19730 .and_then(|params| params.get("_meta")),
19731 task.params.as_ref().and_then(|params| params.get("_meta")),
19732 "Tasks negotiation is unchanged between the paired requests"
19733 );
19734
19735 let result = router
19736 .dispatch_stateless(&request_ctx, &complete)
19737 .expect("a declared task-capable handler may complete without Tasks negotiation");
19738 assert_eq!(result["resultType"], "complete");
19739 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19740 assert_eq!(store.task_count(), 0);
19741
19742 let error = router
19743 .dispatch_stateless(&request_ctx, &task)
19744 .expect_err("only the CreateTask outcome requires Tasks negotiation");
19745 assert!(matches!(error.code, McpErrorCode::Custom(_)));
19746 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
19747 assert_eq!(
19748 store.task_count(),
19749 0,
19750 "a rejected CreateTask outcome must not mutate the task store"
19751 );
19752 }
19753
19754 #[cfg(feature = "tasks")]
19755 #[test]
19756 fn final_task_outcome_without_runtime_rejects_after_handler_before_store_mutation() {
19757 let final_calls = Arc::new(AtomicUsize::new(0));
19758 let store = Arc::new(InMemoryFinalTaskStore::default());
19759 let mut router = Router::new();
19760 router
19761 .add_tool(TaskCapableRouterTool {
19762 final_calls: Arc::clone(&final_calls),
19763 })
19764 .expect("tool registration succeeds");
19765 let cx = Cx::for_testing();
19766 let state = SessionState::new();
19767 let request_ctx = request_context(&cx, 161, Budget::INFINITE, &state);
19768
19769 let error = router
19770 .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(161_i64))
19771 .expect_err("a CreateTask outcome cannot persist without a final Tasks runtime");
19772 assert_eq!(error.code, McpErrorCode::InternalError);
19773 assert_eq!(
19774 error.message,
19775 "task-capable tool requires an installed final Tasks runtime"
19776 );
19777 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19778 assert_eq!(
19779 store.task_count(),
19780 0,
19781 "outcome-aware runtime admission rejects before task-store mutation"
19782 );
19783 }
19784
19785 #[cfg(feature = "tasks")]
19786 #[test]
19787 fn final_task_outcome_with_unready_service_rejects_after_handler_before_store_mutation() {
19788 let final_calls = Arc::new(AtomicUsize::new(0));
19789 let store = Arc::new(InMemoryFinalTaskStore::default());
19790 let runtime = task_runtime_for_router(Arc::clone(&store));
19791 let _unready_service = runtime
19792 .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
19793 .expect("an installed but unpolled task service remains unready");
19794 let mut router = Router::new();
19795 router.set_final_task_runtime(Some(runtime));
19796 router
19797 .add_tool(TaskCapableRouterTool {
19798 final_calls: Arc::clone(&final_calls),
19799 })
19800 .expect("tool registration succeeds");
19801 let cx = Cx::for_testing();
19802 let state = SessionState::new();
19803 let request_ctx = request_context(&cx, 162, Budget::INFINITE, &state);
19804
19805 let error = router
19806 .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(162_i64))
19807 .expect_err("an installed but unready task service is refused before task creation");
19808 assert_eq!(error.code, McpErrorCode::InvalidParams);
19809 assert_eq!(
19810 error.message,
19811 "Final task creation requires an installed ready task service"
19812 );
19813 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19814 assert_eq!(
19815 store.task_count(),
19816 0,
19817 "outcome-aware readiness admission cannot persist a task"
19818 );
19819 }
19820
19821 #[cfg(feature = "tasks")]
19822 #[test]
19823 fn final_task_outcome_requires_peer_capability_before_store_mutation() {
19824 let final_calls = Arc::new(AtomicUsize::new(0));
19825 let store = Arc::new(InMemoryFinalTaskStore::default());
19826 let runtime = task_runtime_for_router(Arc::clone(&store));
19827 let service_runner = runtime
19828 .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
19829 .expect("a bounded application-owned task service is installed");
19830 let service_cx = Cx::for_testing();
19831 let mut running_service = Box::pin(service_runner.run(&service_cx));
19832 let mut task_cx = std::task::Context::from_waker(std::task::Waker::noop());
19833 assert!(matches!(
19834 Future::poll(running_service.as_mut(), &mut task_cx),
19835 Poll::Pending
19836 ));
19837 let mut router = Router::new();
19838 router.set_final_task_runtime(Some(runtime));
19839 router
19840 .add_tool(TaskCapableRouterTool {
19841 final_calls: Arc::clone(&final_calls),
19842 })
19843 .expect("tool registration succeeds");
19844 let cx = Cx::for_testing();
19845 let state = SessionState::new();
19846 let request_ctx = request_context(&cx, 162, Budget::INFINITE, &state);
19847
19848 let error = router
19849 .dispatch_stateless(
19850 &request_ctx,
19851 &final_tools_call_request(
19852 "task-capable-router-tool",
19853 serde_json::json!({}),
19854 162_i64,
19855 ),
19856 )
19857 .expect_err("a missing peer Tasks capability is refused before task-store mutation");
19858 assert!(matches!(error.code, McpErrorCode::Custom(_)));
19859 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19860 assert_eq!(
19861 store.task_count(),
19862 0,
19863 "a rejected CreateTask outcome must not reach the task store"
19864 );
19865 }
19866
19867 #[cfg(feature = "tasks")]
19868 #[test]
19869 fn final_router_defensively_rejects_an_undeclared_task_outcome() {
19870 let final_calls = Arc::new(AtomicUsize::new(0));
19871 let mut router = Router::new();
19872 router
19873 .add_tool(UndeclaredTaskOutcomeRouterTool {
19874 final_calls: Arc::clone(&final_calls),
19875 })
19876 .expect("tool registration succeeds");
19877 let cx = Cx::for_testing();
19878 let state = SessionState::new();
19879 let request_ctx = request_context(&cx, 163, Budget::INFINITE, &state);
19880 let request = JsonRpcRequest::new(
19881 "tools/call",
19882 Some(serde_json::json!({
19883 "_meta": {
19884 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19885 "io.modelcontextprotocol/clientCapabilities": {
19886 "extensions": {
19887 "io.modelcontextprotocol/tasks": {}
19888 }
19889 },
19890 },
19891 "name": "undeclared-task-outcome-router-tool",
19892 "arguments": {},
19893 })),
19894 163_i64,
19895 );
19896
19897 let error = router
19898 .dispatch_stateless(&request_ctx, &request)
19899 .expect_err("the router refuses a task outcome without a preflight declaration");
19900 assert_eq!(error.code, McpErrorCode::InvalidRequest);
19901 assert_eq!(
19902 error.message,
19903 "tool returned CreateTask without declaring final Tasks capability"
19904 );
19905 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19906 }
19907
19908 #[test]
19909 fn macro_tool_final_metadata_negative_is_non_mutating() {
19910 let _counter_guard = MACRO_DUAL_ERA_TOOL_LOCK
19911 .lock()
19912 .unwrap_or_else(std::sync::PoisonError::into_inner);
19913 let mut router = Router::new();
19914 MACRO_DUAL_ERA_TOOL_CALLS.store(0, Ordering::SeqCst);
19915 router
19916 .add_tool(MacroDualEraTool)
19917 .expect("macro tool registration succeeds");
19918 let cx = Cx::for_testing();
19919 let state = SessionState::new();
19920 let request_ctx = request_context(&cx, 92, Budget::INFINITE, &state);
19921 let baseline = JsonRpcRequest::new(
19922 "tools/call",
19923 Some(serde_json::json!({
19924 "_meta": {
19925 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19926 "io.modelcontextprotocol/clientCapabilities": {},
19927 },
19928 "name": "macro_dual_era_tool",
19929 "arguments": {},
19930 })),
19931 92_i64,
19932 );
19933 let mut planted = baseline.clone();
19934 planted
19935 .params
19936 .as_mut()
19937 .and_then(serde_json::Value::as_object_mut)
19938 .expect("tools/call parameters are an object")
19939 .remove("_meta");
19940
19941 assert_eq!(baseline.method, planted.method);
19942 assert_eq!(baseline.id, planted.id);
19943 assert_eq!(
19944 baseline
19945 .params
19946 .as_ref()
19947 .and_then(|params| params.get("name")),
19948 planted
19949 .params
19950 .as_ref()
19951 .and_then(|params| params.get("name")),
19952 "the final metadata object is the sole planted dimension"
19953 );
19954 let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
19955 let planted_before = serde_json::to_vec(&planted).expect("request serializes");
19956
19957 let baseline_result = router
19958 .dispatch_stateless(&request_ctx, &baseline)
19959 .expect("the baseline invokes the registered handler");
19960 assert_eq!(
19961 baseline_result.get("resultType"),
19962 Some(&serde_json::json!("complete"))
19963 );
19964 assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 1);
19965
19966 let error = router
19967 .dispatch_stateless(&request_ctx, &planted)
19968 .expect_err("only final metadata is refused");
19969 assert_eq!(error.code, McpErrorCode::InvalidParams);
19970 assert_eq!(
19971 serde_json::to_vec(&planted).expect("rejected request serializes"),
19972 planted_before,
19973 "typed refusal cannot mutate caller-owned input"
19974 );
19975 assert_eq!(
19976 serde_json::to_vec(&router.tools()).expect("catalog serializes"),
19977 catalog_before,
19978 "typed refusal cannot mutate the installed handler catalog"
19979 );
19980 assert_eq!(
19981 MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst),
19982 1,
19983 "metadata refusal cannot invoke the macro-generated tool"
19984 );
19985 assert_eq!(
19986 router
19987 .dispatch_stateless(&request_ctx, &baseline)
19988 .expect("the unchanged baseline remains accepted after the rejection"),
19989 baseline_result,
19990 "the one-field rejection cannot alter the accepted final result"
19991 );
19992 assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 2);
19993 }
19994
19995 #[test]
19996 fn public_final_dispatch_encodes_input_required_handler_outcomes() {
19997 let tool_legacy_calls = Arc::new(AtomicUsize::new(0));
19998 let tool_final_calls = Arc::new(AtomicUsize::new(0));
19999 let resource_legacy_calls = Arc::new(AtomicUsize::new(0));
20000 let resource_final_calls = Arc::new(AtomicUsize::new(0));
20001 let prompt_legacy_calls = Arc::new(AtomicUsize::new(0));
20002 let prompt_final_calls = Arc::new(AtomicUsize::new(0));
20003 let mut router = Router::new();
20004 router
20005 .add_tool(InputRequiredTool {
20006 legacy_calls: Arc::clone(&tool_legacy_calls),
20007 final_calls: Arc::clone(&tool_final_calls),
20008 })
20009 .expect("tool registration succeeds");
20010 router.add_resource(InputRequiredResource {
20011 legacy_calls: Arc::clone(&resource_legacy_calls),
20012 final_calls: Arc::clone(&resource_final_calls),
20013 });
20014 router.add_prompt(InputRequiredPrompt {
20015 legacy_calls: Arc::clone(&prompt_legacy_calls),
20016 final_calls: Arc::clone(&prompt_final_calls),
20017 });
20018
20019 let cx = Cx::for_testing();
20020 let connection = ModernConnection::new();
20021 let inbound = InboundRequestContext::with_modern_connection(
20022 cx,
20023 141,
20024 InboundRequestTransport::Memory,
20025 &connection,
20026 );
20027 let request_ctx = inbound.request_context();
20028 let metadata = serde_json::json!({
20029 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20030 "io.modelcontextprotocol/clientCapabilities": {},
20031 });
20032
20033 let tool_request = JsonRpcRequest::new(
20034 "tools/call",
20035 Some(serde_json::json!({
20036 "_meta": metadata.clone(),
20037 "name": "input-required-tool",
20038 "arguments": {},
20039 })),
20040 141_i64,
20041 );
20042 let tool_typed = CoreRequest::decode(
20043 ProtocolEra::Modern2026,
20044 "tools/call",
20045 tool_request.params.as_ref(),
20046 )
20047 .expect("final tools/call request decodes");
20048 let tool_response = router
20049 .dispatch_stateless(&request_ctx, &tool_request)
20050 .expect("public final tools/call dispatch encodes input_required");
20051 assert_eq!(tool_response["resultType"], "input_required");
20052 assert_ne!(tool_response["requestState"], "tool-retry-state");
20053 assert_eq!(
20054 tool_response["inputRequests"]["roots"]["method"],
20055 "roots/list"
20056 );
20057 let tool_wire = serde_json::to_string(&tool_response).expect("tool response serializes");
20058 assert!(matches!(
20059 tool_typed.decode_result(&tool_wire),
20060 Ok(CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { result, .. }))
20061 if result.request_state().is_some()
20062 ));
20063
20064 let resource_request = JsonRpcRequest::new(
20065 "resources/read",
20066 Some(serde_json::json!({
20067 "_meta": metadata.clone(),
20068 "uri": "file:///input-required-resource",
20069 })),
20070 142_i64,
20071 );
20072 let resource_typed = CoreRequest::decode(
20073 ProtocolEra::Modern2026,
20074 "resources/read",
20075 resource_request.params.as_ref(),
20076 )
20077 .expect("final resources/read request decodes");
20078 let resource_response = router
20079 .dispatch_stateless(&request_ctx, &resource_request)
20080 .expect("public final resources/read dispatch encodes input_required");
20081 assert_eq!(resource_response["resultType"], "input_required");
20082 assert_ne!(resource_response["requestState"], "resource-retry-state");
20083 assert_eq!(
20084 resource_response["inputRequests"]["roots"]["method"],
20085 "roots/list"
20086 );
20087 let resource_wire =
20088 serde_json::to_string(&resource_response).expect("resource response serializes");
20089 assert!(matches!(
20090 resource_typed.decode_result(&resource_wire),
20091 Ok(CoreResult::Final(FinalCoreResult::ResourcesReadInputRequired { result, .. }))
20092 if result.request_state().is_some()
20093 ));
20094
20095 let prompt_request = JsonRpcRequest::new(
20096 "prompts/get",
20097 Some(serde_json::json!({
20098 "_meta": metadata,
20099 "name": "input-required-prompt",
20100 })),
20101 143_i64,
20102 );
20103 let prompt_typed = CoreRequest::decode(
20104 ProtocolEra::Modern2026,
20105 "prompts/get",
20106 prompt_request.params.as_ref(),
20107 )
20108 .expect("final prompts/get request decodes");
20109 let prompt_response = router
20110 .dispatch_stateless(&request_ctx, &prompt_request)
20111 .expect("public final prompts/get dispatch encodes input_required");
20112 assert_eq!(prompt_response["resultType"], "input_required");
20113 assert_ne!(prompt_response["requestState"], "prompt-retry-state");
20114 assert_eq!(
20115 prompt_response["inputRequests"]["roots"]["method"],
20116 "roots/list"
20117 );
20118 let prompt_wire =
20119 serde_json::to_string(&prompt_response).expect("prompt response serializes");
20120 assert!(matches!(
20121 prompt_typed.decode_result(&prompt_wire),
20122 Ok(CoreResult::Final(FinalCoreResult::PromptsGetInputRequired { result, .. }))
20123 if result.request_state().is_some()
20124 ));
20125
20126 assert_eq!(tool_legacy_calls.load(Ordering::SeqCst), 0);
20127 assert_eq!(resource_legacy_calls.load(Ordering::SeqCst), 0);
20128 assert_eq!(prompt_legacy_calls.load(Ordering::SeqCst), 0);
20129 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
20130 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
20131 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
20132 }
20133
20134 #[test]
20135 fn final_context_elicitation_round_trips_through_tools_call_mrtr() {
20136 let initial_calls = Arc::new(AtomicUsize::new(0));
20137 let resumed_calls = Arc::new(AtomicUsize::new(0));
20138 let mut router = Router::new();
20139 router
20140 .add_tool(ContextElicitationTool {
20141 initial_calls: Arc::clone(&initial_calls),
20142 resumed_calls: Arc::clone(&resumed_calls),
20143 })
20144 .expect("context elicitation tool registers");
20145
20146 let cx = Cx::for_testing();
20147 let connection = ModernConnection::new();
20148 let mut initial_request =
20149 final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 940_i64);
20150 initial_request
20151 .params
20152 .as_mut()
20153 .expect("tool params are present")["_meta"]["io.modelcontextprotocol/clientCapabilities"] = serde_json::json!({
20154 "elicitation": {"form": {}},
20155 });
20156 let initial_context = InboundRequestContext::with_modern_connection(
20157 cx.clone(),
20158 940,
20159 InboundRequestTransport::Memory,
20160 &connection,
20161 )
20162 .request_context()
20163 .with_client_capabilities(ClientCapabilityInfo::new().with_elicitation(true, false));
20164
20165 let initial = router
20166 .dispatch_stateless(&initial_context, &initial_request)
20167 .expect("an elicitation-capable final client receives MRTR input_required");
20168 assert_eq!(initial["resultType"], "input_required");
20169 assert_eq!(
20170 initial["inputRequests"]["approval"]["method"],
20171 "elicitation/create"
20172 );
20173 assert_eq!(
20174 initial["inputRequests"]["approval"]["params"]["mode"],
20175 "form"
20176 );
20177 assert!(
20178 initial["inputRequests"]["approval"]
20179 .get("jsonrpc")
20180 .is_none()
20181 && initial["inputRequests"]["approval"].get("id").is_none(),
20182 "final elicitation must be embedded input, never a reverse JSON-RPC request"
20183 );
20184 let request_state = initial["requestState"]
20185 .as_str()
20186 .expect("router mints an opaque MRTR request state")
20187 .to_owned();
20188
20189 let mut retry_request =
20190 final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 941_i64);
20191 let retry_params = retry_request
20192 .params
20193 .as_mut()
20194 .expect("tool params are present");
20195 retry_params["_meta"]["io.modelcontextprotocol/clientCapabilities"] = serde_json::json!({
20196 "elicitation": {"form": {}},
20197 });
20198 retry_params["inputResponses"] = serde_json::json!({
20199 "approval": {"action": "accept", "content": {"approved": true}},
20200 });
20201 retry_params["requestState"] = serde_json::Value::String(request_state);
20202 let retry_context = InboundRequestContext::with_modern_connection(
20203 cx,
20204 941,
20205 InboundRequestTransport::Memory,
20206 &connection,
20207 )
20208 .request_context()
20209 .with_client_capabilities(ClientCapabilityInfo::new().with_elicitation(true, false));
20210
20211 let completed = router
20212 .dispatch_stateless(&retry_context, &retry_request)
20213 .expect("the accepted final elicitation resumes the original tools/call");
20214 assert_eq!(completed["resultType"], "complete");
20215 assert_eq!(completed["content"][0]["text"], "approved");
20216 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20217 assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
20218 }
20219
20220 #[test]
20221 fn final_context_elicitation_without_capability_rejects_before_mrtr_state_mutation() {
20222 let initial_calls = Arc::new(AtomicUsize::new(0));
20223 let resumed_calls = Arc::new(AtomicUsize::new(0));
20224 let mut router = Router::new();
20225 router
20226 .add_tool(ContextElicitationTool {
20227 initial_calls: Arc::clone(&initial_calls),
20228 resumed_calls: Arc::clone(&resumed_calls),
20229 })
20230 .expect("context elicitation tool registers");
20231
20232 let cx = Cx::for_testing();
20233 let connection = ModernConnection::new();
20234 let request =
20235 final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 942_i64);
20236 let context = InboundRequestContext::with_modern_connection(
20237 cx,
20238 942,
20239 InboundRequestTransport::Memory,
20240 &connection,
20241 )
20242 .request_context();
20243
20244 let error = router
20245 .dispatch_stateless(&context, &request)
20246 .expect_err("changing only client elicitation capability must refuse the request");
20247 assert_eq!(error.code, McpErrorCode::InvalidRequest);
20248 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20249 assert_eq!(resumed_calls.load(Ordering::SeqCst), 0);
20250 assert_eq!(
20251 router.mrtr_exchanges.active_len(),
20252 0,
20253 "capability refusal cannot mint MRTR continuation state"
20254 );
20255 }
20256
20257 #[test]
20258 fn final_sampling_capability_preserves_tool_choice_and_rejection_leaves_registry_unchanged() {
20259 let calls = Arc::new(AtomicUsize::new(0));
20260 let mut router = Router::new();
20261 router
20262 .add_tool(ContextSamplingTool {
20263 calls: Arc::clone(&calls),
20264 })
20265 .expect("context sampling tool registers");
20266
20267 let cx = Cx::for_testing();
20268 let connection = ModernConnection::new();
20269 let request =
20270 final_tools_call_request("context-sampling-tool", serde_json::json!({}), 943_i64);
20271 let admitted_context = InboundRequestContext::with_modern_connection(
20272 cx.clone(),
20273 943,
20274 InboundRequestTransport::Memory,
20275 &connection,
20276 )
20277 .request_context()
20278 .with_client_capabilities(ClientCapabilityInfo::new().with_sampling());
20279 let admitted = router
20280 .dispatch_stateless(&admitted_context, &request)
20281 .expect("sampling capability admits final MRTR sampling");
20282 assert_eq!(admitted["resultType"], "input_required");
20283 assert_eq!(
20284 admitted["inputRequests"]["sample"]["params"]["toolChoice"],
20285 serde_json::json!({"mode": "required"})
20286 );
20287 assert_eq!(
20288 admitted["inputRequests"]["sample"]["params"]["messages"][0]["content"]["type"],
20289 "tool_use"
20290 );
20291 assert_eq!(router.mrtr_exchanges.active_len(), 1);
20292
20293 let removed_capability_context = InboundRequestContext::with_modern_connection(
20294 cx,
20295 944,
20296 InboundRequestTransport::Memory,
20297 &connection,
20298 )
20299 .request_context();
20300 let rejection = router
20301 .dispatch_stateless(&removed_capability_context, &request)
20302 .expect_err("removing only sampling capability rejects the descriptor");
20303 assert_eq!(rejection.code, McpErrorCode::InvalidRequest);
20304 assert_eq!(
20305 router.mrtr_exchanges.active_len(),
20306 1,
20307 "the one-field capability removal cannot mutate the admitted registry"
20308 );
20309 assert_eq!(calls.load(Ordering::SeqCst), 2);
20310 }
20311
20312 #[test]
20313 fn http_admission_raw_sidecar_reaches_tool_resource_and_prompt_mrtr_retries() {
20314 let tool_calls = Arc::new(AtomicUsize::new(0));
20315 let resource_calls = Arc::new(AtomicUsize::new(0));
20316 let prompt_calls = Arc::new(AtomicUsize::new(0));
20317 let mut router = Router::new();
20318 router
20319 .add_tool(InputRequiredTool {
20320 legacy_calls: Arc::new(AtomicUsize::new(0)),
20321 final_calls: Arc::clone(&tool_calls),
20322 })
20323 .expect("tool registers");
20324 router.add_resource(InputRequiredResource {
20325 legacy_calls: Arc::new(AtomicUsize::new(0)),
20326 final_calls: Arc::clone(&resource_calls),
20327 });
20328 router.add_prompt(InputRequiredPrompt {
20329 legacy_calls: Arc::new(AtomicUsize::new(0)),
20330 final_calls: Arc::clone(&prompt_calls),
20331 });
20332
20333 let cx = Cx::for_testing();
20334 let connection = ModernConnection::new();
20335 let inbound = InboundRequestContext::with_modern_connection(
20336 cx,
20337 1438,
20338 InboundRequestTransport::Http,
20339 &connection,
20340 );
20341 let request_ctx = inbound.request_context();
20342 let cancellation = inbound
20343 .mrtr_continuation_cancellation()
20344 .expect("HTTP-bound modern context owns continuations");
20345 let metadata = serde_json::json!({
20346 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20347 "io.modelcontextprotocol/clientCapabilities": {},
20348 });
20349 let roots = serde_json::to_string(&router_roots_response_wire())
20350 .expect("roots response serializes");
20351
20352 let tool_initial_body = serde_json::to_vec(&serde_json::json!({
20353 "jsonrpc": "2.0", "id": 1438, "method": "tools/call",
20354 "params": {"_meta": metadata.clone(), "name": "input-required-tool", "arguments": {}},
20355 }))
20356 .expect("tool body serializes");
20357 let (tool_initial, tool_initial_raw) =
20358 admit_http_wire("tools/call", "input-required-tool", &tool_initial_body);
20359 let tool_issued = router
20360 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20361 &request_ctx,
20362 &tool_initial,
20363 tool_initial_raw.as_deref(),
20364 &cancellation,
20365 )
20366 .expect("HTTP-admitted tool issues state");
20367 let tool_retry_body = format!(
20368 r#"{{"jsonrpc":"2.0","id":1439,"method":"tools/call","params":{{"_meta":{},"name":"input-required-tool","arguments":{{}},"inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
20369 serde_json::to_string(&metadata).expect("metadata serializes"),
20370 roots,
20371 roots,
20372 serde_json::to_string(&tool_issued["requestState"]).expect("tool state serializes"),
20373 );
20374 let (tool_retry, tool_retry_raw) = admit_http_wire(
20375 "tools/call",
20376 "input-required-tool",
20377 tool_retry_body.as_bytes(),
20378 );
20379 router
20380 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20381 &request_ctx,
20382 &tool_retry,
20383 tool_retry_raw.as_deref(),
20384 &cancellation,
20385 )
20386 .expect("HTTP-admitted ordered tool retry reaches the handler");
20387
20388 let resource_initial_body = serde_json::to_vec(&serde_json::json!({
20389 "jsonrpc": "2.0", "id": 1440, "method": "resources/read",
20390 "params": {"_meta": metadata.clone(), "uri": "file:///input-required-resource"},
20391 }))
20392 .expect("resource body serializes");
20393 let (resource_initial, resource_initial_raw) = admit_http_wire(
20394 "resources/read",
20395 "file:///input-required-resource",
20396 &resource_initial_body,
20397 );
20398 let resource_issued = router
20399 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20400 &request_ctx,
20401 &resource_initial,
20402 resource_initial_raw.as_deref(),
20403 &cancellation,
20404 )
20405 .expect("HTTP-admitted resource issues state");
20406 let resource_retry_body = format!(
20407 r#"{{"jsonrpc":"2.0","id":1441,"method":"resources/read","params":{{"_meta":{},"uri":"file:///input-required-resource","inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
20408 serde_json::to_string(&metadata).expect("metadata serializes"),
20409 roots,
20410 roots,
20411 serde_json::to_string(&resource_issued["requestState"])
20412 .expect("resource state serializes"),
20413 );
20414 let (resource_retry, resource_retry_raw) = admit_http_wire(
20415 "resources/read",
20416 "file:///input-required-resource",
20417 resource_retry_body.as_bytes(),
20418 );
20419 let mut sanitized_resource_retry = resource_retry.clone();
20420 sanitized_resource_retry
20421 .params
20422 .as_mut()
20423 .and_then(serde_json::Value::as_object_mut)
20424 .expect("HTTP-admitted resource parameters are an object")
20425 .insert(
20426 "uri".to_owned(),
20427 serde_json::json!("file:///sanitized-resource"),
20428 );
20429 let resource_sidecar_error = router
20430 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20431 &request_ctx,
20432 &sanitized_resource_retry,
20433 resource_retry_raw.as_deref(),
20434 &cancellation,
20435 )
20436 .expect_err(
20437 "a resource raw sidecar cannot survive a one-field sanitized typed mismatch",
20438 );
20439 assert_eq!(resource_sidecar_error.code, McpErrorCode::InvalidParams);
20440 assert_eq!(
20441 resource_calls.load(Ordering::SeqCst),
20442 1,
20443 "the mismatched resource sidecar is rejected before handler invocation"
20444 );
20445 assert_eq!(
20446 router.mrtr_exchanges.active_len(),
20447 2,
20448 "the rejected resource sidecar leaves its continuation available"
20449 );
20450 router
20451 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20452 &request_ctx,
20453 &resource_retry,
20454 resource_retry_raw.as_deref(),
20455 &cancellation,
20456 )
20457 .expect("HTTP-admitted ordered resource retry reaches the handler");
20458
20459 let prompt_initial_body = serde_json::to_vec(&serde_json::json!({
20460 "jsonrpc": "2.0", "id": 1442, "method": "prompts/get",
20461 "params": {"_meta": metadata.clone(), "name": "input-required-prompt"},
20462 }))
20463 .expect("prompt body serializes");
20464 let (prompt_initial, prompt_initial_raw) =
20465 admit_http_wire("prompts/get", "input-required-prompt", &prompt_initial_body);
20466 let prompt_issued = router
20467 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20468 &request_ctx,
20469 &prompt_initial,
20470 prompt_initial_raw.as_deref(),
20471 &cancellation,
20472 )
20473 .expect("HTTP-admitted prompt issues state");
20474 let prompt_retry_body = format!(
20475 r#"{{"jsonrpc":"2.0","id":1443,"method":"prompts/get","params":{{"_meta":{},"name":"input-required-prompt","inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
20476 serde_json::to_string(&metadata).expect("metadata serializes"),
20477 roots,
20478 roots,
20479 serde_json::to_string(&prompt_issued["requestState"]).expect("prompt state serializes"),
20480 );
20481 let (prompt_retry, prompt_retry_raw) = admit_http_wire(
20482 "prompts/get",
20483 "input-required-prompt",
20484 prompt_retry_body.as_bytes(),
20485 );
20486 let mut sanitized_prompt_retry = prompt_retry.clone();
20487 sanitized_prompt_retry
20488 .params
20489 .as_mut()
20490 .and_then(serde_json::Value::as_object_mut)
20491 .expect("HTTP-admitted prompt parameters are an object")
20492 .insert("name".to_owned(), serde_json::json!("sanitized-prompt"));
20493 let prompt_sidecar_error = router
20494 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20495 &request_ctx,
20496 &sanitized_prompt_retry,
20497 prompt_retry_raw.as_deref(),
20498 &cancellation,
20499 )
20500 .expect_err("a prompt raw sidecar cannot survive a one-field sanitized typed mismatch");
20501 assert_eq!(prompt_sidecar_error.code, McpErrorCode::InvalidParams);
20502 assert_eq!(
20503 prompt_calls.load(Ordering::SeqCst),
20504 1,
20505 "the mismatched prompt sidecar is rejected before handler invocation"
20506 );
20507 assert_eq!(
20508 router.mrtr_exchanges.active_len(),
20509 3,
20510 "the rejected prompt sidecar leaves its continuation available"
20511 );
20512 router
20513 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20514 &request_ctx,
20515 &prompt_retry,
20516 prompt_retry_raw.as_deref(),
20517 &cancellation,
20518 )
20519 .expect("HTTP-admitted ordered prompt retry reaches the handler");
20520
20521 assert_eq!(tool_calls.load(Ordering::SeqCst), 2);
20522 assert_eq!(resource_calls.load(Ordering::SeqCst), 2);
20523 assert_eq!(prompt_calls.load(Ordering::SeqCst), 2);
20524 }
20525
20526 #[test]
20527 fn stateless_final_dispatch_keeps_complete_only_handlers_available_for_all_families() {
20528 let mut router = Router::new();
20529 router
20530 .add_tool(NamedTool::new("complete-only-tool"))
20531 .expect("complete-only tool registers");
20532 router.add_resource(NamedResource::new("file:///complete-only-resource"));
20533 router.add_prompt(NamedPrompt::new("complete-only-prompt"));
20534
20535 let cx = Cx::for_testing();
20536 let state = SessionState::new();
20537 let request_ctx = request_context(&cx, 1430, Budget::INFINITE, &state);
20538 let metadata = serde_json::json!({
20539 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20540 "io.modelcontextprotocol/clientCapabilities": {},
20541 });
20542
20543 let tool = router
20544 .dispatch_stateless(
20545 &request_ctx,
20546 &final_tools_call_request("complete-only-tool", serde_json::json!({}), 1430_i64),
20547 )
20548 .expect("a complete-only tool remains available without a session partition");
20549 assert_eq!(tool["resultType"], "complete");
20550
20551 let resource = router
20552 .dispatch_stateless(
20553 &request_ctx,
20554 &JsonRpcRequest::new(
20555 "resources/read",
20556 Some(serde_json::json!({
20557 "_meta": metadata.clone(),
20558 "uri": "file:///complete-only-resource",
20559 })),
20560 1431_i64,
20561 ),
20562 )
20563 .expect("a complete-only resource remains available without a session partition");
20564 assert_eq!(resource["resultType"], "complete");
20565
20566 let prompt = router
20567 .dispatch_stateless(
20568 &request_ctx,
20569 &JsonRpcRequest::new(
20570 "prompts/get",
20571 Some(serde_json::json!({
20572 "_meta": metadata,
20573 "name": "complete-only-prompt",
20574 })),
20575 1432_i64,
20576 ),
20577 )
20578 .expect("a complete-only prompt remains available without a session partition");
20579 assert_eq!(prompt["resultType"], "complete");
20580 }
20581
20582 #[test]
20583 fn stateless_mrtr_handlers_are_rejected_before_all_family_invocations() {
20584 let tool_calls = Arc::new(AtomicUsize::new(0));
20585 let resource_calls = Arc::new(AtomicUsize::new(0));
20586 let prompt_calls = Arc::new(AtomicUsize::new(0));
20587 let mut router = Router::new();
20588 router
20589 .add_tool(InputRequiredTool {
20590 legacy_calls: Arc::new(AtomicUsize::new(0)),
20591 final_calls: Arc::clone(&tool_calls),
20592 })
20593 .expect("MRTR tool registers");
20594 router.add_resource(InputRequiredResource {
20595 legacy_calls: Arc::new(AtomicUsize::new(0)),
20596 final_calls: Arc::clone(&resource_calls),
20597 });
20598 router.add_prompt(InputRequiredPrompt {
20599 legacy_calls: Arc::new(AtomicUsize::new(0)),
20600 final_calls: Arc::clone(&prompt_calls),
20601 });
20602
20603 let cx = Cx::for_testing();
20604 let state = SessionState::new();
20605 let request_ctx = request_context(&cx, 1433, Budget::INFINITE, &state);
20606 let metadata = serde_json::json!({
20607 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20608 "io.modelcontextprotocol/clientCapabilities": {},
20609 });
20610
20611 let tool_error = router
20612 .dispatch_stateless(
20613 &request_ctx,
20614 &final_tools_call_request("input-required-tool", serde_json::json!({}), 1433_i64),
20615 )
20616 .expect_err("a stateless MRTR tool is rejected before its handler runs");
20617 assert_eq!(tool_error.code, McpErrorCode::InvalidParams);
20618
20619 let resource_error = router
20620 .dispatch_stateless(
20621 &request_ctx,
20622 &JsonRpcRequest::new(
20623 "resources/read",
20624 Some(serde_json::json!({
20625 "_meta": metadata.clone(),
20626 "uri": "file:///input-required-resource",
20627 })),
20628 1434_i64,
20629 ),
20630 )
20631 .expect_err("a stateless MRTR resource is rejected before its handler runs");
20632 assert_eq!(resource_error.code, McpErrorCode::InvalidParams);
20633
20634 let prompt_error = router
20635 .dispatch_stateless(
20636 &request_ctx,
20637 &JsonRpcRequest::new(
20638 "prompts/get",
20639 Some(serde_json::json!({
20640 "_meta": metadata,
20641 "name": "input-required-prompt",
20642 })),
20643 1435_i64,
20644 ),
20645 )
20646 .expect_err("a stateless MRTR prompt is rejected before its handler runs");
20647 assert_eq!(prompt_error.code, McpErrorCode::InvalidParams);
20648 assert_eq!(tool_calls.load(Ordering::SeqCst), 0);
20649 assert_eq!(resource_calls.load(Ordering::SeqCst), 0);
20650 assert_eq!(prompt_calls.load(Ordering::SeqCst), 0);
20651 assert_eq!(router.mrtr_exchanges.active_len(), 0);
20652 }
20653
20654 #[test]
20655 fn bound_owned_mrtr_retry_rejects_foreign_and_stale_state_without_mutation_then_resumes() {
20656 let calls = Arc::new(AtomicUsize::new(0));
20657 let mut unshared_router = Router::new();
20658 unshared_router
20659 .add_tool(OneShotMrtrTool {
20660 calls: Arc::clone(&calls),
20661 })
20662 .expect("one-shot MRTR tool registers");
20663 let router = Arc::new(unshared_router);
20664 let cx = Cx::for_testing();
20665 let origin_connection = ModernConnection::new();
20666 let origin_inbound = InboundRequestContext::with_modern_connection(
20667 cx.clone(),
20668 1436,
20669 InboundRequestTransport::Memory,
20670 &origin_connection,
20671 );
20672 let origin_ctx = origin_inbound.request_context();
20673 let origin_cancellation = origin_inbound
20674 .mrtr_continuation_cancellation()
20675 .expect("a bound origin supplies continuation ownership");
20676 let initial =
20677 final_tools_call_request("one-shot-mrtr-tool", serde_json::json!({}), 1436_i64);
20678 let issued = block_on(
20679 Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
20680 origin_ctx,
20681 initial,
20682 origin_cancellation.clone(),
20683 ),
20684 )
20685 .expect("the bound owned path issues one continuation");
20686 let request_state = issued["requestState"]
20687 .as_str()
20688 .expect("issued continuation has opaque state")
20689 .to_owned();
20690 assert_eq!(calls.load(Ordering::SeqCst), 1);
20691 assert_eq!(router.mrtr_exchanges.active_len(), 1);
20692
20693 let retry = JsonRpcRequest::new(
20694 "tools/call",
20695 Some(serde_json::json!({
20696 "_meta": {
20697 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20698 "io.modelcontextprotocol/clientCapabilities": {},
20699 },
20700 "name": "one-shot-mrtr-tool",
20701 "arguments": {},
20702 "inputResponses": {"roots": router_roots_response_wire()},
20703 "requestState": request_state,
20704 })),
20705 1437_i64,
20706 );
20707
20708 let mismatched_sidecar = serde_json::to_string(
20709 retry
20710 .params
20711 .as_ref()
20712 .expect("retry has materialized parameters"),
20713 )
20714 .expect("retry parameters serialize")
20715 .replacen("one-shot-mrtr-tool", "other-tool", 1);
20716 let sidecar_error = block_on(
20717 Arc::clone(&router)
20718 .dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
20719 origin_inbound.request_context(),
20720 retry.clone(),
20721 Some(Arc::<str>::from(mismatched_sidecar)),
20722 origin_cancellation.clone(),
20723 ),
20724 )
20725 .expect_err("a mismatched raw sidecar cannot reach continuation admission");
20726 assert_eq!(sidecar_error.code, McpErrorCode::InvalidParams);
20727 assert_eq!(calls.load(Ordering::SeqCst), 1);
20728 assert_eq!(router.mrtr_exchanges.active_len(), 1);
20729
20730 let foreign_connection = ModernConnection::new();
20731 let foreign_inbound = InboundRequestContext::with_modern_connection(
20732 cx.clone(),
20733 1437,
20734 InboundRequestTransport::Memory,
20735 &foreign_connection,
20736 );
20737 let foreign_error = block_on(
20738 Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
20739 foreign_inbound.request_context(),
20740 retry.clone(),
20741 foreign_inbound
20742 .mrtr_continuation_cancellation()
20743 .expect("a foreign connection still supplies its own cancellation"),
20744 ),
20745 )
20746 .expect_err("a foreign connection cannot consume origin continuation state");
20747 assert_eq!(foreign_error.code, McpErrorCode::InvalidParams);
20748 assert_eq!(calls.load(Ordering::SeqCst), 1);
20749 assert_eq!(router.mrtr_exchanges.active_len(), 1);
20750
20751 let mut stale_retry = retry.clone();
20752 stale_retry
20753 .params
20754 .as_mut()
20755 .and_then(serde_json::Value::as_object_mut)
20756 .expect("retry parameters are an object")
20757 .insert(
20758 "requestState".to_owned(),
20759 serde_json::json!("stale-request-state"),
20760 );
20761 let stale_error = block_on(
20762 Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
20763 origin_inbound.request_context(),
20764 stale_retry,
20765 origin_cancellation.clone(),
20766 ),
20767 )
20768 .expect_err("a stale state cannot consume the live continuation");
20769 assert_eq!(stale_error.code, McpErrorCode::InvalidParams);
20770 assert_eq!(calls.load(Ordering::SeqCst), 1);
20771 assert_eq!(router.mrtr_exchanges.active_len(), 1);
20772
20773 let completed = block_on(
20774 Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
20775 origin_inbound.request_context(),
20776 retry,
20777 origin_cancellation,
20778 ),
20779 )
20780 .expect("the unchanged origin retry resumes exactly one continuation");
20781 assert_eq!(completed["resultType"], "complete");
20782 assert_eq!(calls.load(Ordering::SeqCst), 2);
20783 assert_eq!(router.mrtr_exchanges.active_len(), 0);
20784 }
20785
20786 #[test]
20787 fn modern_mrtr_state_only_retry_round_trips_only_when_input_responses_are_absent() {
20788 let initial_calls = Arc::new(AtomicUsize::new(0));
20789 let resumed_calls = Arc::new(AtomicUsize::new(0));
20790 let mut router = Router::new();
20791 router
20792 .add_tool(StateOnlyInputRequiredTool {
20793 initial_calls: Arc::clone(&initial_calls),
20794 resumed_calls: Arc::clone(&resumed_calls),
20795 })
20796 .expect("state-only tool registration succeeds");
20797
20798 let cx = Cx::for_testing();
20799 let connection = ModernConnection::new();
20800 let inbound = InboundRequestContext::with_modern_connection(
20801 cx,
20802 144,
20803 InboundRequestTransport::Memory,
20804 &connection,
20805 );
20806 let request_ctx = inbound.request_context();
20807 let metadata = serde_json::json!({
20808 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20809 "io.modelcontextprotocol/clientCapabilities": {},
20810 });
20811 let initial = JsonRpcRequest::new(
20812 "tools/call",
20813 Some(serde_json::json!({
20814 "_meta": metadata.clone(),
20815 "name": "state-only-input-required-tool",
20816 "arguments": {},
20817 })),
20818 144_i64,
20819 );
20820 let input_required = router
20821 .dispatch_stateless(&request_ctx, &initial)
20822 .expect("state-only handler outcome is framework-bound");
20823 assert_eq!(input_required["resultType"], "input_required");
20824 assert!(
20825 input_required.get("inputRequests").is_none(),
20826 "the framework preserves state-only input_required without an empty request map"
20827 );
20828 let request_state = input_required["requestState"]
20829 .as_str()
20830 .expect("framework output includes opaque state")
20831 .to_owned();
20832
20833 let retry = JsonRpcRequest::new(
20834 "tools/call",
20835 Some(serde_json::json!({
20836 "_meta": metadata,
20837 "name": "state-only-input-required-tool",
20838 "arguments": {},
20839 "requestState": request_state,
20840 })),
20841 145_i64,
20842 );
20843 assert!(
20844 retry
20845 .params
20846 .as_ref()
20847 .and_then(serde_json::Value::as_object)
20848 .is_some_and(|params| !params.contains_key("inputResponses")),
20849 "the admitted retry keeps inputResponses absent"
20850 );
20851 let mut explicit_empty = retry.clone();
20852 explicit_empty
20853 .params
20854 .as_mut()
20855 .and_then(serde_json::Value::as_object_mut)
20856 .expect("retry parameters are an object")
20857 .insert("inputResponses".to_owned(), serde_json::json!({}));
20858 let explicit_empty_raw_params = serde_json::to_string(
20859 explicit_empty
20860 .params
20861 .as_ref()
20862 .expect("explicit-empty retry has parameters"),
20863 )
20864 .expect("explicit-empty retry parameters serialize");
20865 let empty_error = router
20866 .dispatch_stateless_with_raw_params(
20867 &request_ctx,
20868 &explicit_empty,
20869 Some(&explicit_empty_raw_params),
20870 )
20871 .expect_err("an explicit empty inputResponses map cannot impersonate an absent member");
20872 assert_eq!(empty_error.code, McpErrorCode::InvalidParams);
20873 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20874 assert_eq!(resumed_calls.load(Ordering::SeqCst), 0);
20875
20876 let retry_raw_params = serde_json::to_string(
20877 retry
20878 .params
20879 .as_ref()
20880 .expect("absent-member retry has parameters"),
20881 )
20882 .expect("absent-member retry parameters serialize");
20883 let completed = router
20884 .dispatch_stateless_with_raw_params(&request_ctx, &retry, Some(&retry_raw_params))
20885 .expect("the unchanged absent-member retry resumes exactly once");
20886 assert_eq!(completed["resultType"], "complete");
20887 assert_eq!(completed["content"][0]["text"], "state-only resumed");
20888 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20889 assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
20890 }
20891
20892 #[test]
20893 fn modern_mrtr_retries_resume_each_method_once_and_refuse_replay_or_kind_mismatch() {
20894 let tool_final_calls = Arc::new(AtomicUsize::new(0));
20895 let resource_final_calls = Arc::new(AtomicUsize::new(0));
20896 let prompt_final_calls = Arc::new(AtomicUsize::new(0));
20897 let mut router = Router::new();
20898 router
20899 .add_tool(InputRequiredTool {
20900 legacy_calls: Arc::new(AtomicUsize::new(0)),
20901 final_calls: Arc::clone(&tool_final_calls),
20902 })
20903 .expect("tool registration succeeds");
20904 router.add_resource(InputRequiredResource {
20905 legacy_calls: Arc::new(AtomicUsize::new(0)),
20906 final_calls: Arc::clone(&resource_final_calls),
20907 });
20908 router.add_prompt(InputRequiredPrompt {
20909 legacy_calls: Arc::new(AtomicUsize::new(0)),
20910 final_calls: Arc::clone(&prompt_final_calls),
20911 });
20912
20913 let cx = Cx::for_testing();
20914 let connection = ModernConnection::new();
20915 let inbound = InboundRequestContext::with_modern_connection(
20916 cx.clone(),
20917 145,
20918 InboundRequestTransport::Memory,
20919 &connection,
20920 );
20921 let request_ctx = inbound.request_context();
20922 let metadata = serde_json::json!({
20923 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20924 "io.modelcontextprotocol/clientCapabilities": {},
20925 });
20926
20927 let tool_initial = JsonRpcRequest::new(
20928 "tools/call",
20929 Some(serde_json::json!({
20930 "_meta": metadata.clone(),
20931 "name": "input-required-tool",
20932 "arguments": {},
20933 })),
20934 145_i64,
20935 );
20936 let tool_initial_result = router
20937 .dispatch_stateless(&request_ctx, &tool_initial)
20938 .expect("normal final dispatch mints the tool request state");
20939 let tool_state = tool_initial_result["requestState"]
20940 .as_str()
20941 .expect("framework result carries opaque tool state")
20942 .to_owned();
20943 let tool_retry = JsonRpcRequest::new(
20944 "tools/call",
20945 Some(serde_json::json!({
20946 "_meta": metadata.clone(),
20947 "name": "input-required-tool",
20948 "arguments": {},
20949 "inputResponses": {"roots": router_roots_response_wire()},
20950 "requestState": tool_state,
20951 })),
20952 145_i64,
20953 );
20954 let raw_params_for = |request: &JsonRpcRequest| {
20955 serde_json::to_string(request.params.as_ref().expect("retry has parameters"))
20956 .expect("retry parameters serialize")
20957 };
20958 let mut kind_mismatch = tool_retry.clone();
20959 kind_mismatch
20960 .params
20961 .as_mut()
20962 .and_then(serde_json::Value::as_object_mut)
20963 .and_then(|params| params.get_mut("inputResponses"))
20964 .and_then(serde_json::Value::as_object_mut)
20965 .expect("tool retry contains an inputResponses object")
20966 .insert(
20967 "roots".to_owned(),
20968 serde_json::to_value(
20969 MrtrInputResponse::sampling(
20970 serde_json::from_value(serde_json::json!({
20971 "content": {"type": "text", "text": "not roots"},
20972 "role": "assistant",
20973 "model": "test-model",
20974 }))
20975 .expect("final sampling response must decode"),
20976 )
20977 .expect("sampling response serializes"),
20978 )
20979 .expect("sampling response converts to a wire value"),
20980 );
20981 let kind_mismatch_raw_params = raw_params_for(&kind_mismatch);
20982 let kind_error = router
20983 .dispatch_stateless_with_raw_params(
20984 &request_ctx,
20985 &kind_mismatch,
20986 Some(&kind_mismatch_raw_params),
20987 )
20988 .expect_err("changing only the response kind is refused before handler invocation");
20989 assert_eq!(kind_error.code, McpErrorCode::InvalidParams);
20990 assert_eq!(
20991 tool_final_calls.load(Ordering::SeqCst),
20992 1,
20993 "a wrong-kind retry must leave the matching state unconsumed"
20994 );
20995
20996 let missing_responses = JsonRpcRequest::new(
20997 "tools/call",
20998 Some(serde_json::json!({
20999 "_meta": metadata.clone(),
21000 "name": "input-required-tool",
21001 "arguments": {},
21002 "inputResponses": {},
21003 "requestState": tool_state,
21004 })),
21005 145_i64,
21006 );
21007 let missing_raw_params = raw_params_for(&missing_responses);
21008 let missing_error = router
21009 .dispatch_stateless_with_raw_params(
21010 &request_ctx,
21011 &missing_responses,
21012 Some(&missing_raw_params),
21013 )
21014 .expect_err("a missing roots response cannot consume a tool continuation");
21015 assert_eq!(missing_error.code, McpErrorCode::InvalidParams);
21016 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21017
21018 let roots_wire = serde_json::to_string(&router_roots_response_wire())
21019 .expect("roots response serializes for duplicate-key raw ingress");
21020 let duplicate_raw_params = format!(
21021 r#"{{"_meta":{},"name":"input-required-tool","arguments":{{}},"inputResponses":{{"roots":{roots_wire},"roots":{roots_wire}}},"requestState":{}}}"#,
21022 serde_json::to_string(&metadata).expect("metadata serializes"),
21023 serde_json::to_string(&tool_state).expect("opaque state serializes"),
21024 );
21025 let duplicate_retry = JsonRpcRequest::new(
21026 "tools/call",
21027 Some(
21028 serde_json::from_str(&duplicate_raw_params)
21029 .expect("duplicate raw parameters materialize for source equality"),
21030 ),
21031 145_i64,
21032 );
21033 let duplicate_error = router
21034 .dispatch_stateless_with_raw_params(
21035 &request_ctx,
21036 &duplicate_retry,
21037 Some(&duplicate_raw_params),
21038 )
21039 .expect_err("duplicate raw response keys cannot collapse before MRTR admission");
21040 assert_eq!(duplicate_error.code, McpErrorCode::InvalidParams);
21041 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21042
21043 let mut unknown_only = tool_retry.clone();
21044 *unknown_only
21045 .params
21046 .as_mut()
21047 .and_then(serde_json::Value::as_object_mut)
21048 .and_then(|params| params.get_mut("inputResponses"))
21049 .expect("tool retry contains inputResponses") = serde_json::json!({"inert": null});
21050 let unknown_only_raw_params = raw_params_for(&unknown_only);
21051 let unknown_error = router
21052 .dispatch_stateless_with_raw_params(
21053 &request_ctx,
21054 &unknown_only,
21055 Some(&unknown_only_raw_params),
21056 )
21057 .expect_err("unknown-only inputResponses cannot consume a tool continuation");
21058 assert_eq!(unknown_error.code, McpErrorCode::InvalidParams);
21059 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21060
21061 let mut oversized_map = tool_retry.clone();
21062 let responses = oversized_map
21063 .params
21064 .as_mut()
21065 .and_then(serde_json::Value::as_object_mut)
21066 .and_then(|params| params.get_mut("inputResponses"))
21067 .and_then(serde_json::Value::as_object_mut)
21068 .expect("tool retry contains an inputResponses object");
21069 for index in 0..crate::bidirectional::DEFAULT_MAX_MRTR_INPUT_REQUESTS_PER_ROUND {
21070 responses.insert(format!("inert-{index}"), serde_json::Value::Null);
21071 }
21072 let oversized_error = router
21073 .dispatch_stateless(&request_ctx, &oversized_map)
21074 .expect_err("an oversized raw response map is refused before retry decoding");
21075 assert_eq!(oversized_error.code, McpErrorCode::InvalidParams);
21076 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21077
21078 let mut tool_oversized_bytes = tool_retry.clone();
21079 tool_oversized_bytes
21080 .params
21081 .as_mut()
21082 .and_then(serde_json::Value::as_object_mut)
21083 .and_then(|params| params.get_mut("inputResponses"))
21084 .and_then(serde_json::Value::as_object_mut)
21085 .expect("tool retry contains an inputResponses object")
21086 .insert(
21087 "roots".to_owned(),
21088 serde_json::Value::String("x".repeat(MAX_MRTR_RAW_INPUT_RESPONSES_BYTES + 1)),
21089 );
21090 let tool_bytes_error = router
21091 .dispatch_stateless(&request_ctx, &tool_oversized_bytes)
21092 .expect_err("only an oversized tool response value is refused before retry decoding");
21093 assert_eq!(tool_bytes_error.code, McpErrorCode::InvalidParams);
21094 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21095
21096 let mut target_mismatch = tool_retry.clone();
21097 target_mismatch
21098 .params
21099 .as_mut()
21100 .and_then(serde_json::Value::as_object_mut)
21101 .expect("tool retry parameters are an object")
21102 .insert("name".to_owned(), serde_json::json!("other-tool"));
21103 let target_error = router
21104 .dispatch_stateless(&request_ctx, &target_mismatch)
21105 .expect_err("changing only the target cannot consume a tool state");
21106 assert_eq!(target_error.code, McpErrorCode::InvalidParams);
21107 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21108
21109 let other_connection = ModernConnection::new();
21110 let other_inbound = InboundRequestContext::with_modern_connection(
21111 cx.clone(),
21112 145,
21113 InboundRequestTransport::Memory,
21114 &other_connection,
21115 );
21116 let other_session_ctx = other_inbound.request_context();
21117 let session_error = router
21118 .dispatch_stateless(&other_session_ctx, &tool_retry)
21119 .expect_err("changing only the session cannot consume a tool state");
21120 assert_eq!(session_error.code, McpErrorCode::InvalidParams);
21121 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21122
21123 let principal_ctx = inbound.request_context();
21124 assert!(principal_ctx.set_auth(fastmcp_core::AuthContext::with_subject("other-user")));
21125 let principal_error = router
21126 .dispatch_stateless(&principal_ctx, &tool_retry)
21127 .expect_err("changing only the principal cannot consume a tool state");
21128 assert_eq!(principal_error.code, McpErrorCode::InvalidParams);
21129 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21130
21131 let tool_retry_raw_params = raw_params_for(&tool_retry);
21132 let tool_response = router
21133 .dispatch_stateless_with_raw_params(
21134 &request_ctx,
21135 &tool_retry,
21136 Some(&tool_retry_raw_params),
21137 )
21138 .expect("a framework-minted tool state resumes through the final handler");
21139 assert_eq!(tool_response["resultType"], "input_required");
21140 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 2);
21141
21142 let resource_initial = JsonRpcRequest::new(
21143 "resources/read",
21144 Some(serde_json::json!({
21145 "_meta": metadata.clone(),
21146 "uri": "file:///input-required-resource",
21147 })),
21148 146_i64,
21149 );
21150 let resource_initial_result = router
21151 .dispatch_stateless(&request_ctx, &resource_initial)
21152 .expect("normal final dispatch mints the resource request state");
21153 let resource_state = resource_initial_result["requestState"]
21154 .as_str()
21155 .expect("framework result carries opaque resource state")
21156 .to_owned();
21157 let resource_retry = JsonRpcRequest::new(
21158 "resources/read",
21159 Some(serde_json::json!({
21160 "_meta": metadata.clone(),
21161 "uri": "file:///input-required-resource",
21162 "inputResponses": {"roots": router_roots_response_wire()},
21163 "requestState": resource_state,
21164 })),
21165 146_i64,
21166 );
21167 let mut resource_unknown_only = resource_retry.clone();
21168 *resource_unknown_only
21169 .params
21170 .as_mut()
21171 .and_then(serde_json::Value::as_object_mut)
21172 .and_then(|params| params.get_mut("inputResponses"))
21173 .expect("resource retry contains inputResponses") = serde_json::json!({"inert": null});
21174 let resource_unknown_error = router
21175 .dispatch_stateless(&request_ctx, &resource_unknown_only)
21176 .expect_err("unknown-only inputResponses cannot consume a resource continuation");
21177 assert_eq!(resource_unknown_error.code, McpErrorCode::InvalidParams);
21178 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
21179
21180 let mut resource_nested_value = serde_json::Value::Null;
21181 for _ in 0..=MAX_MRTR_RAW_JSON_DEPTH {
21182 resource_nested_value = serde_json::Value::Array(vec![resource_nested_value]);
21183 }
21184 let mut resource_oversized_depth = resource_retry.clone();
21185 resource_oversized_depth
21186 .params
21187 .as_mut()
21188 .and_then(serde_json::Value::as_object_mut)
21189 .and_then(|params| params.get_mut("inputResponses"))
21190 .and_then(serde_json::Value::as_object_mut)
21191 .expect("resource retry contains an inputResponses object")
21192 .insert("roots".to_owned(), resource_nested_value);
21193 let resource_depth_error = router
21194 .dispatch_stateless(&request_ctx, &resource_oversized_depth)
21195 .expect_err("only excessive response nesting is refused before retry decoding");
21196 assert_eq!(resource_depth_error.code, McpErrorCode::InvalidParams);
21197 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
21198
21199 let resource_retry_raw_params = raw_params_for(&resource_retry);
21200 let resource_response = router
21201 .dispatch_stateless_with_raw_params(
21202 &request_ctx,
21203 &resource_retry,
21204 Some(&resource_retry_raw_params),
21205 )
21206 .expect("a framework-minted resource state resumes through the final handler");
21207 assert_eq!(resource_response["resultType"], "input_required");
21208 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 2);
21209
21210 let prompt_initial = JsonRpcRequest::new(
21211 "prompts/get",
21212 Some(serde_json::json!({
21213 "_meta": metadata.clone(),
21214 "name": "input-required-prompt",
21215 })),
21216 147_i64,
21217 );
21218 let prompt_initial_result = router
21219 .dispatch_stateless(&request_ctx, &prompt_initial)
21220 .expect("normal final dispatch mints the prompt request state");
21221 let prompt_state = prompt_initial_result["requestState"]
21222 .as_str()
21223 .expect("framework result carries opaque prompt state")
21224 .to_owned();
21225 let prompt_retry = JsonRpcRequest::new(
21226 "prompts/get",
21227 Some(serde_json::json!({
21228 "_meta": metadata.clone(),
21229 "name": "input-required-prompt",
21230 "inputResponses": {"roots": router_roots_response_wire()},
21231 "requestState": prompt_state,
21232 })),
21233 147_i64,
21234 );
21235 let mut prompt_unknown_only = prompt_retry.clone();
21236 *prompt_unknown_only
21237 .params
21238 .as_mut()
21239 .and_then(serde_json::Value::as_object_mut)
21240 .and_then(|params| params.get_mut("inputResponses"))
21241 .expect("prompt retry contains inputResponses") = serde_json::json!({"inert": null});
21242 let prompt_unknown_error = router
21243 .dispatch_stateless(&request_ctx, &prompt_unknown_only)
21244 .expect_err("unknown-only inputResponses cannot consume a prompt continuation");
21245 assert_eq!(prompt_unknown_error.code, McpErrorCode::InvalidParams);
21246 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
21247
21248 let mut prompt_oversized_values = prompt_retry.clone();
21249 prompt_oversized_values
21250 .params
21251 .as_mut()
21252 .and_then(serde_json::Value::as_object_mut)
21253 .and_then(|params| params.get_mut("inputResponses"))
21254 .and_then(serde_json::Value::as_object_mut)
21255 .expect("prompt retry contains an inputResponses object")
21256 .insert(
21257 "roots".to_owned(),
21258 serde_json::Value::Array(vec![serde_json::Value::Null; MAX_MRTR_RAW_JSON_VALUES]),
21259 );
21260 let prompt_values_error = router
21261 .dispatch_stateless(&request_ctx, &prompt_oversized_values)
21262 .expect_err(
21263 "only an oversized prompt response value set is refused before retry decoding",
21264 );
21265 assert_eq!(prompt_values_error.code, McpErrorCode::InvalidParams);
21266 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
21267
21268 let prompt_retry_raw_params = raw_params_for(&prompt_retry);
21269 let prompt_response = router
21270 .dispatch_stateless_with_raw_params(
21271 &request_ctx,
21272 &prompt_retry,
21273 Some(&prompt_retry_raw_params),
21274 )
21275 .expect("a framework-minted prompt state resumes through the final handler");
21276 assert_eq!(prompt_response["resultType"], "input_required");
21277 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 2);
21278
21279 let mut tool_second_retry = tool_retry.clone();
21284 tool_second_retry
21285 .params
21286 .as_mut()
21287 .and_then(serde_json::Value::as_object_mut)
21288 .expect("tool retry parameters are an object")
21289 .insert(
21290 "requestState".to_owned(),
21291 tool_response["requestState"].clone(),
21292 );
21293 let tool_second_response = router
21294 .dispatch_stateless(&request_ctx, &tool_second_retry)
21295 .expect("a second public tools/call round reaches the resumed handler");
21296 assert_eq!(tool_second_response["resultType"], "input_required");
21297
21298 let mut resource_second_retry = resource_retry.clone();
21299 resource_second_retry
21300 .params
21301 .as_mut()
21302 .and_then(serde_json::Value::as_object_mut)
21303 .expect("resource retry parameters are an object")
21304 .insert(
21305 "requestState".to_owned(),
21306 resource_response["requestState"].clone(),
21307 );
21308 let resource_second_response = router
21309 .dispatch_stateless(&request_ctx, &resource_second_retry)
21310 .expect("a second public resources/read round reaches the resumed handler");
21311 assert_eq!(resource_second_response["resultType"], "input_required");
21312
21313 let mut prompt_second_retry = prompt_retry.clone();
21314 prompt_second_retry
21315 .params
21316 .as_mut()
21317 .and_then(serde_json::Value::as_object_mut)
21318 .expect("prompt retry parameters are an object")
21319 .insert(
21320 "requestState".to_owned(),
21321 prompt_response["requestState"].clone(),
21322 );
21323 let prompt_second_response = router
21324 .dispatch_stateless(&request_ctx, &prompt_second_retry)
21325 .expect("a second public prompts/get round reaches the resumed handler");
21326 assert_eq!(prompt_second_response["resultType"], "input_required");
21327 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 3);
21328 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 3);
21329 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 3);
21330
21331 let replay = router
21332 .dispatch_stateless(&request_ctx, &tool_retry)
21333 .expect_err("replaying only the already consumed tool state is refused");
21334 assert_eq!(replay.code, McpErrorCode::InvalidParams);
21335 assert_eq!(
21336 tool_final_calls.load(Ordering::SeqCst),
21337 3,
21338 "replay must fail before the tool handler is invoked again"
21339 );
21340 }
21341
21342 #[test]
21343 fn modern_connection_disablements_block_final_handlers_before_mrtr_state_is_minted() {
21344 let tool_final_calls = Arc::new(AtomicUsize::new(0));
21345 let resource_final_calls = Arc::new(AtomicUsize::new(0));
21346 let prompt_final_calls = Arc::new(AtomicUsize::new(0));
21347 let mut router = Router::new();
21348 router
21349 .add_tool(InputRequiredTool {
21350 legacy_calls: Arc::new(AtomicUsize::new(0)),
21351 final_calls: Arc::clone(&tool_final_calls),
21352 })
21353 .expect("tool registration succeeds");
21354 router.add_resource(InputRequiredResource {
21355 legacy_calls: Arc::new(AtomicUsize::new(0)),
21356 final_calls: Arc::clone(&resource_final_calls),
21357 });
21358 router.add_prompt(InputRequiredPrompt {
21359 legacy_calls: Arc::new(AtomicUsize::new(0)),
21360 final_calls: Arc::clone(&prompt_final_calls),
21361 });
21362
21363 let metadata = serde_json::json!({
21364 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21365 "io.modelcontextprotocol/clientCapabilities": {},
21366 });
21367 let tool_request = JsonRpcRequest::new(
21368 "tools/call",
21369 Some(serde_json::json!({
21370 "_meta": metadata.clone(),
21371 "name": "input-required-tool",
21372 "arguments": {},
21373 })),
21374 611_i64,
21375 );
21376 let resource_request = JsonRpcRequest::new(
21377 "resources/read",
21378 Some(serde_json::json!({
21379 "_meta": metadata.clone(),
21380 "uri": "file:///input-required-resource",
21381 })),
21382 612_i64,
21383 );
21384 let prompt_request = JsonRpcRequest::new(
21385 "prompts/get",
21386 Some(serde_json::json!({
21387 "_meta": metadata,
21388 "name": "input-required-prompt",
21389 })),
21390 613_i64,
21391 );
21392 let request_bytes = [
21393 serde_json::to_vec(&tool_request).expect("tool request serializes"),
21394 serde_json::to_vec(&resource_request).expect("resource request serializes"),
21395 serde_json::to_vec(&prompt_request).expect("prompt request serializes"),
21396 ];
21397
21398 let cx = Cx::for_testing();
21399 let allowed_connection = ModernConnection::new();
21400 let allowed_inbound = InboundRequestContext::with_modern_connection(
21401 cx.clone(),
21402 611,
21403 InboundRequestTransport::Stdio,
21404 &allowed_connection,
21405 );
21406 let allowed_ctx = allowed_inbound.request_context();
21407 let allowed_cancellation = allowed_inbound
21408 .mrtr_continuation_cancellation()
21409 .expect("modern connection supplies continuation ownership");
21410
21411 for request in [&tool_request, &resource_request, &prompt_request] {
21412 let result = router
21413 .dispatch_stateless_with_continuation_cancellation(
21414 &allowed_ctx,
21415 request,
21416 &allowed_cancellation,
21417 )
21418 .expect("enabled final component reaches its handler");
21419 assert_eq!(result["resultType"], "input_required");
21420 assert!(
21421 result["requestState"].is_string(),
21422 "an enabled MRTR-capable handler mints framework-owned state"
21423 );
21424 }
21425 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21426 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
21427 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
21428
21429 let denied_connection = ModernConnection::new();
21430 let denied_inbound = InboundRequestContext::with_modern_connection(
21431 cx,
21432 611,
21433 InboundRequestTransport::Stdio,
21434 &denied_connection,
21435 );
21436 let denied_ctx = denied_inbound.request_context();
21437 let denied_cancellation = denied_inbound
21438 .mrtr_continuation_cancellation()
21439 .expect("modern connection supplies continuation ownership");
21440 assert!(denied_ctx.disable_tool("input-required-tool"));
21441 assert!(denied_ctx.disable_resource("file:///input-required-resource"));
21442 assert!(denied_ctx.disable_prompt("input-required-prompt"));
21443
21444 assert_eq!(
21445 serde_json::to_vec(&tool_request).expect("tool request remains serializable"),
21446 request_bytes[0],
21447 "connection state is the sole tool-request admission difference"
21448 );
21449 let tool_error = router
21450 .dispatch_stateless_with_continuation_cancellation(
21451 &denied_ctx,
21452 &tool_request,
21453 &denied_cancellation,
21454 )
21455 .expect_err("a disabled tool cannot mint final MRTR state");
21456 assert_eq!(tool_error.code, McpErrorCode::MethodNotFound);
21457
21458 assert_eq!(
21459 serde_json::to_vec(&resource_request).expect("resource request remains serializable"),
21460 request_bytes[1],
21461 "connection state is the sole resource-request admission difference"
21462 );
21463 let resource_error = router
21464 .dispatch_stateless_with_continuation_cancellation(
21465 &denied_ctx,
21466 &resource_request,
21467 &denied_cancellation,
21468 )
21469 .expect_err("a disabled resource cannot mint final MRTR state");
21470 assert_eq!(resource_error.code, McpErrorCode::ResourceNotFound);
21471
21472 assert_eq!(
21473 serde_json::to_vec(&prompt_request).expect("prompt request remains serializable"),
21474 request_bytes[2],
21475 "connection state is the sole prompt-request admission difference"
21476 );
21477 let prompt_error = router
21478 .dispatch_stateless_with_continuation_cancellation(
21479 &denied_ctx,
21480 &prompt_request,
21481 &denied_cancellation,
21482 )
21483 .expect_err("a disabled prompt cannot mint final MRTR state");
21484 assert_eq!(prompt_error.code, McpErrorCode::PromptNotFound);
21485
21486 assert_eq!(
21487 tool_final_calls.load(Ordering::SeqCst),
21488 1,
21489 "refused tool dispatch must not invoke the MRTR-capable handler"
21490 );
21491 assert_eq!(
21492 resource_final_calls.load(Ordering::SeqCst),
21493 1,
21494 "refused resource dispatch must not invoke the MRTR-capable handler"
21495 );
21496 assert_eq!(
21497 prompt_final_calls.load(Ordering::SeqCst),
21498 1,
21499 "refused prompt dispatch must not invoke the MRTR-capable handler"
21500 );
21501 }
21502
21503 #[test]
21504 fn modern_final_catalogs_ignore_connection_disablements_while_exact_legacy_lists_remain_stateful()
21505 {
21506 let template_uri = "file:///input-required-template/{id}";
21507 let mut router = Router::new();
21508 router
21509 .add_tool(NamedTool::new("connection-scoped-tool"))
21510 .expect("tool registration succeeds");
21511 router.add_resource(NamedResource::new("file:///connection-scoped-resource"));
21512 router
21513 .add_resource_template_with_behavior(
21514 ResourceTemplate {
21515 uri_template: template_uri.to_owned(),
21516 name: "connection-scoped-template".to_owned(),
21517 description: None,
21518 mime_type: None,
21519 icon: None,
21520 version: None,
21521 tags: Vec::new(),
21522 },
21523 crate::DuplicateBehavior::Replace,
21524 )
21525 .expect("resource-template registration succeeds");
21526 router.add_prompt(NamedPrompt::new("connection-scoped-prompt"));
21527
21528 let cx = Cx::for_testing();
21529 let connection = ModernConnection::new();
21530 let inbound = InboundRequestContext::with_modern_connection(
21531 cx,
21532 614,
21533 InboundRequestTransport::Stdio,
21534 &connection,
21535 );
21536 let request_ctx = inbound.request_context();
21537 let metadata = serde_json::json!({
21538 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21539 "io.modelcontextprotocol/clientCapabilities": {},
21540 });
21541 let list_request = |method, id| {
21542 JsonRpcRequest::new(
21543 method,
21544 Some(serde_json::json!({"_meta": metadata.clone()})),
21545 id,
21546 )
21547 };
21548
21549 let tools = router
21550 .dispatch_stateless(&request_ctx, &list_request("tools/list", 614_i64))
21551 .expect("enabled tool is discovered");
21552 let resources = router
21553 .dispatch_stateless(&request_ctx, &list_request("resources/list", 615_i64))
21554 .expect("enabled resource is discovered");
21555 let templates = router
21556 .dispatch_stateless(
21557 &request_ctx,
21558 &list_request("resources/templates/list", 616_i64),
21559 )
21560 .expect("enabled resource template is discovered");
21561 let prompts = router
21562 .dispatch_stateless(&request_ctx, &list_request("prompts/list", 617_i64))
21563 .expect("enabled prompt is discovered");
21564 assert_eq!(tools["tools"][0]["name"], "connection-scoped-tool");
21565 assert_eq!(
21566 resources["resources"][0]["uri"],
21567 "file:///connection-scoped-resource"
21568 );
21569 assert_eq!(
21570 templates["resourceTemplates"][0]["uriTemplate"],
21571 template_uri
21572 );
21573 assert_eq!(prompts["prompts"][0]["name"], "connection-scoped-prompt");
21574
21575 assert!(request_ctx.disable_tool("connection-scoped-tool"));
21576 assert!(request_ctx.disable_resource("file:///connection-scoped-resource"));
21577 assert!(request_ctx.disable_resource(template_uri));
21578 assert!(request_ctx.disable_prompt("connection-scoped-prompt"));
21579
21580 let discovered_tools = router
21581 .dispatch_stateless(&request_ctx, &list_request("tools/list", 618_i64))
21582 .expect("disabled tool remains in the immutable final catalog");
21583 let discovered_resources = router
21584 .dispatch_stateless(&request_ctx, &list_request("resources/list", 619_i64))
21585 .expect("disabled resource remains in the immutable final catalog");
21586 let discovered_templates = router
21587 .dispatch_stateless(
21588 &request_ctx,
21589 &list_request("resources/templates/list", 620_i64),
21590 )
21591 .expect("disabled template remains in the immutable final catalog");
21592 let discovered_prompts = router
21593 .dispatch_stateless(&request_ctx, &list_request("prompts/list", 621_i64))
21594 .expect("disabled prompt remains in the immutable final catalog");
21595 assert_eq!(
21596 discovered_tools["tools"][0]["name"],
21597 "connection-scoped-tool"
21598 );
21599 assert_eq!(
21600 discovered_resources["resources"][0]["uri"],
21601 "file:///connection-scoped-resource"
21602 );
21603 assert_eq!(
21604 discovered_templates["resourceTemplates"][0]["uriTemplate"],
21605 template_uri
21606 );
21607 assert_eq!(
21608 discovered_prompts["prompts"][0]["name"],
21609 "connection-scoped-prompt"
21610 );
21611
21612 let legacy_state = SessionState::new();
21613 let legacy_request_ctx =
21614 request_context(request_ctx.cx(), 622, Budget::INFINITE, &legacy_state);
21615 assert!(legacy_request_ctx.disable_tool("connection-scoped-tool"));
21616 assert!(legacy_request_ctx.disable_resource("file:///connection-scoped-resource"));
21617 assert!(legacy_request_ctx.disable_resource(template_uri));
21618 assert!(legacy_request_ctx.disable_prompt("connection-scoped-prompt"));
21619
21620 let legacy_tools = router
21621 .handle_tools_list(
21622 &legacy_request_ctx,
21623 ListToolsParams {
21624 cursor: None,
21625 include_tags: None,
21626 exclude_tags: None,
21627 },
21628 Some(&legacy_state),
21629 )
21630 .expect("exact legacy tools/list remains session-stateful");
21631 let legacy_resources = router
21632 .handle_resources_list(
21633 &legacy_request_ctx,
21634 ListResourcesParams {
21635 cursor: None,
21636 include_tags: None,
21637 exclude_tags: None,
21638 },
21639 Some(&legacy_state),
21640 )
21641 .expect("exact legacy resources/list remains session-stateful");
21642 let legacy_templates = router
21643 .handle_resource_templates_list(
21644 &legacy_request_ctx,
21645 ListResourceTemplatesParams {
21646 cursor: None,
21647 include_tags: None,
21648 exclude_tags: None,
21649 },
21650 Some(&legacy_state),
21651 )
21652 .expect("exact legacy resources/templates/list remains session-stateful");
21653 let legacy_prompts = router
21654 .handle_prompts_list(
21655 &legacy_request_ctx,
21656 ListPromptsParams {
21657 cursor: None,
21658 include_tags: None,
21659 exclude_tags: None,
21660 },
21661 Some(&legacy_state),
21662 )
21663 .expect("exact legacy prompts/list remains session-stateful");
21664 assert!(legacy_tools.tools.is_empty());
21665 assert!(legacy_resources.resources.is_empty());
21666 assert!(legacy_templates.resource_templates.is_empty());
21667 assert!(legacy_prompts.prompts.is_empty());
21668
21669 let later_inbound = InboundRequestContext::with_modern_connection(
21670 request_ctx.cx().clone(),
21671 630,
21672 InboundRequestTransport::Stdio,
21673 &connection,
21674 );
21675 let later_ctx = later_inbound.request_context();
21676 let read_error = router
21677 .dispatch_stateless(
21678 &later_ctx,
21679 &JsonRpcRequest::new(
21680 "resources/read",
21681 Some(serde_json::json!({
21682 "_meta": metadata,
21683 "uri": "file:///connection-scoped-resource",
21684 })),
21685 631_i64,
21686 ),
21687 )
21688 .expect_err(
21689 "a later inbound on the same modern connection must refuse the disabled resource",
21690 );
21691 assert_eq!(read_error.code, McpErrorCode::ResourceNotFound);
21692 assert!(
21693 read_error.message.contains("disabled"),
21694 "the refused resource read must keep the session-disabled message: {read_error:?}"
21695 );
21696 let prompt_error = router
21697 .dispatch_stateless(
21698 &later_ctx,
21699 &JsonRpcRequest::new(
21700 "prompts/get",
21701 Some(serde_json::json!({
21702 "_meta": metadata,
21703 "name": "connection-scoped-prompt",
21704 })),
21705 632_i64,
21706 ),
21707 )
21708 .expect_err(
21709 "a later inbound on the same modern connection must refuse the disabled prompt",
21710 );
21711 assert_eq!(prompt_error.code, McpErrorCode::PromptNotFound);
21712 assert!(
21713 prompt_error.message.contains("disabled"),
21714 "the refused prompt get must keep the session-disabled message: {prompt_error:?}"
21715 );
21716 let tool_error = router
21717 .dispatch_stateless(
21718 &later_ctx,
21719 &JsonRpcRequest::new(
21720 "tools/call",
21721 Some(serde_json::json!({
21722 "_meta": metadata,
21723 "name": "connection-scoped-tool",
21724 "arguments": {},
21725 })),
21726 633_i64,
21727 ),
21728 )
21729 .expect_err(
21730 "a later inbound on the same modern connection must refuse the disabled tool",
21731 );
21732 assert_eq!(tool_error.code, McpErrorCode::MethodNotFound);
21733 assert!(
21734 tool_error.message.contains("disabled"),
21735 "the refused tool call must keep the session-disabled message: {tool_error:?}"
21736 );
21737 }
21738
21739 #[test]
21740 fn final_catalog_cursor_survives_a_changed_session_state() {
21741 let mut router = Router::new();
21742 router.set_list_page_size(Some(1));
21743 router
21744 .add_tool(NamedTool::new("first-connection-cursor-tool"))
21745 .expect("first tool registration succeeds");
21746 router
21747 .add_tool(NamedTool::new("second-connection-cursor-tool"))
21748 .expect("second tool registration succeeds");
21749
21750 let cx = Cx::for_testing();
21751 let state = SessionState::new();
21752 let request_ctx = request_context(&cx, 622, Budget::INFINITE, &state);
21753 let initial = final_tools_list_request(None, None, None, 622_i64);
21754 let initial_bytes = serde_json::to_vec(&initial).expect("initial request serializes");
21755 let first_page = router
21756 .dispatch_stateless(&request_ctx, &initial)
21757 .expect("the initial final catalog page is admitted");
21758 let cursor = first_page["nextCursor"]
21759 .as_str()
21760 .expect("the first page has a continuation")
21761 .to_owned();
21762
21763 assert!(request_ctx.disable_tool("first-connection-cursor-tool"));
21764 assert_eq!(
21765 serde_json::to_vec(&initial).expect("initial request remains serializable"),
21766 initial_bytes,
21767 "connection state cannot alter the request or its catalog continuation"
21768 );
21769 let continued = router
21770 .dispatch_stateless(
21771 &request_ctx,
21772 &final_tools_list_request(Some(&cursor), None, None, 623_i64),
21773 )
21774 .expect("a disabled-component change cannot invalidate an immutable final cursor");
21775 assert_eq!(
21776 continued["tools"][0]["name"],
21777 "second-connection-cursor-tool"
21778 );
21779 assert!(continued.get("nextCursor").is_none());
21780
21781 let refreshed = router
21782 .dispatch_stateless(
21783 &request_ctx,
21784 &final_tools_list_request(None, None, None, 624_i64),
21785 )
21786 .expect("a cursor-free request keeps the immutable final catalog");
21787 assert_eq!(
21788 refreshed["tools"][0]["name"],
21789 "first-connection-cursor-tool"
21790 );
21791 }
21792
21793 #[test]
21794 fn final_catalog_cursor_continues_across_modern_connection_partitions() {
21795 let mut router = Router::new();
21796 router.set_list_page_size(Some(1));
21797 router
21798 .add_tool(NamedTool::new("first-cross-connection-cursor-tool"))
21799 .expect("first tool registration succeeds");
21800 router
21801 .add_tool(NamedTool::new("second-cross-connection-cursor-tool"))
21802 .expect("second tool registration succeeds");
21803
21804 let cx = Cx::for_testing();
21805 let origin_connection = ModernConnection::new();
21806 let origin_inbound = InboundRequestContext::with_modern_connection(
21807 cx.clone(),
21808 625,
21809 InboundRequestTransport::Stdio,
21810 &origin_connection,
21811 );
21812 let origin_context = origin_inbound.request_context();
21813 let continuation_connection = ModernConnection::new();
21814 let continuation_inbound = InboundRequestContext::with_modern_connection(
21815 cx,
21816 626,
21817 InboundRequestTransport::Stdio,
21818 &continuation_connection,
21819 );
21820 let continuation_context = continuation_inbound.request_context();
21821 let origin_partition = origin_context
21822 .session_cache_partition()
21823 .expect("a modern origin context has a durable partition")
21824 .0;
21825 let continuation_partition = continuation_context
21826 .session_cache_partition()
21827 .expect("a modern continuation context has a durable partition")
21828 .0;
21829 assert_ne!(
21830 origin_partition, continuation_partition,
21831 "distinct modern connections have distinct durable partitions"
21832 );
21833
21834 let first_page = router
21835 .dispatch_stateless(
21836 &origin_context,
21837 &final_tools_list_request(None, None, None, 625_i64),
21838 )
21839 .expect("the origin connection receives the first final catalog page");
21840 let cursor = first_page["nextCursor"]
21841 .as_str()
21842 .expect("the first page has a continuation")
21843 .to_owned();
21844
21845 let continued = router
21846 .dispatch_stateless(
21847 &continuation_context,
21848 &final_tools_list_request(Some(&cursor), None, None, 626_i64),
21849 )
21850 .expect("a final catalog cursor is not bound to its origin connection partition");
21851 assert_eq!(
21852 continued["tools"][0]["name"],
21853 "second-cross-connection-cursor-tool"
21854 );
21855 assert!(continued.get("nextCursor").is_none());
21856 }
21857
21858 #[test]
21859 fn modern_connection_disconnect_is_the_only_changed_retry_dimension_and_cancels_state() {
21860 let final_calls = Arc::new(AtomicUsize::new(0));
21861 let mut router = Router::new();
21862 router
21863 .add_tool(InputRequiredTool {
21864 legacy_calls: Arc::new(AtomicUsize::new(0)),
21865 final_calls: Arc::clone(&final_calls),
21866 })
21867 .expect("tool registration succeeds");
21868
21869 let cx = Cx::for_testing();
21870 let connection = ModernConnection::new();
21871 let inbound = InboundRequestContext::with_modern_connection(
21872 cx.clone(),
21873 301,
21874 InboundRequestTransport::Stdio,
21875 &connection,
21876 );
21877 let request_ctx = inbound.request_context();
21878 let continuation_cancellation = inbound
21879 .mrtr_continuation_cancellation()
21880 .expect("a modern connection supplies continuation ownership");
21881 let metadata = serde_json::json!({
21882 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21883 "io.modelcontextprotocol/clientCapabilities": {},
21884 });
21885 let initial = JsonRpcRequest::new(
21886 "tools/call",
21887 Some(serde_json::json!({
21888 "_meta": metadata.clone(),
21889 "name": "input-required-tool",
21890 "arguments": {},
21891 })),
21892 301_i64,
21893 );
21894 let initial_result = router
21895 .dispatch_stateless_with_continuation_cancellation(
21896 &request_ctx,
21897 &initial,
21898 &continuation_cancellation,
21899 )
21900 .expect("the connected request mints a continuation");
21901 let retry = JsonRpcRequest::new(
21902 "tools/call",
21903 Some(serde_json::json!({
21904 "_meta": metadata,
21905 "name": "input-required-tool",
21906 "arguments": {},
21907 "inputResponses": {"roots": router_roots_response_wire()},
21908 "requestState": initial_result["requestState"].clone(),
21909 })),
21910 302_i64,
21911 );
21912 let retry_before_disconnect = serde_json::to_vec(&retry).expect("retry serializes");
21913
21914 connection.disconnect();
21917 let error = router
21918 .dispatch_stateless_with_continuation_cancellation(
21919 &request_ctx,
21920 &retry,
21921 &continuation_cancellation,
21922 )
21923 .expect_err("disconnect must cancel the retained continuation");
21924 assert_eq!(error.code, McpErrorCode::RequestCancelled);
21925 assert_eq!(
21926 serde_json::to_vec(&retry).expect("retry serializes"),
21927 retry_before_disconnect,
21928 "disconnect cancellation cannot mutate client retry state"
21929 );
21930 assert_eq!(
21931 final_calls.load(Ordering::SeqCst),
21932 1,
21933 "a disconnected continuation cannot reach the resumed handler"
21934 );
21935 }
21936
21937 #[cfg(feature = "tasks")]
21938 #[test]
21939 fn task_capable_input_required_retry_does_not_require_tasks_capability() {
21940 let final_calls = Arc::new(AtomicUsize::new(0));
21941 let mut router = Router::new();
21942 router
21943 .add_tool(TaskCapableInputRequiredTool {
21944 final_calls: Arc::clone(&final_calls),
21945 })
21946 .expect("task-capable input-required tool registration succeeds");
21947 let cx = Cx::for_testing();
21948 let connection = ModernConnection::new();
21949 let inbound = InboundRequestContext::with_modern_connection(
21950 cx,
21951 148,
21952 InboundRequestTransport::Memory,
21953 &connection,
21954 );
21955 let request_ctx = inbound.request_context();
21956 let metadata_without_tasks = serde_json::json!({
21957 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21958 "io.modelcontextprotocol/clientCapabilities": {},
21959 });
21960
21961 let initial = JsonRpcRequest::new(
21962 "tools/call",
21963 Some(serde_json::json!({
21964 "_meta": metadata_without_tasks.clone(),
21965 "name": "task-capable-input-required-tool",
21966 "arguments": {},
21967 })),
21968 148_i64,
21969 );
21970 let initial_result = router
21971 .dispatch_stateless(&request_ctx, &initial)
21972 .expect("a task-capable tool may return input_required without Tasks negotiation");
21973 let request_state = initial_result["requestState"]
21974 .as_str()
21975 .expect("framework result carries opaque task-capable state")
21976 .to_owned();
21977 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
21978
21979 let retry = JsonRpcRequest::new(
21980 "tools/call",
21981 Some(serde_json::json!({
21982 "_meta": metadata_without_tasks,
21983 "name": "task-capable-input-required-tool",
21984 "arguments": {},
21985 "inputResponses": {"roots": router_roots_response_wire()},
21986 "requestState": request_state,
21987 })),
21988 149_i64,
21989 );
21990 let resumed = router
21991 .dispatch_stateless(&request_ctx, &retry)
21992 .expect("a task-capable input-required retry remains an ordinary MRTR operation");
21993 assert_eq!(resumed["resultType"], "input_required");
21994 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
21995 }
21996
21997 #[test]
21998 fn final_input_required_does_not_fallback_without_negotiation_metadata() {
21999 let legacy_calls = Arc::new(AtomicUsize::new(0));
22000 let final_calls = Arc::new(AtomicUsize::new(0));
22001 let mut router = Router::new();
22002 router
22003 .add_tool(InputRequiredTool {
22004 legacy_calls: Arc::clone(&legacy_calls),
22005 final_calls: Arc::clone(&final_calls),
22006 })
22007 .expect("tool registration succeeds");
22008 let cx = Cx::for_testing();
22009 let connection = ModernConnection::new();
22010 let inbound = InboundRequestContext::with_modern_connection(
22011 cx,
22012 144,
22013 InboundRequestTransport::Memory,
22014 &connection,
22015 );
22016 let request_ctx = inbound.request_context();
22017 let baseline = JsonRpcRequest::new(
22018 "tools/call",
22019 Some(serde_json::json!({
22020 "_meta": {
22021 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22022 "io.modelcontextprotocol/clientCapabilities": {},
22023 },
22024 "name": "input-required-tool",
22025 "arguments": {},
22026 })),
22027 144_i64,
22028 );
22029 let mut planted = baseline.clone();
22030 planted
22031 .params
22032 .as_mut()
22033 .and_then(serde_json::Value::as_object_mut)
22034 .expect("tools/call parameters are an object")
22035 .remove("_meta");
22036
22037 assert_eq!(baseline.method, planted.method);
22038 assert_eq!(baseline.id, planted.id);
22039 assert_eq!(
22040 baseline
22041 .params
22042 .as_ref()
22043 .and_then(|params| params.get("name")),
22044 planted
22045 .params
22046 .as_ref()
22047 .and_then(|params| params.get("name")),
22048 "negotiation metadata is the sole planted dimension"
22049 );
22050 let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
22051 let planted_before = serde_json::to_vec(&planted).expect("request serializes");
22052
22053 let accepted = router
22054 .dispatch_stateless(&request_ctx, &baseline)
22055 .expect("negotiated final request is accepted");
22056 assert_eq!(accepted["resultType"], "input_required");
22057 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22058 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22059
22060 let error = router
22061 .dispatch_stateless(&request_ctx, &planted)
22062 .expect_err("one-field no-negotiation request is rejected instead of falling back");
22063 assert_eq!(error.code, McpErrorCode::InvalidParams);
22064 assert_eq!(
22065 serde_json::to_vec(&planted).expect("rejected request serializes"),
22066 planted_before,
22067 "rejection cannot mutate caller-owned no-negotiation parameters"
22068 );
22069 assert_eq!(
22070 serde_json::to_vec(&router.tools()).expect("catalog serializes"),
22071 catalog_before,
22072 "rejection cannot mutate the installed handler catalog"
22073 );
22074 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22075 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22076 let retried = router
22077 .dispatch_stateless(&request_ctx, &baseline)
22078 .expect("the negotiated baseline remains accepted after rejection");
22079 assert_eq!(retried["resultType"], "input_required");
22080 assert_ne!(
22081 retried["requestState"], accepted["requestState"],
22082 "every framework-issued MRTR continuation has fresh opaque state"
22083 );
22084 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
22085 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22086 }
22087
22088 #[test]
22089 fn final_prompts_get_dispatches_direct_request_owned_handler() {
22090 let final_calls = Arc::new(AtomicUsize::new(0));
22091 let mut router = Router::new();
22092 router.add_prompt(DirectFinalPrompt {
22093 final_calls: Arc::clone(&final_calls),
22094 });
22095 let cx = Cx::for_testing();
22096 let state = SessionState::new();
22097 let request_ctx = request_context(&cx, 96, Budget::INFINITE, &state);
22098 let request = direct_final_prompt_request(96);
22099 let typed_request = CoreRequest::decode(
22100 ProtocolEra::Modern2026,
22101 "prompts/get",
22102 request.params.as_ref(),
22103 )
22104 .expect("final prompts/get request decodes through the public core surface");
22105
22106 let response = router
22107 .dispatch_stateless(&request_ctx, &request)
22108 .expect("final prompts/get reaches the direct final handler");
22109
22110 assert_eq!(
22111 response.get("resultType"),
22112 Some(&serde_json::json!("complete"))
22113 );
22114 assert_eq!(
22115 response["description"],
22116 serde_json::json!("direct final prompt description")
22117 );
22118 assert_eq!(response["messages"][0]["content"]["type"], "audio");
22119 assert_eq!(
22120 response["messages"][0]["content"]["_meta"]["com.example/direct-prompt"]["source"],
22121 "final-handler"
22122 );
22123 assert_eq!(
22124 response["messages"][0]["content"]["com.example/direct-field"],
22125 true
22126 );
22127 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22128
22129 let wire = serde_json::to_string(&response).expect("final prompt response serializes");
22130 let CoreResult::Final(FinalCoreResult::PromptsGet { result, .. }) = typed_request
22131 .decode_result(&wire)
22132 .expect("final prompts/get result decodes through the public core surface")
22133 else {
22134 panic!("prompts/get selects the exact final result");
22135 };
22136 assert_eq!(
22137 result.payload.description.as_deref(),
22138 Some("direct final prompt description")
22139 );
22140 assert!(matches!(
22141 result.payload.messages.as_slice(),
22142 [FinalPromptMessage {
22143 content: ContentBlock::Audio { data, mime_type, .. },
22144 ..
22145 }] if data == "aGVsbG8=" && mime_type == "audio/mpeg"
22146 ));
22147 }
22148
22149 #[test]
22150 fn public_final_resource_uri_policy_admits_client_direct_https_and_rejects_only_policy_change_without_mutation()
22151 {
22152 let client_direct = HttpsCatalogResource {
22153 client_direct_https: true,
22154 };
22155 let server_mediated = HttpsCatalogResource {
22156 client_direct_https: false,
22157 };
22158 assert_eq!(
22159 client_direct.definition(),
22160 server_mediated.definition(),
22161 "the URI-use policy is the sole registration difference"
22162 );
22163
22164 let cx = Cx::for_testing();
22165 let state = SessionState::new();
22166 let request_ctx = request_context(&cx, 971, Budget::INFINITE, &state);
22167 let final_metadata = serde_json::json!({
22168 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22169 "io.modelcontextprotocol/clientCapabilities": {},
22170 });
22171
22172 let mut accepted_router = Router::new();
22173 accepted_router
22174 .add_resource_with_behavior(client_direct, crate::DuplicateBehavior::Replace)
22175 .expect("an explicitly client-direct HTTPS catalog resource is admitted");
22176 let accepted = accepted_router
22177 .dispatch_stateless(
22178 &request_ctx,
22179 &JsonRpcRequest::new(
22180 "resources/list",
22181 Some(serde_json::json!({"_meta": final_metadata.clone()})),
22182 971_i64,
22183 ),
22184 )
22185 .expect("public final resource listing succeeds");
22186 assert_eq!(
22187 accepted["resources"][0]["uri"],
22188 "https://client.example.test/catalog.txt"
22189 );
22190 let direct_read = accepted_router
22191 .dispatch_stateless(
22192 &request_ctx,
22193 &JsonRpcRequest::new(
22194 "resources/read",
22195 Some(serde_json::json!({
22196 "_meta": final_metadata.clone(),
22197 "uri": "https://client.example.test/catalog.txt",
22198 })),
22199 972_i64,
22200 ),
22201 )
22202 .expect_err("client-direct HTTPS catalog entries are not MCP-read identities");
22203 assert_eq!(direct_read.code, McpErrorCode::InvalidParams);
22204
22205 let mut rejected_router = Router::new();
22206 rejected_router.add_resource(NamedResource::new("mcp://uri-policy/unchanged"));
22207 let catalog_before =
22208 serde_json::to_vec(&rejected_router.resources()).expect("existing catalog serializes");
22209 let count_before = rejected_router.resources_count();
22210 let error = rejected_router
22211 .add_resource_with_behavior(server_mediated, crate::DuplicateBehavior::Replace)
22212 .expect_err("changing only to server-mediated policy rejects HTTPS registration");
22213 assert_eq!(error.code, McpErrorCode::InvalidParams);
22214 assert_eq!(
22215 rejected_router.resources_count(),
22216 count_before,
22217 "rejected final admission cannot add or replace a resource handler"
22218 );
22219 assert_eq!(
22220 serde_json::to_vec(&rejected_router.resources()).expect("catalog remains serializable"),
22221 catalog_before,
22222 "rejected URI-use admission leaves the prior catalog unchanged"
22223 );
22224 let unchanged = rejected_router
22225 .dispatch_stateless(
22226 &request_ctx,
22227 &JsonRpcRequest::new(
22228 "resources/list",
22229 Some(serde_json::json!({"_meta": final_metadata})),
22230 972_i64,
22231 ),
22232 )
22233 .expect("rejection leaves public final dispatch usable");
22234 assert_eq!(unchanged["resources"].as_array().map(Vec::len), Some(1));
22235 assert_eq!(
22236 unchanged["resources"][0]["uri"],
22237 "mcp://uri-policy/unchanged"
22238 );
22239 }
22240
22241 #[test]
22242 fn resource_uri_use_policy_leaves_exact_2024_resource_registration_unchanged() {
22243 let mut router = Router::new();
22244 router
22245 .add_legacy_resource_with_behavior(
22246 HttpsCatalogResource {
22247 client_direct_https: false,
22248 },
22249 crate::DuplicateBehavior::Replace,
22250 )
22251 .expect("exact-2024 registration does not consult final URI-use admission");
22252
22253 let cx = Cx::for_testing();
22254 let request_ctx = McpContext::new(cx, 976);
22255 let listed = router
22256 .handle_resources_list(&request_ctx, ListResourcesParams::default(), None)
22257 .expect("exact-2024 listing remains available");
22258 assert_eq!(listed.resources.len(), 1);
22259 assert_eq!(
22260 listed.resources[0].uri,
22261 "https://client.example.test/catalog.txt"
22262 );
22263 }
22264
22265 #[test]
22266 fn public_final_uri_policy_rechecks_prompt_and_mrtr_resource_emissions() {
22267 let initial_calls = Arc::new(AtomicUsize::new(0));
22268 let resumed_calls = Arc::new(AtomicUsize::new(0));
22269 let mut router = Router::new();
22270 router.add_prompt(ClientDirectHttpsPrompt);
22271 router.add_resource(MrtrHttpsEmbeddedResource {
22272 initial_calls: Arc::clone(&initial_calls),
22273 resumed_calls: Arc::clone(&resumed_calls),
22274 });
22275
22276 let cx = Cx::for_testing();
22277 let state = SessionState::new();
22278 let request_ctx = request_context(&cx, 973, Budget::INFINITE, &state);
22279 let metadata = serde_json::json!({
22280 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22281 "io.modelcontextprotocol/clientCapabilities": {},
22282 });
22283
22284 let prompt = router
22285 .dispatch_stateless(
22286 &request_ctx,
22287 &JsonRpcRequest::new(
22288 "prompts/get",
22289 Some(serde_json::json!({
22290 "_meta": metadata.clone(),
22291 "name": "client-direct-https-prompt",
22292 })),
22293 973_i64,
22294 ),
22295 )
22296 .expect("a client-direct HTTPS resource link is emitted from the public prompt path");
22297 assert_eq!(
22298 prompt["messages"][0]["content"]["uri"],
22299 "https://client.example.test/prompt-link"
22300 );
22301
22302 let initial = router
22303 .dispatch_stateless(
22304 &request_ctx,
22305 &JsonRpcRequest::new(
22306 "resources/read",
22307 Some(serde_json::json!({
22308 "_meta": metadata.clone(),
22309 "uri": "mcp://uri-policy/mrtr",
22310 })),
22311 974_i64,
22312 ),
22313 )
22314 .expect("initial public resource request mints MRTR state");
22315 assert_eq!(initial["resultType"], "input_required");
22316 let request_state = initial["requestState"]
22317 .as_str()
22318 .expect("framework minted MRTR state")
22319 .to_owned();
22320 let catalog_before = serde_json::to_vec(&router.resources())
22321 .expect("catalog serializes before the resumed rejection");
22322 let retry = JsonRpcRequest::new(
22323 "resources/read",
22324 Some(serde_json::json!({
22325 "_meta": metadata,
22326 "uri": "mcp://uri-policy/mrtr",
22327 "inputResponses": {"roots": router_roots_response_wire()},
22328 "requestState": request_state,
22329 })),
22330 975_i64,
22331 );
22332 let error = router.dispatch_stateless(&request_ctx, &retry).expect_err(
22333 "an MRTR-resumed HTTPS embedded resource remains server-mediated and is refused",
22334 );
22335 assert_eq!(error.code, McpErrorCode::InternalError);
22336 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
22337 assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
22338 assert_eq!(
22339 serde_json::to_vec(&router.resources()).expect("catalog remains serializable"),
22340 catalog_before,
22341 "rejected dynamic MRTR output cannot mutate registered resource state"
22342 );
22343 }
22344
22345 #[test]
22346 fn final_prompt_arguments_are_validated_before_handler_and_legacy_is_unchanged() {
22347 let final_calls = Arc::new(AtomicUsize::new(0));
22348 let legacy_calls = Arc::new(AtomicUsize::new(0));
22349 let mut router = Router::new();
22350 router.add_prompt(PromptArgumentBoundary {
22351 final_calls: Arc::clone(&final_calls),
22352 legacy_calls: Arc::clone(&legacy_calls),
22353 });
22354 let cx = Cx::for_testing();
22355 let state = SessionState::new();
22356 let request_ctx = request_context(&cx, 155, Budget::INFINITE, &state);
22357 let baseline = final_prompt_get_request(
22358 "prompt-argument-boundary",
22359 serde_json::json!({"topic": "release"}),
22360 155_i64,
22361 );
22362 let mut missing_required = baseline.clone();
22363 let removed_topic = missing_required
22364 .params
22365 .as_mut()
22366 .and_then(serde_json::Value::as_object_mut)
22367 .and_then(|params| params.get_mut("arguments"))
22368 .and_then(serde_json::Value::as_object_mut)
22369 .expect("prompt arguments are an object")
22370 .remove("topic");
22371 assert_eq!(removed_topic, Some(serde_json::json!("release")));
22372
22373 assert_eq!(baseline.method, missing_required.method);
22374 assert_eq!(baseline.id, missing_required.id);
22375 assert_eq!(
22376 baseline
22377 .params
22378 .as_ref()
22379 .and_then(|params| params.get("_meta")),
22380 missing_required
22381 .params
22382 .as_ref()
22383 .and_then(|params| params.get("_meta")),
22384 "the required argument is the sole planted dimension"
22385 );
22386 assert_eq!(
22387 baseline
22388 .params
22389 .as_ref()
22390 .and_then(|params| params.get("name")),
22391 missing_required
22392 .params
22393 .as_ref()
22394 .and_then(|params| params.get("name")),
22395 "the required argument is the sole planted dimension"
22396 );
22397
22398 let accepted = router
22399 .dispatch_stateless(&request_ctx, &baseline)
22400 .expect("the complete required-argument request is accepted");
22401 assert_eq!(accepted["resultType"], "complete");
22402 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22403 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22404
22405 let missing_error = router
22406 .dispatch_stateless(&request_ctx, &missing_required)
22407 .expect_err("removing only a required prompt argument is rejected");
22408 assert_eq!(missing_error.code, McpErrorCode::InvalidParams);
22409 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22410 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22411
22412 let unknown_error = router
22413 .dispatch_stateless(
22414 &request_ctx,
22415 &final_prompt_get_request("unknown-prompt", serde_json::json!({}), 156_i64),
22416 )
22417 .expect_err("an unknown final prompt is an invalid-params protocol error");
22418 assert_eq!(unknown_error.code, McpErrorCode::InvalidParams);
22419 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22420
22421 let legacy = router
22422 .handle_prompts_get(
22423 &request_ctx,
22424 GetPromptParams {
22425 name: "prompt-argument-boundary".to_owned(),
22426 arguments: None,
22427 meta: None,
22428 },
22429 state,
22430 None,
22431 None,
22432 )
22433 .expect("the exact legacy prompt path retains its existing argument behavior");
22434 let legacy_wire = serde_json::to_value(&legacy).expect("legacy prompt result serializes");
22435 assert_eq!(
22436 legacy_wire["messages"][0]["content"]["text"],
22437 "legacy prompt-argument-boundary result"
22438 );
22439 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
22440 }
22441
22442 #[test]
22443 fn final_prompt_argument_snapshot_is_immutable_after_registration() {
22444 let expose_admitted_argument = Arc::new(AtomicBool::new(true));
22445 let final_calls = Arc::new(AtomicUsize::new(0));
22446 let mut router = Router::new();
22447 router.add_prompt(MutablePromptDefinition {
22448 expose_admitted_argument: Arc::clone(&expose_admitted_argument),
22449 final_calls: Arc::clone(&final_calls),
22450 });
22451 expose_admitted_argument.store(false, Ordering::SeqCst);
22452
22453 let cx = Cx::for_testing();
22454 let state = SessionState::new();
22455 let request_ctx = request_context(&cx, 156, Budget::INFINITE, &state);
22456 let accepted = final_prompt_get_request(
22457 "mutable-prompt-definition",
22458 serde_json::json!({"topic": "release"}),
22459 156_i64,
22460 );
22461 let mut missing_required = accepted.clone();
22462 let removed = missing_required
22463 .params
22464 .as_mut()
22465 .and_then(serde_json::Value::as_object_mut)
22466 .and_then(|params| params.get_mut("arguments"))
22467 .and_then(serde_json::Value::as_object_mut)
22468 .expect("prompt arguments are an object")
22469 .remove("topic");
22470 assert_eq!(removed, Some(serde_json::json!("release")));
22471 assert_eq!(accepted.method, missing_required.method);
22472 assert_eq!(accepted.id, missing_required.id);
22473 assert_eq!(
22474 accepted
22475 .params
22476 .as_ref()
22477 .and_then(|params| params.get("_meta")),
22478 missing_required
22479 .params
22480 .as_ref()
22481 .and_then(|params| params.get("_meta")),
22482 "the required argument is the sole planted dimension"
22483 );
22484 assert_eq!(
22485 accepted
22486 .params
22487 .as_ref()
22488 .and_then(|params| params.get("name")),
22489 missing_required
22490 .params
22491 .as_ref()
22492 .and_then(|params| params.get("name")),
22493 "the required argument is the sole planted dimension"
22494 );
22495
22496 let catalog = router
22497 .dispatch_stateless(
22498 &request_ctx,
22499 &JsonRpcRequest::new(
22500 "prompts/list",
22501 Some(serde_json::json!({
22502 "_meta": {
22503 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22504 "io.modelcontextprotocol/clientCapabilities": {},
22505 },
22506 })),
22507 157_i64,
22508 ),
22509 )
22510 .expect("the final prompt catalog retains its admitted argument metadata");
22511 assert_eq!(catalog["prompts"][0]["arguments"][0]["name"], "topic");
22512 assert_eq!(catalog["prompts"][0]["arguments"][0]["required"], true);
22513
22514 let response = router
22515 .dispatch_stateless(&request_ctx, &accepted)
22516 .expect("the admitted final argument remains accepted after the legacy hook changes");
22517 assert_eq!(response["resultType"], "complete");
22518 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22519
22520 let error = router
22521 .dispatch_stateless(&request_ctx, &missing_required)
22522 .expect_err("removing only the admitted required argument is rejected");
22523 assert_eq!(error.code, McpErrorCode::InvalidParams);
22524 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22525 }
22526
22527 #[test]
22528 fn final_prompts_get_rejects_one_field_incompatible_result_type() {
22529 let final_calls = Arc::new(AtomicUsize::new(0));
22530 let mut router = Router::new();
22531 router.add_prompt(DirectFinalPrompt {
22532 final_calls: Arc::clone(&final_calls),
22533 });
22534 let cx = Cx::for_testing();
22535 let state = SessionState::new();
22536 let request_ctx = request_context(&cx, 97, Budget::INFINITE, &state);
22537 let request = direct_final_prompt_request(97);
22538 let typed_request = CoreRequest::decode(
22539 ProtocolEra::Modern2026,
22540 "prompts/get",
22541 request.params.as_ref(),
22542 )
22543 .expect("final prompts/get request decodes through the public core surface");
22544 let accepted = router
22545 .dispatch_stateless(&request_ctx, &request)
22546 .expect("the direct final prompt response is accepted");
22547 let accepted_wire = serde_json::to_string(&accepted).expect("accepted result serializes");
22548 let mut incompatible = accepted.clone();
22549 incompatible["resultType"] = serde_json::json!("input_required");
22550
22551 let mut accepted_without_type = accepted.clone();
22552 let mut incompatible_without_type = incompatible.clone();
22553 let accepted_type = accepted_without_type
22554 .as_object_mut()
22555 .and_then(|object| object.remove("resultType"));
22556 let incompatible_type = incompatible_without_type
22557 .as_object_mut()
22558 .and_then(|object| object.remove("resultType"));
22559 assert_eq!(accepted_type, Some(serde_json::json!("complete")));
22560 assert_eq!(incompatible_type, Some(serde_json::json!("input_required")));
22561 assert_eq!(
22562 incompatible_without_type, accepted_without_type,
22563 "resultType is the sole incompatible result dimension"
22564 );
22565
22566 let incompatible_wire =
22567 serde_json::to_string(&incompatible).expect("incompatible result serializes");
22568 assert!(matches!(
22569 typed_request.decode_result(&incompatible_wire),
22570 Err(fastmcp_protocol::CoreDispatchError::ResultCodec(_))
22571 ));
22572 assert_eq!(
22573 serde_json::to_string(&accepted).expect("accepted result remains serializable"),
22574 accepted_wire,
22575 "rejecting the one-field incompatible result cannot mutate the accepted response"
22576 );
22577 assert_eq!(
22578 final_calls.load(Ordering::SeqCst),
22579 1,
22580 "local result admission cannot invoke or mutate the direct prompt handler"
22581 );
22582 let reaccepted = typed_request
22583 .decode_result(&accepted_wire)
22584 .expect("the original direct final result remains accepted");
22585 assert_eq!(
22586 serde_json::from_str::<serde_json::Value>(
22587 &reaccepted.encode().expect("reaccepted result encodes"),
22588 )
22589 .expect("reaccepted result is JSON"),
22590 accepted,
22591 "the incompatible result cannot alter the accepted final prompt contract"
22592 );
22593 }
22594
22595 #[test]
22596 fn final_resources_read_dispatches_direct_handler_without_legacy_projection() {
22597 let legacy_calls = Arc::new(AtomicUsize::new(0));
22598 let final_calls = Arc::new(AtomicUsize::new(0));
22599 let mut router = Router::new();
22600 router.add_resource(DirectFinalResource {
22601 legacy_calls: Arc::clone(&legacy_calls),
22602 final_calls: Arc::clone(&final_calls),
22603 });
22604 let cx = Cx::for_testing();
22605 let state = SessionState::new();
22606 let request_ctx = request_context(&cx, 98, Budget::INFINITE, &state);
22607
22608 let legacy = router
22609 .handle_resources_read(
22610 &request_ctx,
22611 &ReadResourceParams {
22612 uri: "file:///direct-final-resource".to_owned(),
22613 meta: None,
22614 },
22615 state.clone(),
22616 None,
22617 None,
22618 )
22619 .expect("legacy resource requests retain their exact handler path");
22620 let [LegacyResourceContent::Text { text, .. }] = legacy.contents.as_slice() else {
22621 panic!("legacy resource result retains its exact text variant");
22622 };
22623 assert_eq!(text, "legacy resource result");
22624 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
22625 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
22626
22627 let request = direct_final_resource_request(98);
22628 let typed_request = CoreRequest::decode(
22629 ProtocolEra::Modern2026,
22630 "resources/read",
22631 request.params.as_ref(),
22632 )
22633 .expect("final resources/read request decodes through the public core surface");
22634 let response = router
22635 .dispatch_stateless(&request_ctx, &request)
22636 .expect("final resources/read reaches the direct final handler");
22637
22638 assert_eq!(response["resultType"], "complete");
22639 assert_eq!(response["ttlMs"], 321);
22640 assert_eq!(response["cacheScope"], "public");
22641 assert_eq!(
22642 response["contents"][0]["text"],
22643 "direct final resource result"
22644 );
22645 assert_eq!(response["contents"][0]["mimeType"], "text/markdown");
22646 assert_eq!(
22647 response["contents"][0]["_meta"]["com.example/direct-resource"]["source"],
22648 "final-handler"
22649 );
22650 assert_eq!(response["contents"][0]["com.example/direct-field"], true);
22651 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
22652 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22653
22654 let wire = serde_json::to_string(&response).expect("final resource response serializes");
22655 let CoreResult::Final(FinalCoreResult::ResourcesRead { result, .. }) = typed_request
22656 .decode_result(&wire)
22657 .expect("final resource result decodes through the public core surface")
22658 else {
22659 panic!("resources/read selects the exact final result");
22660 };
22661 assert_eq!(result.payload.ttl_ms.as_str(), "321");
22662 assert_eq!(result.payload.cache_scope, CacheScope::Public);
22663 assert!(matches!(
22664 result.payload.contents.as_slice(),
22665 [EmbeddedResourceContents::Text { text, mime_type, .. }]
22666 if text == "direct final resource result"
22667 && mime_type.as_deref() == Some("text/markdown")
22668 ));
22669 }
22670
22671 #[test]
22672 fn resource_read_cache_hint_provenance_controls_router_policy_not_wire_value() {
22673 let cx = Cx::for_testing();
22674 let state = SessionState::new();
22675 let request_ctx = request_context(&cx, 981, Budget::INFINITE, &state);
22676 let request = JsonRpcRequest::new(
22677 "resources/read",
22678 Some(serde_json::json!({
22679 "_meta": {
22680 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22681 "io.modelcontextprotocol/clientCapabilities": {},
22682 },
22683 "uri": "file:///sentinel-hint-resource",
22684 })),
22685 981_i64,
22686 );
22687
22688 for (provenance, expected_ttl, expected_scope) in [
22689 (
22690 FinalResourceReadCacheHintProvenance::Explicit,
22691 DEFAULT_FINAL_RESOURCE_TTL_MS,
22692 "private",
22693 ),
22694 (
22695 FinalResourceReadCacheHintProvenance::RouterPolicy,
22696 23,
22697 "public",
22698 ),
22699 ] {
22700 let mut router = Router::new();
22701 router.set_final_cache_hint_policy(
22702 CacheTtl::milliseconds(17),
22703 CacheTtl::milliseconds(23),
22704 CacheScope::Public,
22705 );
22706 router.add_resource(SentinelHintResource { provenance });
22707
22708 let response = router
22709 .dispatch_stateless(&request_ctx, &request)
22710 .expect("final resource result dispatches");
22711 assert_eq!(response["ttlMs"], expected_ttl);
22712 assert_eq!(response["cacheScope"], expected_scope);
22713 }
22714 }
22715
22716 #[test]
22717 fn final_unknown_resource_is_invalid_params_with_exact_uri_and_legacy_is_unchanged() {
22718 let legacy_calls = Arc::new(AtomicUsize::new(0));
22719 let final_calls = Arc::new(AtomicUsize::new(0));
22720 let mut router = Router::new();
22721 router.add_resource(DirectFinalResource {
22722 legacy_calls: Arc::clone(&legacy_calls),
22723 final_calls: Arc::clone(&final_calls),
22724 });
22725 let cx = Cx::for_testing();
22726 let state = SessionState::new();
22727 let request_ctx = request_context(&cx, 157, Budget::INFINITE, &state);
22728 let baseline = direct_final_resource_request(157);
22729 let mut unknown = baseline.clone();
22730 let replaced_uri = unknown
22731 .params
22732 .as_mut()
22733 .and_then(serde_json::Value::as_object_mut)
22734 .expect("resource parameters are an object")
22735 .insert(
22736 "uri".to_owned(),
22737 serde_json::json!("file:///unknown-final-resource"),
22738 );
22739 assert_eq!(
22740 replaced_uri,
22741 Some(serde_json::json!("file:///direct-final-resource"))
22742 );
22743
22744 assert_eq!(baseline.method, unknown.method);
22745 assert_eq!(baseline.id, unknown.id);
22746 assert_eq!(
22747 baseline
22748 .params
22749 .as_ref()
22750 .and_then(|params| params.get("_meta")),
22751 unknown
22752 .params
22753 .as_ref()
22754 .and_then(|params| params.get("_meta")),
22755 "the resource URI is the sole planted dimension"
22756 );
22757
22758 let accepted = router
22759 .dispatch_stateless(&request_ctx, &baseline)
22760 .expect("the registered final resource is accepted");
22761 assert_eq!(accepted["resultType"], "complete");
22762 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22763 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22764
22765 let error = router
22766 .dispatch_stateless(&request_ctx, &unknown)
22767 .expect_err("changing only the URI to an unknown resource is rejected");
22768 assert_eq!(error.code, McpErrorCode::InvalidParams);
22769 assert_eq!(error.message, "Resource not found");
22770 assert_eq!(
22771 error.data,
22772 Some(serde_json::json!({"uri": "file:///unknown-final-resource"}))
22773 );
22774 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22775 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22776
22777 let legacy_error = router
22778 .handle_resources_read(
22779 &request_ctx,
22780 &ReadResourceParams {
22781 uri: "file:///unknown-final-resource".to_owned(),
22782 meta: None,
22783 },
22784 state,
22785 None,
22786 None,
22787 )
22788 .expect_err("the exact legacy missing-resource error remains unchanged");
22789 assert_eq!(legacy_error.code, McpErrorCode::ResourceNotFound);
22790 assert_eq!(
22791 legacy_error.message,
22792 "Resource not found: file:///unknown-final-resource"
22793 );
22794 }
22795
22796 #[test]
22797 fn final_resources_read_rejects_one_field_incompatible_result_type() {
22798 let legacy_calls = Arc::new(AtomicUsize::new(0));
22799 let final_calls = Arc::new(AtomicUsize::new(0));
22800 let mut router = Router::new();
22801 router.add_resource(DirectFinalResource {
22802 legacy_calls,
22803 final_calls: Arc::clone(&final_calls),
22804 });
22805 let cx = Cx::for_testing();
22806 let state = SessionState::new();
22807 let request_ctx = request_context(&cx, 99, Budget::INFINITE, &state);
22808 let request = direct_final_resource_request(99);
22809 let typed_request = CoreRequest::decode(
22810 ProtocolEra::Modern2026,
22811 "resources/read",
22812 request.params.as_ref(),
22813 )
22814 .expect("final resources/read request decodes through the public core surface");
22815 let accepted = router
22816 .dispatch_stateless(&request_ctx, &request)
22817 .expect("the direct final resource response is accepted");
22818 let accepted_wire = serde_json::to_string(&accepted).expect("accepted result serializes");
22819 let mut incompatible = accepted.clone();
22820 incompatible["resultType"] = serde_json::json!("input_required");
22821
22822 let mut accepted_without_type = accepted.clone();
22823 let mut incompatible_without_type = incompatible.clone();
22824 let accepted_type = accepted_without_type
22825 .as_object_mut()
22826 .and_then(|object| object.remove("resultType"));
22827 let incompatible_type = incompatible_without_type
22828 .as_object_mut()
22829 .and_then(|object| object.remove("resultType"));
22830 assert_eq!(accepted_type, Some(serde_json::json!("complete")));
22831 assert_eq!(incompatible_type, Some(serde_json::json!("input_required")));
22832 assert_eq!(
22833 incompatible_without_type, accepted_without_type,
22834 "resultType is the sole incompatible result dimension"
22835 );
22836
22837 let incompatible_wire =
22838 serde_json::to_string(&incompatible).expect("incompatible result serializes");
22839 assert!(matches!(
22840 typed_request.decode_result(&incompatible_wire),
22841 Err(fastmcp_protocol::CoreDispatchError::ResultCodec(_))
22842 ));
22843 assert_eq!(
22844 serde_json::to_string(&accepted).expect("accepted result remains serializable"),
22845 accepted_wire,
22846 "rejecting the one-field incompatible result cannot mutate the accepted response"
22847 );
22848 assert_eq!(
22849 final_calls.load(Ordering::SeqCst),
22850 1,
22851 "local result admission cannot invoke or mutate the direct resource handler"
22852 );
22853 let reaccepted = typed_request
22854 .decode_result(&accepted_wire)
22855 .expect("the original direct final result remains accepted");
22856 assert_eq!(
22857 serde_json::from_str::<serde_json::Value>(
22858 &reaccepted.encode().expect("reaccepted result encodes"),
22859 )
22860 .expect("reaccepted result is JSON"),
22861 accepted,
22862 "the incompatible result cannot alter the accepted final resource contract"
22863 );
22864 }
22865
22866 #[test]
22867 fn final_catalog_preserves_resource_template_and_prompt_fields() {
22868 let resource_metadata = OpenMetadata::try_from_entries([(
22869 "com.example/resource".to_owned(),
22870 serde_json::json!({"source": "resource-handler"}),
22871 )])
22872 .expect("resource metadata is valid");
22873 let template_metadata = OpenMetadata::try_from_entries([(
22874 "com.example/template".to_owned(),
22875 serde_json::json!({"source": "template-handler"}),
22876 )])
22877 .expect("template metadata is valid");
22878 let prompt_metadata = OpenMetadata::try_from_entries([(
22879 "com.example/prompt".to_owned(),
22880 serde_json::json!({"source": "prompt-handler"}),
22881 )])
22882 .expect("prompt metadata is valid");
22883 let resource_annotations = Annotations {
22884 audience: None,
22885 priority: Some(0.25),
22886 last_modified: Some("2026-08-08T00:00:00Z".to_owned()),
22887 additional: BTreeMap::new(),
22888 };
22889 let template_annotations = Annotations {
22890 audience: None,
22891 priority: Some(0.75),
22892 last_modified: Some("2026-08-08T00:00:01Z".to_owned()),
22893 additional: BTreeMap::new(),
22894 };
22895 let resource_icon = RawIcon::try_with_details(
22896 "https://example.test/resource.png",
22897 Some("image/png".to_owned()),
22898 Some(vec!["32x32".to_owned()]),
22899 None,
22900 )
22901 .expect("resource icon is valid");
22902 let template_icon = RawIcon::try_with_details(
22903 "https://example.test/template.png",
22904 Some("image/png".to_owned()),
22905 Some(vec!["48x48".to_owned()]),
22906 None,
22907 )
22908 .expect("template icon is valid");
22909 let prompt_icon = RawIcon::try_with_details(
22910 "https://example.test/prompt.png",
22911 Some("image/png".to_owned()),
22912 Some(vec!["64x64".to_owned()]),
22913 None,
22914 )
22915 .expect("prompt icon is valid");
22916
22917 let mut router = Router::new();
22918 router.add_resource(FinalCatalogResource {
22919 metadata: resource_metadata,
22920 icons: vec![resource_icon],
22921 annotations: resource_annotations,
22922 });
22923 router.add_resource(FinalCatalogResourceTemplate {
22924 metadata: template_metadata,
22925 icons: vec![template_icon],
22926 annotations: template_annotations,
22927 });
22928 router.add_prompt(FinalCatalogPrompt {
22929 metadata: prompt_metadata,
22930 icons: vec![prompt_icon],
22931 });
22932
22933 let cx = Cx::for_testing();
22934 let state = SessionState::new();
22935 let request_ctx = request_context(&cx, 96, Budget::INFINITE, &state);
22936 let final_metadata = serde_json::json!({
22937 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22938 "io.modelcontextprotocol/clientCapabilities": {},
22939 });
22940
22941 let resources = router
22942 .dispatch_stateless(
22943 &request_ctx,
22944 &JsonRpcRequest::new(
22945 "resources/list",
22946 Some(serde_json::json!({"_meta": final_metadata.clone()})),
22947 96_i64,
22948 ),
22949 )
22950 .expect("final resource catalog is encoded");
22951 assert_eq!(resources["resources"][0]["title"], "Final Catalog Resource");
22952 assert_eq!(
22953 resources["resources"][0]["icons"][0]["src"],
22954 "https://example.test/resource.png"
22955 );
22956 assert_eq!(resources["resources"][0]["annotations"]["priority"], 0.25);
22957 assert_eq!(
22958 resources["resources"][0]["_meta"]["com.example/resource"]["source"],
22959 "resource-handler"
22960 );
22961
22962 let templates = router
22963 .dispatch_stateless(
22964 &request_ctx,
22965 &JsonRpcRequest::new(
22966 "resources/templates/list",
22967 Some(serde_json::json!({"_meta": final_metadata.clone()})),
22968 97_i64,
22969 ),
22970 )
22971 .expect("final resource-template catalog is encoded");
22972 assert_eq!(
22973 templates["resourceTemplates"][0]["title"],
22974 "Final Catalog Template"
22975 );
22976 assert_eq!(
22977 templates["resourceTemplates"][0]["icons"][0]["src"],
22978 "https://example.test/template.png"
22979 );
22980 assert_eq!(
22981 templates["resourceTemplates"][0]["annotations"]["priority"],
22982 0.75
22983 );
22984 assert_eq!(
22985 templates["resourceTemplates"][0]["_meta"]["com.example/template"]["source"],
22986 "template-handler"
22987 );
22988
22989 let prompts = router
22990 .dispatch_stateless(
22991 &request_ctx,
22992 &JsonRpcRequest::new(
22993 "prompts/list",
22994 Some(serde_json::json!({"_meta": final_metadata})),
22995 98_i64,
22996 ),
22997 )
22998 .expect("final prompt catalog is encoded");
22999 assert_eq!(prompts["prompts"][0]["title"], "Final Catalog Prompt");
23000 assert_eq!(
23001 prompts["prompts"][0]["icons"][0]["src"],
23002 "https://example.test/prompt.png"
23003 );
23004 assert_eq!(
23005 prompts["prompts"][0]["_meta"]["com.example/prompt"]["source"],
23006 "prompt-handler"
23007 );
23008 assert_eq!(prompts["prompts"][0]["arguments"][0]["required"], false);
23009 }
23010
23011 #[test]
23012 fn final_resource_catalog_missing_metadata_is_non_mutating() {
23013 let metadata = OpenMetadata::try_from_entries([(
23014 "com.example/resource".to_owned(),
23015 serde_json::json!({"source": "resource-handler"}),
23016 )])
23017 .expect("resource metadata is valid");
23018 let icon = RawIcon::try_with_details(
23019 "https://example.test/resource.png",
23020 Some("image/png".to_owned()),
23021 None,
23022 None,
23023 )
23024 .expect("resource icon is valid");
23025 let mut router = Router::new();
23026 router.add_resource(FinalCatalogResource {
23027 metadata,
23028 icons: vec![icon],
23029 annotations: Annotations::default(),
23030 });
23031 let cx = Cx::for_testing();
23032 let state = SessionState::new();
23033 let request_ctx = request_context(&cx, 99, Budget::INFINITE, &state);
23034 let baseline = JsonRpcRequest::new(
23035 "resources/list",
23036 Some(serde_json::json!({
23037 "_meta": {
23038 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
23039 "io.modelcontextprotocol/clientCapabilities": {},
23040 },
23041 })),
23042 99_i64,
23043 );
23044 let mut planted = baseline.clone();
23045 planted
23046 .params
23047 .as_mut()
23048 .and_then(serde_json::Value::as_object_mut)
23049 .expect("final resource-list parameters are an object")
23050 .remove("_meta");
23051
23052 assert_eq!(baseline.method, planted.method);
23053 assert_eq!(baseline.id, planted.id);
23054 assert_eq!(
23055 baseline
23056 .params
23057 .as_ref()
23058 .and_then(serde_json::Value::as_object)
23059 .map(serde_json::Map::len),
23060 Some(1),
23061 "the final metadata object is the sole baseline parameter"
23062 );
23063 assert_eq!(
23064 planted
23065 .params
23066 .as_ref()
23067 .and_then(serde_json::Value::as_object)
23068 .map(serde_json::Map::len),
23069 Some(0),
23070 "the planted request differs only by final metadata removal"
23071 );
23072 let catalog_before = serde_json::to_vec(&router.resources()).expect("catalog serializes");
23073 let planted_before = serde_json::to_vec(&planted).expect("request serializes");
23074 let baseline_result = router
23075 .dispatch_stateless(&request_ctx, &baseline)
23076 .expect("final baseline is accepted");
23077 assert_eq!(
23078 baseline_result["resources"][0]["title"],
23079 "Final Catalog Resource"
23080 );
23081
23082 let error = router
23083 .dispatch_stateless(&request_ctx, &planted)
23084 .expect_err("only final request metadata is refused");
23085 assert_eq!(error.code, McpErrorCode::InvalidParams);
23086 assert_eq!(
23087 serde_json::to_vec(&planted).expect("rejected request serializes"),
23088 planted_before,
23089 "the one-field rejection cannot mutate caller-owned input"
23090 );
23091 assert_eq!(
23092 serde_json::to_vec(&router.resources()).expect("catalog serializes"),
23093 catalog_before,
23094 "the one-field rejection cannot mutate the resource catalog"
23095 );
23096 assert_eq!(
23097 router
23098 .dispatch_stateless(&request_ctx, &baseline)
23099 .expect("the baseline remains accepted after rejection"),
23100 baseline_result,
23101 "the one-field rejection cannot alter final field preservation"
23102 );
23103 }
23104
23105 #[test]
23106 fn core_request_decode_result_round_trips_final_catalog_and_read_cache_hints() {
23107 let metadata = OpenMetadata::try_from_entries([(
23108 "com.example/catalog".to_owned(),
23109 serde_json::json!({"source": "handler"}),
23110 )])
23111 .expect("valid final catalog metadata");
23112 let icon = RawIcon::try_with_details(
23113 "https://example.test/tool.png",
23114 Some("image/png".to_owned()),
23115 Some(vec!["48x48".to_owned()]),
23116 None,
23117 )
23118 .expect("valid final icon");
23119 let alternate_icon = RawIcon::try_with_details(
23120 "https://example.test/tool-dark.svg",
23121 Some("image/svg+xml".to_owned()),
23122 Some(vec!["any".to_owned()]),
23123 Some(fastmcp_protocol::common_types::IconTheme::Dark),
23124 )
23125 .expect("valid alternate final icon");
23126 let mut router = Router::new();
23127 let list_ttl: CacheTtl = serde_json::from_str("922337203685477580812345678901234567890")
23128 .expect("an arbitrary-width final list TTL is valid");
23129 let resource_read_ttl: CacheTtl =
23130 serde_json::from_str("184467440737095516160000000000000000000")
23131 .expect("an arbitrary-width final resource-read TTL is valid");
23132 router.set_final_cache_hint_policy(
23133 list_ttl.clone(),
23134 resource_read_ttl.clone(),
23135 CacheScope::Private,
23136 );
23137 router
23138 .add_tool(FinalCatalogTool {
23139 metadata,
23140 icons: vec![icon, alternate_icon],
23141 })
23142 .expect("final catalog tool registration succeeds");
23143 router.add_resource(NamedResource::new("file:///catalog-resource"));
23144 let cx = Cx::for_testing();
23145 let state = SessionState::new();
23146 let request_ctx = request_context(&cx, 93, Budget::INFINITE, &state);
23147
23148 let legacy = router
23149 .handle_tools_list(&request_ctx, ListToolsParams::default(), None)
23150 .expect("legacy catalog remains available");
23151 let legacy_wire = serde_json::to_value(&legacy).expect("legacy catalog serializes");
23152 assert!(legacy_wire.get("ttlMs").is_none());
23153 assert!(legacy_wire.get("cacheScope").is_none());
23154 assert!(legacy_wire["tools"][0].get("icon").is_some());
23155 assert!(legacy_wire["tools"][0].get("version").is_some());
23156 assert!(legacy_wire["tools"][0].get("tags").is_some());
23157
23158 let final_list_params = serde_json::json!({
23159 "_meta": {
23160 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
23161 "io.modelcontextprotocol/clientCapabilities": {},
23162 },
23163 });
23164 let final_list_request = CoreRequest::decode(
23165 ProtocolEra::Modern2026,
23166 "tools/list",
23167 Some(&final_list_params),
23168 )
23169 .expect("final catalog request decodes through the public core surface");
23170 let modern_list = router
23171 .dispatch_stateless(
23172 &request_ctx,
23173 &JsonRpcRequest::new("tools/list", Some(final_list_params), 93_i64),
23174 )
23175 .expect("final catalog is projected through the exact model");
23176 assert_eq!(modern_list["resultType"], "complete");
23177 assert_eq!(modern_list["ttlMs"].to_string(), list_ttl.as_str());
23178 assert_eq!(modern_list["cacheScope"], "private");
23179 assert_eq!(modern_list["tools"][0]["title"], "Exact Final Catalog Tool");
23180 assert_eq!(
23181 modern_list["tools"][0]["annotations"]["title"],
23182 "Exact annotation title"
23183 );
23184 assert_eq!(
23185 modern_list["tools"][0]["icons"].as_array().map(Vec::len),
23186 Some(2)
23187 );
23188 assert_eq!(
23189 modern_list["tools"][0]["icons"][0]["sizes"],
23190 serde_json::json!(["48x48"])
23191 );
23192 assert_eq!(
23193 modern_list["tools"][0]["_meta"]["com.example/catalog"]["source"],
23194 "handler"
23195 );
23196 assert_eq!(modern_list["tools"][0]["outputSchema"]["type"], "object");
23197 assert!(modern_list["tools"][0].get("icon").is_none());
23198 assert!(modern_list["tools"][0].get("version").is_none());
23199 assert!(modern_list["tools"][0].get("tags").is_none());
23200 let modern_list_wire =
23201 serde_json::to_string(&modern_list).expect("final catalog response serializes");
23202 let CoreResult::Final(FinalCoreResult::ToolsList { result, .. }) = final_list_request
23203 .decode_result(&modern_list_wire)
23204 .expect("final catalog response decodes through the public core surface")
23205 else {
23206 panic!("tools/list selects the exact final catalog result");
23207 };
23208 assert_eq!(result.payload.ttl_ms.as_str(), list_ttl.as_str());
23209 assert_eq!(result.payload.cache_scope, CacheScope::Private);
23210 let final_tool = result
23211 .payload
23212 .tools
23213 .first()
23214 .expect("final catalog contains the registered tool");
23215 assert_eq!(
23216 final_tool.title.as_deref(),
23217 Some("Exact Final Catalog Tool")
23218 );
23219 assert_eq!(
23220 final_tool
23221 .annotations
23222 .as_ref()
23223 .and_then(|annotations| annotations.title.as_deref()),
23224 Some("Exact annotation title")
23225 );
23226 assert_eq!(final_tool.icons.as_ref().map(Vec::len), Some(2));
23227 assert_eq!(
23228 final_tool
23229 .icons
23230 .as_ref()
23231 .and_then(|icons| icons.first())
23232 .map(|icon| icon.src.as_str()),
23233 Some("https://example.test/tool.png")
23234 );
23235 assert_eq!(
23236 final_tool
23237 .meta
23238 .as_ref()
23239 .and_then(|metadata| metadata.get("com.example/catalog"))
23240 .and_then(|value| value.get("source"))
23241 .and_then(serde_json::Value::as_str),
23242 Some("handler")
23243 );
23244 assert_eq!(
23245 final_tool
23246 .output_schema
23247 .as_ref()
23248 .and_then(|schema| schema.get("type"))
23249 .and_then(serde_json::Value::as_str),
23250 Some("object")
23251 );
23252
23253 let final_read_params = serde_json::json!({
23254 "_meta": {
23255 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
23256 "io.modelcontextprotocol/clientCapabilities": {},
23257 },
23258 "uri": "file:///catalog-resource",
23259 });
23260 let final_read_request = CoreRequest::decode(
23261 ProtocolEra::Modern2026,
23262 "resources/read",
23263 Some(&final_read_params),
23264 )
23265 .expect("final resource-read request decodes through the public core surface");
23266 let modern_read = router
23267 .dispatch_stateless(
23268 &request_ctx,
23269 &JsonRpcRequest::new("resources/read", Some(final_read_params), 94_i64),
23270 )
23271 .expect("final resource content is projected through the final model");
23272 assert_eq!(modern_read["ttlMs"].to_string(), resource_read_ttl.as_str());
23273 assert_eq!(modern_read["cacheScope"], "private");
23274 assert_eq!(
23275 modern_read["contents"][0]["uri"],
23276 "file:///catalog-resource"
23277 );
23278 assert_eq!(modern_read["contents"][0]["text"], "content");
23279 let modern_read_wire =
23280 serde_json::to_string(&modern_read).expect("final resource-read response serializes");
23281 let CoreResult::Final(FinalCoreResult::ResourcesRead { result, .. }) = final_read_request
23282 .decode_result(&modern_read_wire)
23283 .expect("final resource-read response decodes through the public core surface")
23284 else {
23285 panic!("resources/read selects the exact final read result");
23286 };
23287 assert_eq!(result.payload.ttl_ms.as_str(), resource_read_ttl.as_str());
23288 assert_eq!(result.payload.cache_scope, CacheScope::Private);
23289 assert!(matches!(
23290 result.payload.contents.as_slice(),
23291 [EmbeddedResourceContents::Text { text, .. }] if text == "content"
23292 ));
23293 }
23294
23295 #[test]
23296 fn final_catalog_missing_metadata_is_non_mutating() {
23297 let mut router = Router::new();
23298 router
23299 .add_tool(NamedTool::new("metadata-guarded-tool"))
23300 .expect("tool registration succeeds");
23301 let cx = Cx::for_testing();
23302 let state = SessionState::new();
23303 let request_ctx = request_context(&cx, 95, Budget::INFINITE, &state);
23304 let baseline = JsonRpcRequest::new(
23305 "tools/list",
23306 Some(serde_json::json!({
23307 "_meta": {
23308 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
23309 "io.modelcontextprotocol/clientCapabilities": {},
23310 },
23311 })),
23312 95_i64,
23313 );
23314 let mut planted = baseline.clone();
23315 planted
23316 .params
23317 .as_mut()
23318 .and_then(serde_json::Value::as_object_mut)
23319 .expect("final list parameters are an object")
23320 .remove("_meta");
23321 assert_eq!(baseline.method, planted.method);
23322 assert_eq!(baseline.id, planted.id);
23323 let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
23324 let planted_before = serde_json::to_vec(&planted).expect("request serializes");
23325
23326 let baseline_result = router
23327 .dispatch_stateless(&request_ctx, &baseline)
23328 .expect("the final baseline is accepted");
23329 let error = router
23330 .dispatch_stateless(&request_ctx, &planted)
23331 .expect_err("only missing final metadata is refused");
23332 assert_eq!(error.code, McpErrorCode::InvalidParams);
23333 assert_eq!(
23334 serde_json::to_vec(&planted).expect("rejected request serializes"),
23335 planted_before,
23336 "the rejected one-field request remains unchanged"
23337 );
23338 assert_eq!(
23339 serde_json::to_vec(&router.tools()).expect("catalog serializes"),
23340 catalog_before,
23341 "the rejected one-field request cannot mutate the catalog"
23342 );
23343 assert_eq!(
23344 router
23345 .dispatch_stateless(&request_ctx, &baseline)
23346 .expect("the unchanged final baseline remains accepted"),
23347 baseline_result,
23348 "the rejection cannot alter final cache hints or catalog projection"
23349 );
23350 }
23351
23352 #[test]
23353 fn srv_04_modern_owned_dispatch_runs_requests_concurrently() {
23354 let started = Arc::new(AtomicUsize::new(0));
23355 let completed = Arc::new(Mutex::new(Vec::new()));
23356 let mut router = Router::new();
23357 router
23358 .add_tool(ConcurrentModernTool::new(
23359 Arc::clone(&started),
23360 Arc::clone(&completed),
23361 ))
23362 .expect("tool registration succeeds");
23363 let router = Arc::new(router);
23364 let runtime = RuntimeBuilder::current_thread()
23365 .build()
23366 .expect("the test runtime is available");
23367 let runtime_handle = runtime.handle();
23368 let mut first = spawn_owned_modern_request(
23369 &runtime_handle,
23370 Arc::clone(&router),
23371 401,
23372 "modern-one",
23373 "one",
23374 None,
23375 );
23376 let mut second =
23377 spawn_owned_modern_request(&runtime_handle, router, 402, "modern-two", "two", None);
23378
23379 let (first, second) = runtime.block_on(async {
23380 let cx = Cx::current().expect("block_on installs an observer context");
23381 let first = first
23382 .recv(&cx)
23383 .await
23384 .expect("the first owner reports a terminal result");
23385 let second = second
23386 .recv(&cx)
23387 .await
23388 .expect("the second owner reports a terminal result");
23389 (first, second)
23390 });
23391 let first = first.expect("the first modern request completes");
23392 let second = second.expect("the second modern request completes");
23393
23394 assert_eq!(started.load(Ordering::SeqCst), 2);
23395 assert_eq!(
23396 first.get("resultType"),
23397 Some(&serde_json::json!("complete"))
23398 );
23399 assert_eq!(
23400 second.get("resultType"),
23401 Some(&serde_json::json!("complete"))
23402 );
23403 let mut completed = completed
23404 .lock()
23405 .expect("completion probe lock is not poisoned")
23406 .clone();
23407 completed.sort();
23408 assert_eq!(completed, vec!["one".to_string(), "two".to_string()]);
23409 }
23410
23411 #[test]
23412 fn srv_04_modern_owned_cancellation_does_not_change_sibling() {
23413 let started = Arc::new(AtomicUsize::new(0));
23414 let completed = Arc::new(Mutex::new(Vec::new()));
23415 let mut router = Router::new();
23416 router
23417 .add_tool(ConcurrentModernTool::new(
23418 Arc::clone(&started),
23419 Arc::clone(&completed),
23420 ))
23421 .expect("tool registration succeeds");
23422 let router = Arc::new(router);
23423 let runtime = RuntimeBuilder::current_thread()
23424 .build()
23425 .expect("the test runtime is available");
23426 let runtime_handle = runtime.handle();
23427 let (cancel_control_sender, mut cancel_control) = oneshot::channel();
23428 let mut cancelled = spawn_owned_modern_request(
23429 &runtime_handle,
23430 Arc::clone(&router),
23431 403,
23432 "modern-cancelled",
23433 "cancelled",
23434 Some(cancel_control_sender),
23435 );
23436 let mut sibling = spawn_owned_modern_request(
23437 &runtime_handle,
23438 router,
23439 404,
23440 "modern-sibling",
23441 "sibling",
23442 None,
23443 );
23444
23445 let (cancelled, sibling) = runtime.block_on(async {
23446 let observer_cx = Cx::current().expect("block_on installs an observer context");
23447 let cancelled_cx = cancel_control
23448 .recv(&observer_cx)
23449 .await
23450 .expect("the request owner exposes its cancellation context");
23451 let admission_deadline = std::time::Instant::now() + Duration::from_secs(10);
23454 while started.load(Ordering::SeqCst) < 2 {
23455 assert!(
23456 std::time::Instant::now() < admission_deadline,
23457 "both owned modern requests must start before the admission deadline"
23458 );
23459 yield_once().await;
23460 }
23461 cancelled_cx.cancel_with(CancelKind::User, Some("test single-request cancellation"));
23462 let cancelled = cancelled
23463 .recv(&observer_cx)
23464 .await
23465 .expect("the cancelled owner reports a terminal result");
23466 let sibling = sibling
23467 .recv(&observer_cx)
23468 .await
23469 .expect("the sibling owner reports a terminal result");
23470 (cancelled, sibling)
23471 });
23472
23473 let cancelled = cancelled.expect_err("only the selected request is cancelled");
23474 assert_eq!(cancelled.code, McpErrorCode::RequestCancelled);
23475 let sibling = sibling.expect("the sibling completes despite peer cancellation");
23476 assert_eq!(
23477 sibling.get("resultType"),
23478 Some(&serde_json::json!("complete"))
23479 );
23480 assert_eq!(
23481 completed
23482 .lock()
23483 .expect("completion probe lock is not poisoned")
23484 .as_slice(),
23485 ["sibling"],
23486 "cancelling one request cannot add, remove, or alter sibling completion"
23487 );
23488 }
23489
23490 #[test]
23491 fn mount_result_with_warning_and_no_error_is_successful() {
23492 let result = MountResult {
23493 tools: 0,
23494 resources: 0,
23495 resource_templates: 0,
23496 prompts: 0,
23497 warnings: vec!["something".to_string()],
23498 errors: vec![],
23499 };
23500 assert!(result.is_success());
23501 assert!(!result.has_components());
23502 }
23503
23504 struct AppsLinkedTool;
23505
23506 impl ToolHandler for AppsLinkedTool {
23507 fn definition(&self) -> Tool {
23508 Tool {
23509 name: "apps-linked-tool".to_owned(),
23510 description: None,
23511 input_schema: serde_json::json!({"type": "object"}),
23512 output_schema: None,
23513 icon: None,
23514 version: None,
23515 tags: Vec::new(),
23516 annotations: None,
23517 }
23518 }
23519
23520 fn final_definition(&self) -> Option<FinalTool> {
23521 let metadata = fastmcp_protocol::McpAppsToolMetadata::try_new(
23522 Some(
23523 AbsoluteUri::parse("ui://weather/dashboard").expect("fixed Apps URI is valid"),
23524 ),
23525 Some(vec![fastmcp_protocol::McpAppsToolVisibility::App]),
23526 )
23527 .expect("fixed Apps metadata is valid")
23528 .to_open_metadata()
23529 .expect("fixed Apps metadata serializes");
23530 Some(FinalTool {
23531 name: "apps-linked-tool".to_owned(),
23532 title: None,
23533 description: None,
23534 input_schema: serde_json::json!({"type": "object"}),
23535 output_schema: None,
23536 annotations: None,
23537 icons: None,
23538 meta: Some(metadata),
23539 })
23540 }
23541
23542 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
23543 Ok(Vec::new())
23544 }
23545 }
23546
23547 struct AppsBoundResource {
23548 mime_type: &'static str,
23549 }
23550
23551 impl ResourceHandler for AppsBoundResource {
23552 fn definition(&self) -> Resource {
23553 Resource {
23554 uri: "ui://weather/dashboard".to_owned(),
23555 name: "weather-dashboard".to_owned(),
23556 description: None,
23557 mime_type: Some(self.mime_type.to_owned()),
23558 icon: None,
23559 version: None,
23560 tags: Vec::new(),
23561 }
23562 }
23563
23564 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
23565 Ok(Vec::new())
23566 }
23567 }
23568
23569 #[test]
23570 fn apps_tool_registration_requires_a_registered_html_ui_resource() {
23571 let mut router = Router::new();
23572 router
23573 .add_mcp_apps_ui_resource_with_behavior(
23574 AppsBoundResource {
23575 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23576 },
23577 crate::DuplicateBehavior::Error,
23578 )
23579 .expect("matching Apps HTML resource registers first");
23580 router
23581 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23582 .expect("tool binding to the registered Apps HTML resource is admitted");
23583 assert!(router.tools().is_empty(), "Apps tools are final-only");
23584 assert!(
23585 router
23586 .resolve_tool_for_era("apps-linked-tool", Some(ProtocolEra::Legacy2024),)
23587 .is_none()
23588 );
23589 assert!(
23590 router
23591 .resolve_tool_for_era("apps-linked-tool", Some(ProtocolEra::Modern2026),)
23592 .is_some()
23593 );
23594 }
23595
23596 #[test]
23597 fn apps_tool_registration_rejects_only_a_non_html_ui_resource_without_mutation() {
23598 let mut router = Router::new();
23599 router
23600 .add_final_resource_with_behavior(
23601 AppsBoundResource {
23602 mime_type: "text/plain",
23603 },
23604 crate::DuplicateBehavior::Error,
23605 )
23606 .expect("the one-field MIME variant remains a valid final resource");
23607
23608 let error = router
23609 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23610 .expect_err("only replacing the Apps HTML MIME type rejects the linked tool");
23611 assert_eq!(error.code, McpErrorCode::InvalidRequest);
23612 assert!(
23613 router.tools().is_empty(),
23614 "rejected Apps linkage cannot add the tool to the legacy or final catalog"
23615 );
23616 }
23617
23618 #[test]
23619 fn generic_registration_rejects_apps_ui_resources_and_tools_without_mutation() {
23620 let mut router = Router::new();
23621 let resource_error = router
23622 .add_resource_with_behavior(
23623 AppsBoundResource {
23624 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23625 },
23626 crate::DuplicateBehavior::Error,
23627 )
23628 .expect_err("generic registration must not expose an Apps View to exact 2024");
23629 assert_eq!(resource_error.code, McpErrorCode::InvalidRequest);
23630 assert!(router.resources().is_empty());
23631
23632 router
23633 .add_mcp_apps_ui_resource_with_behavior(
23634 AppsBoundResource {
23635 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23636 },
23637 crate::DuplicateBehavior::Error,
23638 )
23639 .expect("the negotiated final-only Apps resource registers");
23640 let tool_error = router
23641 .add_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23642 .expect_err("generic registration must not publish Apps metadata without opt-in");
23643 assert_eq!(tool_error.code, McpErrorCode::InvalidRequest);
23644 assert!(router.tools().is_empty());
23645 }
23646
23647 #[test]
23648 fn apps_binding_mounts_reject_dangling_tools_replacements_and_prefixes_atomically() {
23649 let mut source = Router::new();
23650 source
23651 .add_mcp_apps_ui_resource_with_behavior(
23652 AppsBoundResource {
23653 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23654 },
23655 crate::DuplicateBehavior::Error,
23656 )
23657 .expect("source Apps resource registers");
23658 source
23659 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23660 .expect("source Apps tool registers");
23661
23662 let mut tools_only_destination = Router::new();
23663 let tools_only = tools_only_destination.mount_tools(source, None);
23664 assert!(!tools_only.is_success());
23665 assert!(tools_only_destination.tools().is_empty());
23666
23667 let mut destination = Router::new();
23668 destination
23669 .add_mcp_apps_ui_resource_with_behavior(
23670 AppsBoundResource {
23671 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23672 },
23673 crate::DuplicateBehavior::Error,
23674 )
23675 .expect("destination Apps resource registers");
23676 destination
23677 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23678 .expect("destination Apps tool registers");
23679
23680 let mut incompatible_resource = Router::new();
23681 incompatible_resource
23682 .add_final_resource_with_behavior(
23683 AppsBoundResource {
23684 mime_type: "text/plain",
23685 },
23686 crate::DuplicateBehavior::Error,
23687 )
23688 .expect("non-Apps final resource is independently admissible");
23689 let replacement = destination.mount_resources_with_behavior(
23690 incompatible_resource,
23691 None,
23692 crate::DuplicateBehavior::Replace,
23693 );
23694 assert!(!replacement.is_success());
23695 assert_eq!(
23696 destination
23697 .final_resources
23698 .get("ui://weather/dashboard")
23699 .expect("rejected replacement retains the HTML resource")
23700 .definition
23701 .mime_type
23702 .as_deref(),
23703 Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE)
23704 );
23705
23706 let mut prefixed_source = Router::new();
23707 prefixed_source
23708 .add_mcp_apps_ui_resource_with_behavior(
23709 AppsBoundResource {
23710 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23711 },
23712 crate::DuplicateBehavior::Error,
23713 )
23714 .expect("prefixed source Apps resource registers");
23715 prefixed_source
23716 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23717 .expect("prefixed source Apps tool registers");
23718 let mut prefixed_destination = Router::new();
23719 let prefixed = prefixed_destination.mount(prefixed_source, Some("peer"));
23720 assert!(!prefixed.is_success());
23721 assert!(prefixed_destination.tools().is_empty());
23722 assert!(prefixed_destination.resources().is_empty());
23723 }
23724}