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, 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)]
407#[serde(rename_all = "snake_case")]
408enum FinalCatalogKind {
409 Tools,
410 Resources,
411 ResourceTemplates,
412 Prompts,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
422#[serde(deny_unknown_fields)]
423struct FinalCatalogQuery {
424 include_tags: Vec<String>,
425 exclude_tags: Vec<String>,
426}
427
428impl FinalCatalogQuery {
429 fn from_final_list_params(params: &FinalListParams) -> Self {
430 Self::from_tag_filters(
431 params.include_tags.as_deref(),
432 params.exclude_tags.as_deref(),
433 )
434 }
435
436 fn from_tag_filters(include_tags: Option<&[String]>, exclude_tags: Option<&[String]>) -> Self {
437 Self {
438 include_tags: canonical_final_catalog_tags(include_tags),
439 exclude_tags: canonical_final_catalog_tags(exclude_tags),
440 }
441 }
442}
443
444fn canonical_final_catalog_tags(tags: Option<&[String]>) -> Vec<String> {
445 let Some(tags) = tags else {
446 return Vec::new();
447 };
448 let mut canonical = tags
449 .iter()
450 .map(|tag| tag.to_lowercase())
451 .collect::<Vec<_>>();
452 canonical.sort_unstable();
453 canonical.dedup();
454 canonical
455}
456
457#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
458#[serde(deny_unknown_fields)]
459struct FinalCatalogCursor {
460 catalog: FinalCatalogKind,
461 revision: u64,
462 query: FinalCatalogQuery,
463 offset: u64,
464}
465
466fn decode_final_catalog_cursor_offset(
467 cursor: Option<&str>,
468 expected_catalog: FinalCatalogKind,
469 expected_revision: u64,
470 expected_query: &FinalCatalogQuery,
471 catalog_length: usize,
472) -> McpResult<usize> {
473 let Some(cursor) = cursor else {
474 return Ok(0);
475 };
476
477 let decoded = BASE64_STANDARD.decode(cursor).map_err(|_| {
478 McpError::invalid_params("Invalid final catalog cursor (base64 decode failed)")
479 })?;
480 let cursor = serde_json::from_slice::<FinalCatalogCursor>(&decoded).map_err(|_| {
481 McpError::invalid_params("Invalid final catalog cursor (JSON parse failed)")
482 })?;
483 if cursor.catalog != expected_catalog {
484 return Err(McpError::invalid_params(
485 "final catalog cursor belongs to another list method",
486 ));
487 }
488 if cursor.revision != expected_revision {
489 return Err(McpError::invalid_params(
490 "final catalog cursor references a stale catalog revision",
491 ));
492 }
493 if &cursor.query != expected_query {
494 return Err(McpError::invalid_params(
495 "final catalog cursor does not match the requested query filters",
496 ));
497 }
498 let offset = usize::try_from(cursor.offset)
499 .map_err(|_| McpError::invalid_params("Invalid final catalog cursor (offset too large)"))?;
500 if offset >= catalog_length {
501 return Err(McpError::invalid_params(
502 "final catalog cursor offset is outside the requested catalog page",
503 ));
504 }
505 Ok(offset)
506}
507
508fn parse_stateless_params<T: serde::de::DeserializeOwned>(
509 params: Option<serde_json::Value>,
510) -> McpResult<T> {
511 let value = params.ok_or_else(|| McpError::invalid_params("Missing required parameters"))?;
512 serde_json::from_value(value).map_err(|error| McpError::invalid_params(error.to_string()))
513}
514
515fn parse_stateless_params_or_default<T: serde::de::DeserializeOwned + Default>(
516 params: Option<serde_json::Value>,
517) -> McpResult<T> {
518 match params {
519 Some(value) => serde_json::from_value(value)
520 .map_err(|error| McpError::invalid_params(error.to_string())),
521 None => Ok(T::default()),
522 }
523}
524
525fn encode_stateless_handler_result<T: serde::Serialize>(
528 result: McpResult<T>,
529) -> McpResult<serde_json::Value> {
530 encode_final_complete_result(result?)
531}
532
533fn encode_final_tools_call_result(
537 result: McpResult<CompleteResult<FinalCallToolResult>>,
538) -> McpResult<serde_json::Value> {
539 let result = result?;
540 let encoded = CoreResult::Final(FinalCoreResult::ToolsCall {
541 result,
542 diagnostic: None,
543 })
544 .encode()
545 .map_err(|error| McpError::internal_error(error.to_string()))?;
546 serde_json::from_str(&encoded).map_err(McpError::from)
547}
548
549fn encode_mrtr_input_required_result(result: MrtrInputRequired) -> McpResult<serde_json::Value> {
552 serde_json::to_value(result)
553 .map_err(|_| McpError::internal_error("failed to encode MRTR input_required result"))
554}
555
556const MAX_MRTR_BINDING_BYTES: usize = 64 * 1024;
557const MAX_MRTR_RAW_PARAMS_BYTES: usize = 256 * 1024;
558const MAX_MRTR_RAW_INPUT_RESPONSES_BYTES: usize = 192 * 1024;
559const MAX_MRTR_RAW_JSON_DEPTH: usize = 32;
560const MAX_MRTR_RAW_JSON_VALUES: usize = 4_096;
561const MRTR_REQUIRES_BOUND_MODERN_CONNECTION: &str =
562 "MRTR-capable handlers require a bound modern connection";
563
564struct MrtrRawJsonCounter {
565 max_bytes: usize,
566 bytes: usize,
567}
568
569impl Write for MrtrRawJsonCounter {
570 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
571 let remaining = self.max_bytes.saturating_sub(self.bytes);
572 if buffer.len() > remaining {
573 return Err(io::Error::other("MRTR JSON byte limit exceeded"));
574 }
575 self.bytes += buffer.len();
576 Ok(buffer.len())
577 }
578
579 fn flush(&mut self) -> io::Result<()> {
580 Ok(())
581 }
582}
583
584fn admit_mrtr_raw_json_value(value: &serde_json::Value, max_bytes: usize) -> McpResult<()> {
585 fn count_values(value: &serde_json::Value, depth: usize, values: &mut usize) -> McpResult<()> {
586 if depth > MAX_MRTR_RAW_JSON_DEPTH {
587 return Err(McpError::invalid_params(
588 "MRTR JSON exceeds its nesting limit",
589 ));
590 }
591 *values = values.saturating_add(1);
592 if *values > MAX_MRTR_RAW_JSON_VALUES {
593 return Err(McpError::invalid_params(
594 "MRTR JSON exceeds its value limit",
595 ));
596 }
597 match value {
598 serde_json::Value::Array(items) => {
599 for value in items {
600 count_values(value, depth + 1, values)?;
601 }
602 }
603 serde_json::Value::Object(members) => {
604 for value in members.values() {
605 count_values(value, depth + 1, values)?;
606 }
607 }
608 serde_json::Value::Null
609 | serde_json::Value::Bool(_)
610 | serde_json::Value::Number(_)
611 | serde_json::Value::String(_) => {}
612 }
613 Ok(())
614 }
615
616 let mut values = 0;
617 count_values(value, 0, &mut values)?;
618 let mut counter = MrtrRawJsonCounter {
619 max_bytes,
620 bytes: 0,
621 };
622 serde_json::to_writer(&mut counter, value)
623 .map_err(|_| McpError::invalid_params("MRTR JSON exceeds its byte limit"))
624}
625
626enum FinalMrtrDispatch {
627 Fresh,
628 Resume(MrtrCompletedInputs),
629 InputRequired(serde_json::Value),
630}
631
632fn mrtr_digest(value: &impl serde::Serialize) -> McpResult<[u8; 32]> {
633 let bytes = serde_json::to_vec(value)
634 .map_err(|_| McpError::invalid_params("invalid MRTR operation binding"))?;
635 let digest = sha256_bounded(&bytes, MAX_MRTR_BINDING_BYTES)
636 .map_err(|_| McpError::invalid_params("MRTR operation binding exceeds its limit"))?;
637 Ok(*digest.as_bytes())
638}
639
640fn final_mrtr_binding(
641 request_ctx: &McpContext,
642 method: &'static str,
643 target: String,
644 arguments: &impl serde::Serialize,
645) -> McpResult<Option<MrtrExchangeBinding>> {
646 if target.len() > MAX_MRTR_BINDING_BYTES {
647 return Err(McpError::invalid_params("MRTR target exceeds its limit"));
648 }
649 let Some((session_partition, _revision)) = request_ctx.session_cache_partition() else {
654 return Ok(None);
655 };
656 let principal_digest = request_ctx
657 .auth()
658 .map(|auth| {
659 auth.session_owner()
660 .map(|owner| Ok(*owner.as_bytes()))
661 .unwrap_or_else(|| mrtr_digest(&auth))
662 })
663 .transpose()?;
664 Ok(Some(MrtrExchangeBinding::new(
665 method,
666 target,
667 mrtr_digest(arguments)?,
668 session_partition,
669 principal_digest,
670 )))
671}
672
673fn handler_mrtr_input_requests(
674 request_ctx: &McpContext,
675 result: &InputRequiredResult,
676) -> McpResult<MrtrInputRequests> {
677 let Some(input_requests) = result.input_requests() else {
678 return Ok(MrtrInputRequests::default());
679 };
680 if input_requests.members().is_empty() {
681 return Err(McpError::invalid_params(
682 "MRTR inputRequests must not be empty",
683 ));
684 }
685 MrtrInputRequests::new(
686 input_requests
687 .members()
688 .iter()
689 .map(|member| {
690 let value = exact_json_to_serde(&member.value)
691 .map_err(|_| McpError::invalid_params("invalid MRTR input request"))?;
692 let request = MrtrInputRequest::from_wire(&value)?;
693 if request.kind() == MrtrInputKind::Sampling
694 && !request_ctx.client_supports_sampling()
695 {
696 return Err(McpError::invalid_request(
697 "Final sampling is not advertised by the client",
698 ));
699 }
700 Ok((member.name.clone(), request))
701 })
702 .collect::<McpResult<Vec<_>>>()?,
703 )
704}
705
706#[cfg(feature = "tasks")]
707fn encode_final_task_result(
708 result: fastmcp_protocol::tasks_extension::CreateTaskResult,
709) -> McpResult<serde_json::Value> {
710 let encoded = CoreResult::Final(FinalCoreResult::ToolsCallTask { result })
711 .encode()
712 .map_err(|error| McpError::internal_error(error.to_string()))?;
713 serde_json::from_str(&encoded).map_err(McpError::from)
714}
715
716#[cfg(feature = "tasks")]
717fn require_final_tasks_capability(metadata: &OpenMetadata) -> McpResult<()> {
718 let declared = metadata
719 .client_capabilities()
720 .map_err(|_| McpError::invalid_params("invalid final client capabilities"))?
721 .and_then(|capabilities| capabilities.get("extensions"))
722 .and_then(serde_json::Value::as_object)
723 .and_then(|extensions| extensions.get(OFFICIAL_TASKS_EXTENSION_ID))
724 .and_then(serde_json::Value::as_object)
725 .is_some_and(serde_json::Map::is_empty);
726 if declared {
727 return Ok(());
728 }
729
730 let mut extensions = serde_json::Map::new();
731 extensions.insert(
732 OFFICIAL_TASKS_EXTENSION_ID.to_owned(),
733 serde_json::json!({}),
734 );
735 let missing = MissingRequiredClientCapabilityError::new(serde_json::json!({
736 "extensions": serde_json::Value::Object(extensions)
737 }))
738 .map_err(|_| McpError::internal_error("failed to encode required Tasks capability"))?;
739 Err(McpError::with_data(
740 McpErrorCode::Custom(missing.jsonrpc_error_code()),
741 "Required client capability is missing",
742 missing.canonical_error_data(),
743 ))
744}
745
746#[derive(Clone)]
753struct FinalToolSchemas {
754 input: Option<AdmittedSchema>,
755 output: Option<AdmittedSchema>,
756 errors: Option<FinalToolErrorStructuredContent>,
757}
758
759#[derive(Clone)]
760struct FinalToolErrorStructuredContent {
761 input_validation: serde_json::Value,
762 handler: serde_json::Value,
763}
764
765const MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES: usize = 16 * 1024;
766
767fn admit_final_tool_error_structured_content<H: ToolHandler + ?Sized>(
768 handler: &H,
769 output: &AdmittedSchema,
770 kind: ToolErrorKind,
771) -> McpResult<serde_json::Value> {
772 let mapped = crate::catch_extension_unwind(|| {
773 handler.final_tool_error_structured_content(kind)
774 })
775 .map_err(|_payload| {
776 McpError::internal_error("tool error structured-content mapper panicked during admission")
777 })?
778 .ok_or_else(|| {
779 McpError::internal_error(
780 "tool declares outputSchema without a complete tool-error structured-content mapper",
781 )
782 })?;
783 let encoded = serde_json::to_vec(&mapped).map_err(|_error| {
784 McpError::internal_error("tool error structured-content mapper returned invalid JSON")
785 })?;
786 if encoded.len() > MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES {
787 return Err(McpError::internal_error(
788 "tool error structured-content mapper exceeded the registration limit",
789 ));
790 }
791 if output.validate(&mapped).is_err() {
792 return Err(McpError::internal_error(
793 "tool error structured-content mapper does not satisfy outputSchema",
794 ));
795 }
796 Ok(mapped)
797}
798
799fn admit_final_tool_schemas<H: ToolHandler + ?Sized>(
800 upstream_schema_registered: bool,
801 input_schema: &serde_json::Value,
802 output_schema: Option<&serde_json::Value>,
803 handler: &H,
804) -> McpResult<FinalToolSchemas> {
805 if upstream_schema_registered {
806 return Ok(FinalToolSchemas {
811 input: None,
812 output: None,
813 errors: None,
814 });
815 }
816 if !input_schema.is_object() {
817 return Err(McpError::internal_error(
818 "tool declares a final input schema that is not an object",
819 ));
820 }
821 if input_schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
822 return Err(McpError::internal_error(
823 "tool declares a final input schema without type object",
824 ));
825 }
826 let input = admit_final_schema(input_schema.clone()).map_err(|_error| {
827 McpError::internal_error("tool declares an invalid final input schema")
828 })?;
829 let output = output_schema
830 .cloned()
831 .map(|schema| {
832 if !schema.is_object() {
833 return Err(McpError::internal_error(
834 "tool declares a final output schema that is not an object",
835 ));
836 }
837 admit_final_schema(schema).map_err(|_error| {
838 McpError::internal_error("tool declares an invalid final output schema")
839 })
840 })
841 .transpose()?;
842 let errors = output
843 .as_ref()
844 .map(|output| -> McpResult<FinalToolErrorStructuredContent> {
845 Ok(FinalToolErrorStructuredContent {
846 input_validation: admit_final_tool_error_structured_content(
847 handler,
848 output,
849 ToolErrorKind::InputValidation,
850 )?,
851 handler: admit_final_tool_error_structured_content(
852 handler,
853 output,
854 ToolErrorKind::Handler,
855 )?,
856 })
857 })
858 .transpose()?;
859 Ok(FinalToolSchemas {
860 input: Some(input),
861 output,
862 errors,
863 })
864}
865
866struct AdmittedToolRegistration {
869 handler: BoxedToolHandler,
870 definition: Tool,
871 final_registration: Option<AdmittedFinalToolRegistration>,
872 legacy_enabled: bool,
873}
874
875struct AdmittedFinalToolRegistration {
876 final_definition: FinalTool,
877 schemas: FinalToolSchemas,
878 declares_final_tasks: bool,
879}
880
881struct AdmittedFinalResourceRegistration {
884 definition: FinalResource,
885 tags: Vec<String>,
886 uri_use_policy: ResourceUriUsePolicy,
887}
888
889struct AdmittedFinalPromptRegistration {
892 definition: FinalPrompt,
893 tags: Vec<String>,
894 uri_use_policy: ResourceUriUsePolicy,
895}
896
897impl AdmittedToolRegistration {
898 fn admit<H: ToolHandler + 'static>(
899 handler: H,
900 definition: Tool,
901 legacy_enabled: bool,
902 ) -> McpResult<Self> {
903 let (exact_final_definition, declares_final_tasks, upstream_schema_registered) =
904 crate::catch_extension_unwind(|| {
905 (
906 handler.final_definition(),
907 handler.declares_final_tasks(),
908 handler.upstream_final_tool_schema_registration().is_some(),
909 )
910 })
911 .map_err(|_payload| {
912 McpError::internal_error("tool metadata hook panicked during admission")
913 })?;
914 let final_definition = match exact_final_definition {
915 Some(definition) => definition,
916 None => {
917 let (title, icons, metadata) = crate::catch_extension_unwind(|| {
918 (
919 handler.final_title().map(str::to_owned),
920 handler.final_icons().map(|icons| icons.to_vec()),
921 handler.final_metadata().cloned(),
922 )
923 })
924 .map_err(|_payload| {
925 McpError::internal_error("tool metadata hook panicked during admission")
926 })?;
927 FinalTool {
928 name: definition.name.clone(),
929 title,
930 description: definition.description.clone(),
931 input_schema: definition.input_schema.clone(),
932 output_schema: definition.output_schema.clone(),
933 annotations: definition
934 .annotations
935 .clone()
936 .map(project_final_tool_annotations),
937 icons,
938 meta: metadata,
939 }
940 }
941 };
942 if final_definition.name != definition.name {
943 return Err(McpError::internal_error(
944 "tool's exact final definition name differs from its legacy definition name",
945 ));
946 }
947 let schemas = admit_final_tool_schemas(
948 upstream_schema_registered,
949 &final_definition.input_schema,
950 final_definition.output_schema.as_ref(),
951 &handler,
952 )?;
953 Ok(Self {
954 handler: Box::new(handler),
955 definition,
956 final_registration: Some(AdmittedFinalToolRegistration {
957 final_definition,
958 schemas,
959 declares_final_tasks,
960 }),
961 legacy_enabled,
962 })
963 }
964
965 fn legacy_only<H: ToolHandler + 'static>(handler: H, definition: Tool) -> Self {
966 Self {
967 handler: Box::new(handler),
968 definition,
969 final_registration: None,
970 legacy_enabled: true,
971 }
972 }
973
974 fn with_mounted_name(self, mounted_name: String) -> Self {
975 use crate::handler::MountedToolHandler;
976
977 let Self {
978 handler,
979 mut definition,
980 mut final_registration,
981 legacy_enabled,
982 } = self;
983 definition.name.clone_from(&mounted_name);
984 if let Some(final_registration) = final_registration.as_mut() {
985 final_registration
986 .final_definition
987 .name
988 .clone_from(&mounted_name);
989 }
990 Self {
991 handler: Box::new(MountedToolHandler::new(handler, mounted_name)),
992 definition,
993 final_registration,
994 legacy_enabled,
995 }
996 }
997}
998
999fn encode_final_resources_read_result(
1004 result: McpResult<CompleteResult<FinalReadResourceResult>>,
1005) -> McpResult<serde_json::Value> {
1006 let result = result?;
1007 let encoded = CoreResult::Final(FinalCoreResult::ResourcesRead {
1008 result,
1009 diagnostic: None,
1010 })
1011 .encode()
1012 .map_err(|error| McpError::internal_error(error.to_string()))?;
1013 serde_json::from_str(&encoded).map_err(McpError::from)
1014}
1015
1016fn encode_final_prompts_get_result(
1020 result: McpResult<CompleteResult<FinalGetPromptResult>>,
1021) -> McpResult<serde_json::Value> {
1022 let result = result?;
1023 let encoded = CoreResult::Final(FinalCoreResult::PromptsGet {
1024 result,
1025 diagnostic: None,
1026 })
1027 .encode()
1028 .map_err(|error| McpError::internal_error(error.to_string()))?;
1029 serde_json::from_str(&encoded).map_err(McpError::from)
1030}
1031
1032#[derive(Clone, Debug, PartialEq, Eq)]
1033struct FinalCacheHintPolicy {
1034 list_ttl_ms: CacheTtl,
1035 resource_read_ttl_ms: CacheTtl,
1036 scope: CacheScope,
1037}
1038
1039impl Default for FinalCacheHintPolicy {
1040 fn default() -> Self {
1041 Self {
1042 list_ttl_ms: CacheTtl::milliseconds(5 * 60 * 1_000),
1043 resource_read_ttl_ms: CacheTtl::milliseconds(60 * 60 * 1_000),
1044 scope: CacheScope::Private,
1045 }
1046 }
1047}
1048
1049fn encode_final_core_result<T>(
1053 result: McpResult<T>,
1054 select: impl FnOnce(CompleteResult<T>) -> FinalCoreResult,
1055) -> McpResult<serde_json::Value> {
1056 let result = CompleteResult::new(result?, empty_final_result_meta()?);
1057 let encoded = CoreResult::Final(select(result))
1058 .encode()
1059 .map_err(|error| McpError::internal_error(error.to_string()))?;
1060 serde_json::from_str(&encoded).map_err(McpError::from)
1061}
1062
1063fn project_final_tool_annotations(
1064 annotations: fastmcp_protocol::ToolAnnotations,
1065) -> FinalToolAnnotations {
1066 FinalToolAnnotations {
1067 title: None,
1068 destructive: annotations.destructive,
1069 idempotent: annotations.idempotent,
1070 read_only: annotations.read_only,
1071 open_world_hint: annotations.open_world_hint,
1072 }
1073}
1074
1075fn project_final_resource_catalog_entry(
1076 resource: Resource,
1077 title: Option<String>,
1078 icons: Option<Vec<RawIcon>>,
1079 annotations: Option<Annotations>,
1080 meta: Option<OpenMetadata>,
1081) -> McpResult<FinalResource> {
1082 let uri = AbsoluteUri::parse(resource.uri).map_err(|error| {
1083 McpError::internal_error(format!(
1084 "legacy resource URI cannot be projected into the final catalog: {error}",
1085 ))
1086 })?;
1087 Ok(FinalResource {
1088 uri,
1089 name: resource.name,
1090 title,
1091 description: resource.description,
1092 icons,
1093 mime_type: resource.mime_type,
1094 size: None,
1095 annotations,
1096 meta,
1097 })
1098}
1099
1100const FINAL_RESOURCE_URI_USE_REJECTED: &str =
1101 "final resource URI is not admitted for this local use site";
1102const FINAL_RESOURCE_URI_USE_EMISSION_REJECTED: &str =
1103 "handler emitted a final resource URI that is not admitted for this local use site";
1104
1105fn admit_final_resource_uri(
1106 policy: ResourceUriUsePolicy,
1107 uri: &AbsoluteUri,
1108 use_site: FinalResourceUriUse,
1109) -> McpResult<()> {
1110 policy
1111 .admits(uri, use_site)
1112 .then_some(())
1113 .ok_or_else(|| McpError::invalid_params(FINAL_RESOURCE_URI_USE_REJECTED))
1114}
1115
1116fn admit_final_resource_template_uri(
1117 policy: ResourceUriUsePolicy,
1118 uri_template: &str,
1119) -> McpResult<()> {
1120 policy
1121 .admits_template(uri_template)
1122 .then_some(())
1123 .ok_or_else(|| McpError::invalid_params(FINAL_RESOURCE_URI_USE_REJECTED))
1124}
1125
1126fn enforce_final_resource_uri_emission(
1127 policy: ResourceUriUsePolicy,
1128 uri: &AbsoluteUri,
1129 use_site: FinalResourceUriUse,
1130) -> McpResult<()> {
1131 policy
1132 .admits(uri, use_site)
1133 .then_some(())
1134 .ok_or_else(|| McpError::internal_error(FINAL_RESOURCE_URI_USE_EMISSION_REJECTED))
1135}
1136
1137fn embedded_resource_uri(contents: &EmbeddedResourceContents) -> &AbsoluteUri {
1138 match contents {
1139 EmbeddedResourceContents::Text { uri, .. } | EmbeddedResourceContents::Blob { uri, .. } => {
1140 uri
1141 }
1142 }
1143}
1144
1145fn admit_final_resource_read_outcome(
1146 policy: ResourceUriUsePolicy,
1147 outcome: &FinalMethodOutcome<FinalReadResourceResult>,
1148) -> McpResult<()> {
1149 let FinalMethodOutcome::Complete(result) = outcome else {
1150 return Ok(());
1151 };
1152 for contents in &result.payload.contents {
1153 enforce_final_resource_uri_emission(
1154 policy,
1155 embedded_resource_uri(contents),
1156 FinalResourceUriUse::ResourceReadContents,
1157 )?;
1158 }
1159 Ok(())
1160}
1161
1162fn admit_final_prompt_content(
1163 policy: ResourceUriUsePolicy,
1164 content: &fastmcp_protocol::common_types::ContentBlock,
1165) -> McpResult<()> {
1166 match content {
1167 fastmcp_protocol::common_types::ContentBlock::ResourceLink { uri, .. } => {
1168 enforce_final_resource_uri_emission(
1169 policy,
1170 uri,
1171 FinalResourceUriUse::PromptResourceLink,
1172 )
1173 }
1174 fastmcp_protocol::common_types::ContentBlock::Resource { resource, .. } => {
1175 enforce_final_resource_uri_emission(
1176 policy,
1177 embedded_resource_uri(resource),
1178 FinalResourceUriUse::PromptEmbeddedResource,
1179 )
1180 }
1181 fastmcp_protocol::common_types::ContentBlock::Text { .. }
1182 | fastmcp_protocol::common_types::ContentBlock::Image { .. }
1183 | fastmcp_protocol::common_types::ContentBlock::Audio { .. } => Ok(()),
1184 }
1185}
1186
1187fn admit_final_prompt_outcome(
1188 policy: ResourceUriUsePolicy,
1189 outcome: &FinalMethodOutcome<FinalGetPromptResult>,
1190) -> McpResult<()> {
1191 let FinalMethodOutcome::Complete(result) = outcome else {
1192 return Ok(());
1193 };
1194 for message in &result.payload.messages {
1195 admit_final_prompt_content(policy, &message.content)?;
1196 }
1197 Ok(())
1198}
1199
1200fn admit_final_resource_definition<H: ResourceHandler + ?Sized>(
1205 handler: &H,
1206 resource: &Resource,
1207 uri_use_policy: ResourceUriUsePolicy,
1208) -> McpResult<FinalResource> {
1209 if let Some(definition) =
1210 crate::catch_extension_unwind(|| handler.final_definition()).map_err(|_payload| {
1211 McpError::internal_error("resource final-definition hook panicked during admission")
1212 })?
1213 {
1214 if definition.uri.as_str() != resource.uri {
1215 return Err(McpError::internal_error(
1216 "resource exact final URI differs from its legacy definition URI",
1217 ));
1218 }
1219 admit_final_resource_uri(
1220 uri_use_policy,
1221 &definition.uri,
1222 FinalResourceUriUse::CatalogResource,
1223 )?;
1224 return Ok(definition);
1225 }
1226 let (title, icons, annotations, meta) = crate::catch_extension_unwind(|| {
1227 (
1228 handler.final_title().map(str::to_owned),
1229 handler.final_icons().map(|icons| icons.to_vec()),
1230 handler.final_annotations().cloned(),
1231 handler.final_metadata().cloned(),
1232 )
1233 })
1234 .map_err(|_payload| {
1235 McpError::internal_error("resource metadata hook panicked during admission")
1236 })?;
1237 let definition =
1238 project_final_resource_catalog_entry(resource.clone(), title, icons, annotations, meta)?;
1239 admit_final_resource_uri(
1240 uri_use_policy,
1241 &definition.uri,
1242 FinalResourceUriUse::CatalogResource,
1243 )?;
1244 Ok(definition)
1245}
1246
1247fn admit_final_resource_template_definition<H: ResourceHandler + ?Sized>(
1249 handler: Option<&H>,
1250 template: &ResourceTemplate,
1251 uri_use_policy: ResourceUriUsePolicy,
1252) -> McpResult<FinalResourceTemplate> {
1253 if let Some(handler) = handler {
1254 if let Some(definition) =
1255 crate::catch_extension_unwind(|| handler.final_template_definition()).map_err(
1256 |_payload| {
1257 McpError::internal_error(
1258 "resource final-template-definition hook panicked during admission",
1259 )
1260 },
1261 )?
1262 {
1263 if definition.uri_template != template.uri_template {
1264 return Err(McpError::internal_error(
1265 "resource exact final template differs from its legacy template URI",
1266 ));
1267 }
1268 admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
1269 return Ok(definition);
1270 }
1271 }
1272 let (title, icons, annotations, meta) = match handler {
1273 Some(handler) => crate::catch_extension_unwind(|| {
1274 (
1275 handler.final_template_title().map(str::to_owned),
1276 handler.final_template_icons().map(|icons| icons.to_vec()),
1277 handler.final_template_annotations().cloned(),
1278 handler.final_template_metadata().cloned(),
1279 )
1280 })
1281 .map_err(|_payload| {
1282 McpError::internal_error("resource template metadata hook panicked during admission")
1283 })?,
1284 None => (None, None, None, None),
1285 };
1286 let definition = FinalResourceTemplate {
1287 uri_template: template.uri_template.clone(),
1288 name: template.name.clone(),
1289 title,
1290 description: template.description.clone(),
1291 icons,
1292 mime_type: template.mime_type.clone(),
1293 annotations,
1294 meta,
1295 };
1296 admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
1297 Ok(definition)
1298}
1299
1300fn admit_final_prompt_definition<H: PromptHandler + ?Sized>(
1302 handler: &H,
1303 prompt: &Prompt,
1304) -> McpResult<FinalPrompt> {
1305 if let Some(definition) =
1306 crate::catch_extension_unwind(|| handler.final_definition()).map_err(|_payload| {
1307 McpError::internal_error("prompt final-definition hook panicked during admission")
1308 })?
1309 {
1310 if definition.name != prompt.name {
1311 return Err(McpError::internal_error(
1312 "prompt exact final name differs from its legacy definition name",
1313 ));
1314 }
1315 return Ok(definition);
1316 }
1317 let (title, icons, meta) = crate::catch_extension_unwind(|| {
1318 (
1319 handler.final_title().map(str::to_owned),
1320 handler.final_icons().map(|icons| icons.to_vec()),
1321 handler.final_metadata().cloned(),
1322 )
1323 })
1324 .map_err(|_payload| {
1325 McpError::internal_error("prompt metadata hook panicked during admission")
1326 })?;
1327 let arguments = (!prompt.arguments.is_empty()).then(|| {
1328 prompt
1329 .arguments
1330 .iter()
1331 .map(|argument| FinalPromptArgument {
1332 name: argument.name.clone(),
1333 title: None,
1334 description: argument.description.clone(),
1335 required: Some(argument.required),
1336 })
1337 .collect()
1338 });
1339 Ok(FinalPrompt {
1340 name: prompt.name.clone(),
1341 title,
1342 description: prompt.description.clone(),
1343 icons,
1344 arguments,
1345 meta,
1346 })
1347}
1348
1349fn legacy_content_from_handler(content: Content) -> McpResult<LegacyContent> {
1355 match content {
1356 Content::Text { text } => Ok(LegacyContent::Text {
1357 text,
1358 annotations: None,
1359 additional: BTreeMap::new(),
1360 }),
1361 Content::Image { data, mime_type } => Ok(LegacyContent::Image {
1362 data,
1363 mime_type,
1364 annotations: None,
1365 additional: BTreeMap::new(),
1366 }),
1367 Content::Resource { resource } => Ok(LegacyContent::Resource {
1368 resource: legacy_resource_content_from_handler(resource)?,
1369 annotations: None,
1370 additional: BTreeMap::new(),
1371 }),
1372 Content::Audio { .. } => Err(McpError::internal_error(
1373 "legacy 2024 result content does not support audio",
1374 )),
1375 }
1376}
1377
1378fn legacy_contents_from_handler(content: Vec<Content>) -> McpResult<Vec<LegacyContent>> {
1379 content
1380 .into_iter()
1381 .map(legacy_content_from_handler)
1382 .collect()
1383}
1384
1385fn legacy_resource_content_from_handler(
1387 resource: ResourceContent,
1388) -> McpResult<LegacyResourceContent> {
1389 match (resource.text, resource.blob) {
1390 (Some(text), None) => Ok(LegacyResourceContent::Text {
1391 uri: resource.uri,
1392 text,
1393 mime_type: resource.mime_type,
1394 additional: BTreeMap::new(),
1395 }),
1396 (None, Some(blob)) => Ok(LegacyResourceContent::Blob {
1397 uri: resource.uri,
1398 blob,
1399 mime_type: resource.mime_type,
1400 additional: BTreeMap::new(),
1401 }),
1402 _ => Err(McpError::internal_error(
1403 "legacy 2024 resource content requires exactly one text or blob payload",
1404 )),
1405 }
1406}
1407
1408fn legacy_resource_contents_from_handler(
1409 contents: Vec<ResourceContent>,
1410) -> McpResult<Vec<LegacyResourceContent>> {
1411 contents
1412 .into_iter()
1413 .map(legacy_resource_content_from_handler)
1414 .collect()
1415}
1416
1417fn legacy_prompt_messages_from_handler(
1418 messages: Vec<PromptMessage>,
1419) -> McpResult<Vec<LegacyPromptMessage>> {
1420 messages
1421 .into_iter()
1422 .map(|PromptMessage { role, content }| {
1423 Ok(LegacyPromptMessage {
1424 role,
1425 content: legacy_content_from_handler(content)?,
1426 additional: BTreeMap::new(),
1427 })
1428 })
1429 .collect()
1430}
1431
1432fn promote_legacy_resource_content(
1438 resource: LegacyResourceContent,
1439) -> McpResult<EmbeddedResourceContents> {
1440 let (uri, content, mime_type, additional) = match resource {
1441 LegacyResourceContent::Text {
1442 uri,
1443 text,
1444 mime_type,
1445 additional,
1446 } => (
1447 uri,
1448 LegacyEmbeddedContent::Text(text),
1449 mime_type,
1450 additional,
1451 ),
1452 LegacyResourceContent::Blob {
1453 uri,
1454 blob,
1455 mime_type,
1456 additional,
1457 } => (
1458 uri,
1459 LegacyEmbeddedContent::Blob(blob),
1460 mime_type,
1461 additional,
1462 ),
1463 };
1464 let uri = AbsoluteUri::parse(uri).map_err(|error| {
1465 McpError::internal_error(format!(
1466 "legacy resource content cannot be projected into the final result: {error}",
1467 ))
1468 })?;
1469
1470 match content {
1471 LegacyEmbeddedContent::Text(text) => Ok(EmbeddedResourceContents::Text {
1472 uri,
1473 text,
1474 mime_type,
1475 meta: None,
1476 additional,
1477 }),
1478 LegacyEmbeddedContent::Blob(blob) => Ok(EmbeddedResourceContents::Blob {
1479 uri,
1480 blob,
1481 mime_type,
1482 meta: None,
1483 additional,
1484 }),
1485 }
1486}
1487
1488enum LegacyEmbeddedContent {
1489 Text(String),
1490 Blob(String),
1491}
1492
1493fn legacy_read_resource_params(params: FinalReadResourceParams) -> ReadResourceParams {
1494 ReadResourceParams {
1495 uri: params.uri.as_str().to_owned(),
1496 meta: None,
1497 }
1498}
1499
1500fn encode_cursor_offset(offset: usize) -> String {
1501 let payload = serde_json::json!({ "offset": offset });
1502 let bytes = serde_json::to_vec(&payload).expect("cursor state must serialize");
1503 BASE64_STANDARD.encode(bytes)
1504}
1505
1506fn encode_final_catalog_cursor(
1507 catalog: FinalCatalogKind,
1508 revision: u64,
1509 query: &FinalCatalogQuery,
1510 offset: usize,
1511) -> String {
1512 let offset = u64::try_from(offset).expect("usize cursor offsets always fit u64");
1513 let payload = FinalCatalogCursor {
1514 catalog,
1515 revision,
1516 query: query.clone(),
1517 offset,
1518 };
1519 let bytes = serde_json::to_vec(&payload).expect("final catalog cursor must serialize");
1520 BASE64_STANDARD.encode(bytes)
1521}
1522
1523fn page_final_catalog<T: Clone>(
1528 items: Vec<T>,
1529 cursor: Option<&str>,
1530 page_size: Option<usize>,
1531 catalog: FinalCatalogKind,
1532 revision: u64,
1533 query: &FinalCatalogQuery,
1534) -> McpResult<(Vec<T>, Option<String>)> {
1535 let offset = decode_final_catalog_cursor_offset(cursor, catalog, revision, query, items.len())?;
1536 let Some(page_size) = page_size else {
1537 return Ok((items, None));
1538 };
1539 let end = offset.saturating_add(page_size).min(items.len());
1540 Ok((
1541 items.get(offset..end).unwrap_or_default().to_vec(),
1542 (end < items.len()).then(|| encode_final_catalog_cursor(catalog, revision, query, end)),
1543 ))
1544}
1545
1546const SANITIZED_HANDLER_PANIC_MESSAGE: &str = "Internal server error";
1547static NEXT_HANDLER_INCIDENT_ID: AtomicU64 = AtomicU64::new(1);
1548
1549const LOG_LABEL_HASH_INPUT_LIMIT: usize = 4 * 1024;
1556const LOG_LABEL_DIGEST_PREFIX_BYTES: usize = 8;
1557
1558#[derive(Clone, Copy)]
1559struct SafeLogLabel {
1560 byte_len: usize,
1561 hashed_bytes: usize,
1562 digest_prefix: [u8; LOG_LABEL_DIGEST_PREFIX_BYTES],
1563}
1564
1565impl std::fmt::Display for SafeLogLabel {
1566 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1567 write!(f, "bytes={},sha256_prefix=", self.byte_len)?;
1568 for byte in self.digest_prefix {
1569 write!(f, "{byte:02x}")?;
1570 }
1571 if self.hashed_bytes < self.byte_len {
1572 write!(f, ",hashed_prefix_bytes={}", self.hashed_bytes)?;
1573 }
1574 Ok(())
1575 }
1576}
1577
1578impl std::fmt::Debug for SafeLogLabel {
1579 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1580 std::fmt::Display::fmt(self, f)
1581 }
1582}
1583
1584fn safe_log_label(value: &str) -> SafeLogLabel {
1585 let bytes = value.as_bytes();
1586 let hashed_bytes = bytes.len().min(LOG_LABEL_HASH_INPUT_LIMIT);
1587 let bounded_prefix = &bytes[..hashed_bytes];
1588 let mut digest_prefix = [0_u8; LOG_LABEL_DIGEST_PREFIX_BYTES];
1589 if let Ok(digest) = sha256_bounded(bounded_prefix, LOG_LABEL_HASH_INPUT_LIMIT) {
1590 digest_prefix.copy_from_slice(&digest.as_bytes()[..LOG_LABEL_DIGEST_PREFIX_BYTES]);
1591 }
1592
1593 SafeLogLabel {
1594 byte_len: bytes.len(),
1595 hashed_bytes,
1596 digest_prefix,
1597 }
1598}
1599
1600fn duplicate_registration_error(component: &'static str, key: &str) -> McpError {
1601 McpError::invalid_request(format!(
1602 "{component} already exists; component_key={}",
1603 safe_log_label(key)
1604 ))
1605}
1606
1607fn compose_handler_budget(
1608 ambient: Budget,
1609 server_or_request: Budget,
1610 handler_timeout: Option<Duration>,
1611 now: Time,
1612) -> Budget {
1613 let inherited = ambient.meet(server_or_request);
1614 match handler_timeout {
1615 Some(timeout) if !timeout.is_zero() => inherited.tightened_by_timeout(now, timeout),
1616 Some(_) | None => inherited,
1617 }
1618}
1619
1620fn budget_error(ctx: &McpContext) -> Option<McpError> {
1621 if ctx.ensure_live().is_err() {
1622 return Some(McpError::request_cancelled());
1623 }
1624 None
1625}
1626
1627fn sanitized_handler_panic(_request_lifetime: &Cx, handler_class: &'static str) -> McpError {
1628 let incident_id = NEXT_HANDLER_INCIDENT_ID.fetch_add(1, Ordering::Relaxed);
1629 log::error!(
1630 target: "fastmcp_rust::handler",
1631 "handler terminated unexpectedly; incident_id={incident_id}; class={handler_class}; detail=panic_payload_redacted"
1632 );
1633 McpError::internal_error(SANITIZED_HANDLER_PANIC_MESSAGE)
1634}
1635
1636fn sanitized_handler_internal_error(
1637 _request_lifetime: &Cx,
1638 handler_class: &'static str,
1639) -> McpError {
1640 let incident_id = NEXT_HANDLER_INCIDENT_ID.fetch_add(1, Ordering::Relaxed);
1641 log::error!(
1642 target: "fastmcp_rust::handler",
1643 "handler returned an opaque internal failure; incident_id={incident_id}; class={handler_class}; detail=internal_error_redacted"
1644 );
1645 McpError::internal_error(SANITIZED_HANDLER_PANIC_MESSAGE)
1646}
1647
1648fn sanitize_handler_error(cx: &Cx, handler_class: &'static str, error: McpError) -> McpError {
1649 if error.code == McpErrorCode::InternalError {
1650 sanitized_handler_internal_error(cx, handler_class)
1651 } else {
1652 error
1653 }
1654}
1655
1656const fn is_framework_terminal_tool_error(code: McpErrorCode) -> bool {
1657 matches!(
1658 code,
1659 McpErrorCode::InternalError | McpErrorCode::RequestCancelled
1660 )
1661}
1662
1663fn read_handler_timeout(
1664 cx: &Cx,
1665 handler_class: &'static str,
1666 read: impl FnOnce() -> Option<Duration>,
1667) -> McpResult<Option<Duration>> {
1668 crate::catch_extension_unwind(read)
1669 .map_err(|_payload| sanitized_handler_panic(cx, handler_class))
1670}
1671
1672#[allow(
1673 dead_code,
1674 reason = "retained as the blocking dispatcher if a remaining session entry cannot yet take a request-owned child Cx"
1675)]
1676fn run_handler<'a, T>(
1677 ctx: &McpContext,
1678 budget: Budget,
1679 handler_class: &'static str,
1680 make_future: impl FnOnce() -> BoxFuture<'a, McpOutcome<T>>,
1681) -> McpResult<McpOutcome<T>> {
1682 if let Some(error) = budget_error(ctx) {
1683 return Err(error);
1684 }
1685
1686 let execution = crate::catch_extension_unwind(|| {
1687 let future = make_future();
1688 match budget.deadline {
1689 Some(deadline) => block_on(async move {
1690 asupersync::time::timeout_at(deadline, future)
1691 .await
1692 .map_err(|_elapsed| ())
1693 }),
1694 None => Ok(block_on(future)),
1695 }
1696 });
1697
1698 match execution {
1699 Err(_payload) => Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1700 Ok(Err(())) => Err(McpError::new(
1701 McpErrorCode::RequestCancelled,
1702 "Request timeout exceeded",
1703 )),
1704 Ok(Ok(outcome)) => {
1705 if budget_error(ctx).is_some() {
1706 if budget.is_past_deadline(ctx.cx().now()) {
1710 Err(McpError::new(
1711 McpErrorCode::RequestCancelled,
1712 "Request timeout exceeded",
1713 ))
1714 } else {
1715 Err(McpError::request_cancelled())
1716 }
1717 } else {
1718 Ok(outcome)
1719 }
1720 }
1721 }
1722}
1723
1724async fn run_handler_in_request<'a, T>(
1732 ctx: &'a McpContext,
1733 request_cx: &'a Cx,
1734 budget: Budget,
1735 handler_class: &'static str,
1736 make_future: impl FnOnce(&'a Cx) -> BoxFuture<'a, McpOutcome<T>>,
1737) -> McpResult<McpOutcome<T>> {
1738 if request_cx.is_cancel_requested() || budget_error(ctx).is_some() {
1739 return Err(McpError::request_cancelled());
1740 }
1741
1742 let future = crate::catch_extension_unwind(|| make_future(request_cx))
1743 .map_err(|_payload| sanitized_handler_panic(ctx.cx(), handler_class))?;
1744 let mut future = future;
1745 let poll_handler = std::future::poll_fn(|task_cx| {
1746 match crate::catch_extension_unwind(|| future.as_mut().poll(task_cx)) {
1747 Ok(Poll::Ready(outcome)) => Poll::Ready(Ok(outcome)),
1748 Ok(Poll::Pending) => Poll::Pending,
1749 Err(_payload) => Poll::Ready(Err(())),
1750 }
1751 });
1752
1753 let outcome = match budget.deadline {
1754 Some(deadline) => match asupersync::time::timeout_at(deadline, poll_handler).await {
1755 Ok(Ok(outcome)) => outcome,
1756 Ok(Err(())) => return Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1757 Err(_elapsed) => {
1758 return Err(McpError::new(
1759 McpErrorCode::RequestCancelled,
1760 "Request timeout exceeded",
1761 ));
1762 }
1763 },
1764 None => match poll_handler.await {
1765 Ok(outcome) => outcome,
1766 Err(()) => return Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1767 },
1768 };
1769
1770 if request_cx.is_cancel_requested() || budget_error(ctx).is_some() {
1771 Err(McpError::request_cancelled())
1772 } else {
1773 Ok(outcome)
1774 }
1775}
1776
1777fn derive_handler_context(
1778 request_ctx: &McpContext,
1779 progress_marker: Option<ProgressMarker>,
1780 notification_sender: Option<&NotificationSender>,
1781 bidirectional_senders: Option<&BidirectionalSenders>,
1782 protocol_era: ProtocolEra,
1783) -> McpContext {
1784 trace!(
1785 target: targets::HANDLER,
1786 "Deriving handler context for request {}",
1787 request_ctx.request_id()
1788 );
1789 let mut handler_ctx = request_ctx.clone();
1790
1791 let reuse_installed_final_reporter =
1796 matches!(protocol_era, ProtocolEra::Modern2026) && handler_ctx.has_progress_reporter();
1797
1798 if let (Some(marker), Some(sender)) = (progress_marker, notification_sender)
1799 && !reuse_installed_final_reporter
1800 {
1801 let sender = sender.clone();
1802 let reporter = match protocol_era {
1803 ProtocolEra::Legacy2024 => ProgressNotificationSender::new(marker, move |request| {
1804 sender(request);
1805 })
1806 .into_reporter(),
1807 ProtocolEra::Modern2026 => {
1808 ProgressNotificationSender::new_final(marker, move |request| {
1809 sender(request);
1810 })
1811 .into_reporter()
1812 }
1813 };
1814 handler_ctx = handler_ctx.with_progress_reporter(reporter);
1815 }
1816
1817 if let Some(sender) = notification_sender {
1818 let sender = sender.clone();
1819 handler_ctx = handler_ctx.with_log_sender(Arc::new(
1820 crate::handler::LogNotificationSender::new(move |request| {
1821 sender(request);
1822 }),
1823 ));
1824 }
1825
1826 if let Some(senders) = bidirectional_senders {
1827 if let Some(ref sampling) = senders.sampling {
1828 handler_ctx = handler_ctx.with_sampling(sampling.clone());
1829 }
1830 if let Some(ref elicitation) = senders.elicitation {
1831 handler_ctx = handler_ctx.with_elicitation(elicitation.clone());
1832 }
1833 if let Some(ref roots) = senders.roots {
1834 handler_ctx = handler_ctx.with_roots_provider(roots.clone());
1835 }
1836 }
1837
1838 handler_ctx
1839}
1840
1841fn final_progress_marker(metadata: &OpenMetadata) -> McpResult<Option<ProgressMarker>> {
1842 metadata
1843 .get("progressToken")
1844 .map(|value| {
1845 serde_json::from_value(value.clone()).map_err(|_| {
1846 McpError::invalid_params("final progressToken must be a string or integer")
1847 })
1848 })
1849 .transpose()
1850}
1851
1852pub struct Router {
1854 tools: HashMap<String, AdmittedToolRegistration>,
1855 tool_order: Vec<String>,
1856 completion_handler: Option<BoxedCompletionHandler>,
1857 default_final_completion_enabled: bool,
1859 legacy_prompt_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1861 legacy_resource_template_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1863 final_prompt_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1865 final_resource_template_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1867 resources: HashMap<String, BoxedResourceHandler>,
1868 final_only_resources: HashSet<String>,
1870 final_resources: HashMap<String, AdmittedFinalResourceRegistration>,
1872 resource_order: Vec<String>,
1873 prompts: HashMap<String, BoxedPromptHandler>,
1874 final_only_prompts: HashSet<String>,
1876 final_prompts: HashMap<String, AdmittedFinalPromptRegistration>,
1878 prompt_order: Vec<String>,
1879 resource_templates: HashMap<String, ResourceTemplateEntry>,
1880 resource_template_order: Vec<String>,
1881 sorted_template_keys: Vec<String>,
1884 strict_input_validation: bool,
1886 list_page_size: Option<usize>,
1891 final_catalog_revision: u64,
1896 final_cache_hints: FinalCacheHintPolicy,
1898 #[cfg(feature = "tasks")]
1901 final_task_runtime: Option<FinalTaskRuntime>,
1902 #[cfg(all(feature = "proxy", feature = "tasks"))]
1906 final_task_relay: Option<Arc<ProxyFinalTaskRelay>>,
1907 mrtr_exchanges: Arc<MrtrExchangeRegistry>,
1910}
1911
1912impl Router {
1913 #[must_use]
1915 pub fn new() -> Self {
1916 Self {
1917 tools: HashMap::new(),
1918 tool_order: Vec::new(),
1919 completion_handler: None,
1920 default_final_completion_enabled: false,
1921 legacy_prompt_completion_handlers: HashMap::new(),
1922 legacy_resource_template_completion_handlers: HashMap::new(),
1923 final_prompt_completion_handlers: HashMap::new(),
1924 final_resource_template_completion_handlers: HashMap::new(),
1925 resources: HashMap::new(),
1926 final_only_resources: HashSet::new(),
1927 final_resources: HashMap::new(),
1928 resource_order: Vec::new(),
1929 prompts: HashMap::new(),
1930 final_only_prompts: HashSet::new(),
1931 final_prompts: HashMap::new(),
1932 prompt_order: Vec::new(),
1933 resource_templates: HashMap::new(),
1934 resource_template_order: Vec::new(),
1935 sorted_template_keys: Vec::new(),
1936 strict_input_validation: false,
1937 list_page_size: None,
1938 final_catalog_revision: 0,
1939 final_cache_hints: FinalCacheHintPolicy::default(),
1940 #[cfg(feature = "tasks")]
1941 final_task_runtime: None,
1942 #[cfg(all(feature = "proxy", feature = "tasks"))]
1943 final_task_relay: None,
1944 mrtr_exchanges: Arc::new(MrtrExchangeRegistry::new()),
1945 }
1946 }
1947
1948 #[cfg(test)]
1950 pub(crate) fn test_active_mrtr_exchange_count(&self) -> usize {
1951 self.mrtr_exchanges.active_len()
1952 }
1953
1954 #[cfg(feature = "tasks")]
1955 pub(crate) fn set_final_task_runtime(&mut self, runtime: Option<FinalTaskRuntime>) {
1956 self.final_task_runtime = runtime;
1957 }
1958
1959 #[cfg(all(feature = "proxy", feature = "tasks"))]
1960 pub(crate) fn set_final_task_relay(&mut self, relay: Option<Arc<ProxyFinalTaskRelay>>) {
1961 self.final_task_relay = relay;
1962 }
1963
1964 pub fn set_list_page_size(&mut self, page_size: Option<usize>) {
1970 self.list_page_size = page_size.filter(|n| *n > 0);
1971 }
1972
1973 fn advance_final_catalog_revision(&mut self) {
1974 self.final_catalog_revision = self
1975 .final_catalog_revision
1976 .checked_add(1)
1977 .expect("final catalog revision cannot overflow");
1978 }
1979
1980 pub fn set_final_cache_hint_policy(
1984 &mut self,
1985 list_ttl_ms: CacheTtl,
1986 resource_read_ttl_ms: CacheTtl,
1987 scope: CacheScope,
1988 ) {
1989 self.final_cache_hints = FinalCacheHintPolicy {
1990 list_ttl_ms,
1991 resource_read_ttl_ms,
1992 scope,
1993 };
1994 }
1995
1996 #[must_use]
1999 pub fn final_cache_hint_policy(&self) -> (&CacheTtl, &CacheTtl, CacheScope) {
2000 (
2001 &self.final_cache_hints.list_ttl_ms,
2002 &self.final_cache_hints.resource_read_ttl_ms,
2003 self.final_cache_hints.scope,
2004 )
2005 }
2006
2007 pub fn set_strict_input_validation(&mut self, strict: bool) {
2015 self.strict_input_validation = strict;
2016 }
2017
2018 #[must_use]
2020 pub fn strict_input_validation(&self) -> bool {
2021 self.strict_input_validation
2022 }
2023
2024 fn rebuild_sorted_template_keys(&mut self) {
2027 self.sorted_template_keys = self.resource_templates.keys().cloned().collect();
2028 self.sorted_template_keys.sort_by(|a, b| {
2029 let entry_a = &self.resource_templates[a];
2030 let entry_b = &self.resource_templates[b];
2031 let (a_literals, a_literal_segments, a_segments) = entry_a.specificity;
2032 let (b_literals, b_literal_segments, b_segments) = entry_b.specificity;
2033 b_literals
2034 .cmp(&a_literals)
2035 .then(b_literal_segments.cmp(&a_literal_segments))
2036 .then(b_segments.cmp(&a_segments))
2037 .then_with(|| a.cmp(b))
2038 });
2039 }
2040
2041 fn reject_final_template_collisions(
2045 &self,
2046 candidate: &ReversibleResourceTemplate,
2047 replacing_source: Option<&str>,
2048 ) -> McpResult<()> {
2049 for uri in self.final_resources.keys() {
2050 if candidate
2051 .match_uri(uri)
2052 .map_err(|_| McpError::invalid_params("resource template match admission failed"))?
2053 .is_some()
2054 {
2055 return Err(McpError::invalid_params(
2056 "resource template collides with an exact final resource",
2057 ));
2058 }
2059 }
2060
2061 for (source, entry) in &self.resource_templates {
2062 if replacing_source == Some(source.as_str()) {
2063 continue;
2064 }
2065 let Some(existing) = entry.matcher.as_ref() else {
2066 continue;
2067 };
2068 if reversible_templates_may_overlap(candidate, existing)? {
2069 return Err(McpError::invalid_params(
2070 "resource template collides with an admitted final resource template",
2071 ));
2072 }
2073 }
2074
2075 Ok(())
2076 }
2077
2078 fn reject_final_exact_resource_template_collisions(&self, uri: &str) -> McpResult<()> {
2081 for entry in self.resource_templates.values() {
2082 let Some(matcher) = entry.matcher.as_ref() else {
2083 continue;
2084 };
2085 if matcher
2086 .match_uri(uri)
2087 .map_err(|_| McpError::invalid_params("resource template match admission failed"))?
2088 .is_some()
2089 {
2090 return Err(McpError::invalid_params(
2091 "exact final resource collides with an admitted resource template",
2092 ));
2093 }
2094 }
2095
2096 Ok(())
2097 }
2098
2099 pub fn add_tool<H: ToolHandler + 'static>(&mut self, handler: H) -> McpResult<()> {
2105 self.add_tool_with_behavior(handler, crate::DuplicateBehavior::Replace)
2106 }
2107
2108 pub fn add_legacy_tool<H: ToolHandler + 'static>(&mut self, handler: H) -> McpResult<()> {
2116 self.add_legacy_tool_with_behavior(handler, crate::DuplicateBehavior::Replace)
2117 }
2118
2119 pub fn add_tool_with_behavior<H: ToolHandler + 'static>(
2125 &mut self,
2126 handler: H,
2127 behavior: crate::DuplicateBehavior,
2128 ) -> Result<(), McpError> {
2129 self.add_tool_registration_with_behavior(handler, behavior, true, true, false)
2130 }
2131
2132 pub(crate) fn add_final_tool_with_behavior<H: ToolHandler + 'static>(
2134 &mut self,
2135 handler: H,
2136 behavior: crate::DuplicateBehavior,
2137 ) -> Result<(), McpError> {
2138 self.add_tool_registration_with_behavior(handler, behavior, true, false, false)
2139 }
2140
2141 pub(crate) fn add_mcp_apps_tool_with_behavior<H: ToolHandler + 'static>(
2147 &mut self,
2148 handler: H,
2149 behavior: crate::DuplicateBehavior,
2150 ) -> Result<(), McpError> {
2151 self.add_tool_registration_with_behavior(handler, behavior, true, false, true)
2152 }
2153
2154 pub fn add_legacy_tool_with_behavior<H: ToolHandler + 'static>(
2161 &mut self,
2162 handler: H,
2163 behavior: crate::DuplicateBehavior,
2164 ) -> Result<(), McpError> {
2165 self.add_tool_registration_with_behavior(handler, behavior, false, true, false)
2166 }
2167
2168 fn add_tool_registration_with_behavior<H: ToolHandler + 'static>(
2169 &mut self,
2170 handler: H,
2171 behavior: crate::DuplicateBehavior,
2172 admit_final: bool,
2173 legacy_enabled: bool,
2174 allow_mcp_apps: bool,
2175 ) -> Result<(), McpError> {
2176 let def = crate::catch_extension_unwind(|| handler.definition()).map_err(|_payload| {
2177 McpError::internal_error("tool definition hook panicked during admission")
2178 })?;
2179 let name = &def.name;
2180
2181 let existed = self.tools.contains_key(name);
2182 if existed {
2183 match behavior {
2184 crate::DuplicateBehavior::Error => {
2185 return Err(duplicate_registration_error("Tool", name));
2186 }
2187 crate::DuplicateBehavior::Warn => {
2188 log::warn!(
2189 target: "fastmcp_rust::router",
2190 "tool already exists, keeping original; tool_key={}",
2191 safe_log_label(name)
2192 );
2193 return Ok(());
2194 }
2195 crate::DuplicateBehavior::Replace => {
2196 log::debug!(
2197 target: "fastmcp_rust::router",
2198 "replacing tool; tool_key={}",
2199 safe_log_label(name)
2200 );
2201 }
2203 crate::DuplicateBehavior::Ignore => {
2204 return Ok(());
2205 }
2206 }
2207 }
2208
2209 let name = def.name.clone();
2213 let admitted = if admit_final {
2214 AdmittedToolRegistration::admit(handler, def, legacy_enabled)?
2215 } else {
2216 AdmittedToolRegistration::legacy_only(handler, def)
2217 };
2218 if let Some(final_registration) = admitted.final_registration.as_ref() {
2219 self.validate_mcp_apps_tool_admission(
2220 &final_registration.final_definition,
2221 allow_mcp_apps,
2222 )?;
2223 }
2224 self.tools.insert(name.clone(), admitted);
2225 if !existed {
2226 self.tool_order.push(name);
2227 }
2228 self.advance_final_catalog_revision();
2229 Ok(())
2230 }
2231
2232 fn validate_mcp_apps_tool_admission(
2234 &self,
2235 tool: &FinalTool,
2236 allow_mcp_apps: bool,
2237 ) -> McpResult<()> {
2238 let metadata = tool.mcp_apps_metadata().map_err(|error| {
2239 McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2240 })?;
2241 if metadata.is_some() && !allow_mcp_apps {
2242 return Err(McpError::invalid_request(
2243 "MCP Apps tool metadata requires ServerBuilder::mcp_apps_tool",
2244 ));
2245 }
2246 self.validate_mcp_apps_tool_resource_binding(tool)
2247 }
2248
2249 fn validate_mcp_apps_tool_resource_binding(&self, tool: &FinalTool) -> McpResult<()> {
2252 let binding = tool.mcp_apps_resource_binding().map_err(|error| {
2253 McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2254 })?;
2255 let Some(binding) = binding else {
2256 return Ok(());
2257 };
2258 let resource = self
2259 .final_resources
2260 .get(binding.resource_uri.as_str())
2261 .ok_or_else(|| {
2262 McpError::invalid_request(format!(
2263 "MCP Apps UI resource is not registered: {}",
2264 binding.resource_uri.as_str()
2265 ))
2266 })?;
2267 binding
2268 .validate_resource(&resource.definition)
2269 .map_err(|error| {
2270 McpError::invalid_request(format!("invalid MCP Apps UI resource binding: {error}"))
2271 })
2272 }
2273
2274 fn validate_mcp_apps_resource_bindings(&self, resource: &FinalResource) -> McpResult<()> {
2277 for tool in self.tools.values() {
2278 let Some(final_registration) = tool.final_registration.as_ref() else {
2279 continue;
2280 };
2281 let binding = final_registration
2282 .final_definition
2283 .mcp_apps_resource_binding()
2284 .map_err(|error| {
2285 McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2286 })?;
2287 if let Some(binding) = binding
2288 && binding.resource_uri == resource.uri
2289 {
2290 binding.validate_resource(resource).map_err(|error| {
2291 McpError::invalid_request(format!(
2292 "invalid MCP Apps UI resource replacement: {error}"
2293 ))
2294 })?;
2295 }
2296 }
2297 Ok(())
2298 }
2299
2300 fn validate_mcp_apps_ui_resource_admission(
2304 &self,
2305 resource: &FinalResource,
2306 allow_mcp_apps: bool,
2307 ) -> McpResult<()> {
2308 let requires_mcp_apps = Self::mcp_apps_ui_resource_requires_special_admission(resource)?;
2309 if !requires_mcp_apps {
2310 return Ok(());
2311 }
2312 if !allow_mcp_apps {
2313 return Err(McpError::invalid_request(
2314 "MCP Apps UI resources require ServerBuilder::mcp_apps_ui_resource",
2315 ));
2316 }
2317 let is_apps_html = resource.uri.as_str().starts_with("ui://")
2318 && resource.mime_type.as_deref() == Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE);
2319 if !is_apps_html {
2320 return Err(McpError::invalid_request(
2321 "MCP Apps UI resources must use a ui:// URI and the MCP Apps HTML MIME type",
2322 ));
2323 }
2324 Ok(())
2325 }
2326
2327 fn mcp_apps_ui_resource_requires_special_admission(
2328 resource: &FinalResource,
2329 ) -> McpResult<bool> {
2330 let metadata = resource.mcp_apps_metadata().map_err(|error| {
2331 McpError::invalid_request(format!("invalid MCP Apps resource metadata: {error}"))
2332 })?;
2333 let is_apps_html = resource.uri.as_str().starts_with("ui://")
2334 && resource.mime_type.as_deref() == Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE);
2335 Ok(is_apps_html || metadata.is_some())
2336 }
2337
2338 #[must_use]
2344 pub(crate) fn has_mcp_apps_bound_components(&self) -> bool {
2345 self.final_resources.values().any(|registration| {
2346 Self::mcp_apps_ui_resource_requires_special_admission(®istration.definition)
2347 .unwrap_or(true)
2348 }) || self.tools.values().any(|registration| {
2349 registration
2350 .final_registration
2351 .as_ref()
2352 .is_some_and(|final_registration| {
2353 final_registration
2354 .final_definition
2355 .mcp_apps_metadata()
2356 .map_or(true, |metadata| metadata.is_some())
2357 })
2358 })
2359 }
2360
2361 pub fn add_completion_handler<H: CompletionHandler + 'static>(&mut self, handler: H) {
2369 self.completion_handler = Some(Box::new(handler));
2370 self.default_final_completion_enabled = true;
2371 }
2372
2373 pub fn add_legacy_completion_handler<H: CompletionHandler + 'static>(&mut self, handler: H) {
2375 self.completion_handler = Some(Box::new(handler));
2376 self.default_final_completion_enabled = false;
2377 }
2378
2379 pub(crate) fn add_legacy_prompt_completion_handler<H: CompletionHandler + 'static>(
2384 &mut self,
2385 prompt_name: impl Into<String>,
2386 handler: H,
2387 ) {
2388 self.legacy_prompt_completion_handlers
2389 .insert(prompt_name.into(), Box::new(handler));
2390 }
2391
2392 pub(crate) fn add_legacy_resource_template_completion_handler<
2397 H: CompletionHandler + 'static,
2398 >(
2399 &mut self,
2400 uri_template: impl Into<String>,
2401 handler: H,
2402 ) {
2403 self.legacy_resource_template_completion_handlers
2404 .insert(uri_template.into(), Box::new(handler));
2405 }
2406
2407 pub fn add_prompt_completion_handler<H: CompletionHandler + 'static>(
2412 &mut self,
2413 prompt_name: impl Into<String>,
2414 handler: H,
2415 ) {
2416 self.final_prompt_completion_handlers
2417 .insert(prompt_name.into(), Box::new(handler));
2418 }
2419
2420 pub fn add_resource_template_completion_handler<H: CompletionHandler + 'static>(
2425 &mut self,
2426 uri_template: impl Into<String>,
2427 handler: H,
2428 ) {
2429 self.final_resource_template_completion_handlers
2430 .insert(uri_template.into(), Box::new(handler));
2431 }
2432
2433 #[must_use]
2435 pub fn has_completion_handler(&self) -> bool {
2436 self.completion_handler.is_some()
2437 || !self.final_prompt_completion_handlers.is_empty()
2438 || !self.final_resource_template_completion_handlers.is_empty()
2439 }
2440
2441 fn has_final_completion_handler(&self) -> bool {
2442 self.default_final_completion_enabled || self.has_admitted_final_completion_provider()
2443 }
2444
2445 fn has_admitted_final_completion_provider(&self) -> bool {
2446 self.final_prompt_completion_handlers
2447 .keys()
2448 .any(|name| self.final_prompts.contains_key(name))
2449 || self
2450 .final_resource_template_completion_handlers
2451 .keys()
2452 .any(|uri| {
2453 self.resource_templates
2454 .get(uri)
2455 .is_some_and(|entry| entry.final_definition.is_some())
2456 })
2457 }
2458
2459 pub fn add_resource<H: ResourceHandler + 'static>(&mut self, handler: H) {
2465 if let Err(error) = self.add_resource_registration_with_behavior(
2466 handler,
2467 crate::DuplicateBehavior::Replace,
2468 true,
2469 true,
2470 false,
2471 ) {
2472 log::warn!(
2473 target: "fastmcp_rust::router",
2474 "rejected resource registration; code={:?}",
2475 error.code
2476 );
2477 }
2478 }
2479
2480 pub fn add_legacy_resource<H: ResourceHandler + 'static>(&mut self, handler: H) {
2482 if let Err(error) = self.add_resource_registration_with_behavior(
2483 handler,
2484 crate::DuplicateBehavior::Replace,
2485 false,
2486 true,
2487 false,
2488 ) {
2489 log::warn!(
2490 target: "fastmcp_rust::router",
2491 "rejected exact-2024-only resource registration; code={:?}",
2492 error.code
2493 );
2494 }
2495 }
2496
2497 pub fn add_resource_with_behavior<H: ResourceHandler + 'static>(
2502 &mut self,
2503 handler: H,
2504 behavior: crate::DuplicateBehavior,
2505 ) -> Result<(), McpError> {
2506 self.add_resource_registration_with_behavior(handler, behavior, true, true, false)
2507 }
2508
2509 pub(crate) fn add_final_resource_with_behavior<H: ResourceHandler + 'static>(
2511 &mut self,
2512 handler: H,
2513 behavior: crate::DuplicateBehavior,
2514 ) -> Result<(), McpError> {
2515 self.add_resource_registration_with_behavior(handler, behavior, true, false, false)
2516 }
2517
2518 pub(crate) fn add_mcp_apps_ui_resource_with_behavior<H: ResourceHandler + 'static>(
2520 &mut self,
2521 handler: H,
2522 behavior: crate::DuplicateBehavior,
2523 ) -> Result<(), McpError> {
2524 self.add_resource_registration_with_behavior(handler, behavior, true, false, true)
2525 }
2526
2527 pub fn add_legacy_resource_with_behavior<H: ResourceHandler + 'static>(
2529 &mut self,
2530 handler: H,
2531 behavior: crate::DuplicateBehavior,
2532 ) -> Result<(), McpError> {
2533 self.add_resource_registration_with_behavior(handler, behavior, false, true, false)
2534 }
2535
2536 fn add_resource_registration_with_behavior<H: ResourceHandler + 'static>(
2537 &mut self,
2538 handler: H,
2539 behavior: crate::DuplicateBehavior,
2540 admit_final: bool,
2541 legacy_enabled: bool,
2542 allow_mcp_apps: bool,
2543 ) -> Result<(), McpError> {
2544 let (template, def) =
2545 crate::catch_extension_unwind(|| (handler.template(), handler.definition())).map_err(
2546 |_payload| {
2547 McpError::internal_error("resource definition hook panicked during admission")
2548 },
2549 )?;
2550
2551 let key = match template.as_ref() {
2553 Some(template) => template.uri_template.clone(),
2554 None => def.uri.clone(),
2555 };
2556
2557 let exists = if template.is_some() {
2558 self.resource_templates.contains_key(&key)
2559 } else {
2560 self.resources.contains_key(&key)
2561 };
2562
2563 if exists {
2564 match behavior {
2565 crate::DuplicateBehavior::Error => {
2566 return Err(duplicate_registration_error("Resource", &key));
2567 }
2568 crate::DuplicateBehavior::Warn => {
2569 log::warn!(
2570 target: "fastmcp_rust::router",
2571 "resource already exists, keeping original; resource_key={}",
2572 safe_log_label(&key)
2573 );
2574 return Ok(());
2575 }
2576 crate::DuplicateBehavior::Replace => {
2577 log::debug!(
2578 target: "fastmcp_rust::router",
2579 "replacing resource; resource_key={}",
2580 safe_log_label(&key)
2581 );
2582 }
2584 crate::DuplicateBehavior::Ignore => {
2585 return Ok(());
2586 }
2587 }
2588 }
2589
2590 if let Some(template) = template {
2591 let legacy_matcher = if legacy_enabled {
2592 match admit_legacy_resource_template(&template.uri_template) {
2593 Ok(matcher) => Some(matcher),
2594 Err(error) if !admit_final => return Err(error),
2595 Err(_) => None,
2596 }
2597 } else {
2598 None
2599 };
2600 let (matcher, specificity) = if admit_final {
2601 let (matcher, specificity) = admit_resource_template(&template.uri_template)?;
2602 self.reject_final_template_collisions(
2603 &matcher,
2604 Some(template.uri_template.as_str()),
2605 )?;
2606 (Some(matcher), specificity)
2607 } else {
2608 let matcher = legacy_matcher.as_ref().ok_or_else(|| {
2609 McpError::internal_error(
2610 "exact-2024 resource template admission lost its matcher",
2611 )
2612 })?;
2613 (None, matcher.specificity())
2614 };
2615 let uri_use_policy = if admit_final {
2616 ResourceUriUsePolicy::from_client_direct_https(
2617 crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
2618 |_payload| {
2619 McpError::internal_error(
2620 "resource URI-use policy hook panicked during admission",
2621 )
2622 },
2623 )?,
2624 )
2625 } else {
2626 ResourceUriUsePolicy::server_mediated()
2627 };
2628 let final_definition = admit_final
2629 .then(|| {
2630 admit_final_resource_template_definition(
2631 Some(&handler),
2632 &template,
2633 uri_use_policy,
2634 )
2635 })
2636 .transpose()?;
2637 let boxed: BoxedResourceHandler = Box::new(handler);
2638 let is_new = !self.resource_templates.contains_key(&template.uri_template);
2639 if !is_new {
2640 self.legacy_resource_template_completion_handlers
2641 .remove(&template.uri_template);
2642 self.final_resource_template_completion_handlers
2643 .remove(&template.uri_template);
2644 }
2645 let entry = ResourceTemplateEntry {
2646 matcher,
2647 specificity,
2648 template: template.clone(),
2649 handler: Some(boxed),
2650 final_definition,
2651 uri_use_policy,
2652 legacy_enabled: legacy_matcher.is_some(),
2653 legacy_matcher,
2654 };
2655 self.resource_templates
2656 .insert(template.uri_template.clone(), entry);
2657 if is_new {
2658 self.resource_template_order.push(template.uri_template);
2659 }
2660 self.rebuild_sorted_template_keys();
2661 } else {
2662 let uri_use_policy = if admit_final {
2663 ResourceUriUsePolicy::from_client_direct_https(
2664 crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
2665 |_payload| {
2666 McpError::internal_error(
2667 "resource URI-use policy hook panicked during admission",
2668 )
2669 },
2670 )?,
2671 )
2672 } else {
2673 ResourceUriUsePolicy::server_mediated()
2674 };
2675 let final_definition = admit_final
2676 .then(|| admit_final_resource_definition(&handler, &def, uri_use_policy))
2677 .transpose()?;
2678 if let Some(final_definition) = final_definition.as_ref() {
2679 self.validate_mcp_apps_ui_resource_admission(final_definition, allow_mcp_apps)?;
2680 self.validate_mcp_apps_resource_bindings(final_definition)?;
2681 self.reject_final_exact_resource_template_collisions(&def.uri)?;
2682 }
2683 let boxed: BoxedResourceHandler = Box::new(handler);
2684 let is_new = !self.resources.contains_key(&def.uri);
2685 self.resources.insert(def.uri.clone(), boxed);
2686 if legacy_enabled {
2687 self.final_only_resources.remove(&def.uri);
2688 } else {
2689 self.final_only_resources.insert(def.uri.clone());
2690 }
2691 match final_definition {
2692 Some(final_definition) => {
2693 self.final_resources.insert(
2694 def.uri.clone(),
2695 AdmittedFinalResourceRegistration {
2696 definition: final_definition,
2697 tags: def.tags.clone(),
2698 uri_use_policy,
2699 },
2700 );
2701 }
2702 None => {
2703 self.final_resources.remove(&def.uri);
2704 }
2705 }
2706 if is_new {
2707 self.resource_order.push(def.uri);
2708 }
2709 }
2710
2711 self.advance_final_catalog_revision();
2712 Ok(())
2713 }
2714
2715 pub fn add_resource_template(&mut self, template: ResourceTemplate) {
2722 let key = template.uri_template.clone();
2723 if let Err(error) =
2724 self.add_resource_template_with_behavior(template, crate::DuplicateBehavior::Replace)
2725 {
2726 log::warn!(
2727 target: "fastmcp_rust::router",
2728 "rejected resource template definition; template_key={}; code={:?}",
2729 safe_log_label(&key),
2730 error.code
2731 );
2732 }
2733 }
2734
2735 pub fn add_legacy_resource_template(&mut self, template: ResourceTemplate) {
2737 let key = template.uri_template.clone();
2738 if let Err(error) = self.add_resource_template_registration_with_behavior(
2739 template,
2740 crate::DuplicateBehavior::Replace,
2741 false,
2742 ) {
2743 log::warn!(
2744 target: "fastmcp_rust::router",
2745 "rejected exact-2024-only resource template; template_key={}; code={:?}",
2746 safe_log_label(&key),
2747 error.code
2748 );
2749 }
2750 }
2751
2752 pub fn add_resource_template_with_behavior(
2758 &mut self,
2759 template: ResourceTemplate,
2760 behavior: crate::DuplicateBehavior,
2761 ) -> Result<(), McpError> {
2762 self.add_resource_template_registration_with_behavior(template, behavior, true)
2763 }
2764
2765 pub fn add_legacy_resource_template_with_behavior(
2767 &mut self,
2768 template: ResourceTemplate,
2769 behavior: crate::DuplicateBehavior,
2770 ) -> Result<(), McpError> {
2771 self.add_resource_template_registration_with_behavior(template, behavior, false)
2772 }
2773
2774 fn add_resource_template_registration_with_behavior(
2775 &mut self,
2776 template: ResourceTemplate,
2777 behavior: crate::DuplicateBehavior,
2778 admit_final: bool,
2779 ) -> Result<(), McpError> {
2780 let key = template.uri_template.clone();
2781 let existed = self.resource_templates.contains_key(&key);
2782
2783 if existed {
2784 match behavior {
2785 crate::DuplicateBehavior::Error => {
2786 return Err(duplicate_registration_error("Resource template", &key));
2787 }
2788 crate::DuplicateBehavior::Warn => {
2789 log::warn!(
2790 target: "fastmcp_rust::router",
2791 "resource template already exists, keeping original; template_key={}",
2792 safe_log_label(&key)
2793 );
2794 return Ok(());
2795 }
2796 crate::DuplicateBehavior::Replace => {
2797 log::debug!(
2798 target: "fastmcp_rust::router",
2799 "replacing resource template definition; template_key={}",
2800 safe_log_label(&key)
2801 );
2802 }
2803 crate::DuplicateBehavior::Ignore => return Ok(()),
2804 }
2805 }
2806
2807 let legacy_matcher = match admit_legacy_resource_template(&key) {
2808 Ok(matcher) => Some(matcher),
2809 Err(error) if !admit_final => return Err(error),
2810 Err(_) => None,
2811 };
2812 let (matcher, specificity) = if admit_final {
2813 let (matcher, specificity) = admit_resource_template(&key)?;
2814 self.reject_final_template_collisions(&matcher, Some(key.as_str()))?;
2815 (Some(matcher), specificity)
2816 } else {
2817 let matcher = legacy_matcher.as_ref().ok_or_else(|| {
2818 McpError::internal_error("exact-2024 resource template admission lost its matcher")
2819 })?;
2820 (None, matcher.specificity())
2821 };
2822 let uri_use_policy = ResourceUriUsePolicy::server_mediated();
2823 let final_definition = admit_final
2824 .then(|| {
2825 admit_final_resource_template_definition::<dyn ResourceHandler>(
2826 None,
2827 &template,
2828 uri_use_policy,
2829 )
2830 })
2831 .transpose()?;
2832 if existed {
2833 self.legacy_resource_template_completion_handlers
2834 .remove(&key);
2835 self.final_resource_template_completion_handlers
2836 .remove(&key);
2837 }
2838 let needs_rebuild = match self.resource_templates.get_mut(&key) {
2839 Some(existing) => {
2840 existing.template = template;
2841 existing.matcher = matcher;
2842 existing.specificity = specificity;
2843 existing.final_definition = final_definition;
2844 existing.uri_use_policy = uri_use_policy;
2845 existing.legacy_enabled = legacy_matcher.is_some();
2846 existing.legacy_matcher = legacy_matcher;
2847 false }
2849 None => {
2850 self.resource_templates.insert(
2851 key.clone(),
2852 ResourceTemplateEntry {
2853 matcher,
2854 specificity,
2855 template,
2856 handler: None,
2857 final_definition,
2858 uri_use_policy,
2859 legacy_enabled: legacy_matcher.is_some(),
2860 legacy_matcher,
2861 },
2862 );
2863 true }
2865 };
2866 if needs_rebuild {
2867 self.resource_template_order.push(key);
2868 self.rebuild_sorted_template_keys();
2869 }
2870 self.advance_final_catalog_revision();
2871 Ok(())
2872 }
2873
2874 pub fn add_prompt<H: PromptHandler + 'static>(&mut self, handler: H) {
2880 if let Err(error) = self.add_prompt_registration_with_behavior(
2881 handler,
2882 crate::DuplicateBehavior::Replace,
2883 true,
2884 true,
2885 ) {
2886 log::warn!(
2887 target: "fastmcp_rust::router",
2888 "rejected prompt registration; code={:?}",
2889 error.code
2890 );
2891 }
2892 }
2893
2894 pub fn add_legacy_prompt<H: PromptHandler + 'static>(&mut self, handler: H) {
2896 if let Err(error) = self.add_prompt_registration_with_behavior(
2897 handler,
2898 crate::DuplicateBehavior::Replace,
2899 false,
2900 true,
2901 ) {
2902 log::warn!(
2903 target: "fastmcp_rust::router",
2904 "rejected exact-2024-only prompt registration; code={:?}",
2905 error.code
2906 );
2907 }
2908 }
2909
2910 pub fn add_prompt_with_behavior<H: PromptHandler + 'static>(
2915 &mut self,
2916 handler: H,
2917 behavior: crate::DuplicateBehavior,
2918 ) -> Result<(), McpError> {
2919 self.add_prompt_registration_with_behavior(handler, behavior, true, true)
2920 }
2921
2922 pub(crate) fn add_final_prompt_with_behavior<H: PromptHandler + 'static>(
2924 &mut self,
2925 handler: H,
2926 behavior: crate::DuplicateBehavior,
2927 ) -> Result<(), McpError> {
2928 self.add_prompt_registration_with_behavior(handler, behavior, true, false)
2929 }
2930
2931 pub fn add_legacy_prompt_with_behavior<H: PromptHandler + 'static>(
2933 &mut self,
2934 handler: H,
2935 behavior: crate::DuplicateBehavior,
2936 ) -> Result<(), McpError> {
2937 self.add_prompt_registration_with_behavior(handler, behavior, false, true)
2938 }
2939
2940 fn add_prompt_registration_with_behavior<H: PromptHandler + 'static>(
2941 &mut self,
2942 handler: H,
2943 behavior: crate::DuplicateBehavior,
2944 admit_final: bool,
2945 legacy_enabled: bool,
2946 ) -> Result<(), McpError> {
2947 let def = crate::catch_extension_unwind(|| handler.definition()).map_err(|_payload| {
2948 McpError::internal_error("prompt definition hook panicked during admission")
2949 })?;
2950 let name = &def.name;
2951
2952 let existed = self.prompts.contains_key(name);
2953 if existed {
2954 match behavior {
2955 crate::DuplicateBehavior::Error => {
2956 return Err(duplicate_registration_error("Prompt", name));
2957 }
2958 crate::DuplicateBehavior::Warn => {
2959 log::warn!(
2960 target: "fastmcp_rust::router",
2961 "prompt already exists, keeping original; prompt_key={}",
2962 safe_log_label(name)
2963 );
2964 return Ok(());
2965 }
2966 crate::DuplicateBehavior::Replace => {
2967 log::debug!(
2968 target: "fastmcp_rust::router",
2969 "replacing prompt; prompt_key={}",
2970 safe_log_label(name)
2971 );
2972 }
2974 crate::DuplicateBehavior::Ignore => {
2975 return Ok(());
2976 }
2977 }
2978 }
2979
2980 let uri_use_policy = if admit_final {
2981 ResourceUriUsePolicy::from_client_direct_https(
2982 crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
2983 |_payload| {
2984 McpError::internal_error(
2985 "prompt URI-use policy hook panicked during admission",
2986 )
2987 },
2988 )?,
2989 )
2990 } else {
2991 ResourceUriUsePolicy::server_mediated()
2992 };
2993 let final_definition = admit_final
2994 .then(|| admit_final_prompt_definition(&handler, &def))
2995 .transpose()?;
2996 if existed {
2997 self.legacy_prompt_completion_handlers.remove(&def.name);
2998 self.final_prompt_completion_handlers.remove(&def.name);
2999 }
3000 self.prompts.insert(def.name.clone(), Box::new(handler));
3001 if legacy_enabled {
3002 self.final_only_prompts.remove(&def.name);
3003 } else {
3004 self.final_only_prompts.insert(def.name.clone());
3005 }
3006 match final_definition {
3007 Some(final_definition) => {
3008 self.final_prompts.insert(
3009 def.name.clone(),
3010 AdmittedFinalPromptRegistration {
3011 definition: final_definition,
3012 tags: def.tags.clone(),
3013 uri_use_policy,
3014 },
3015 );
3016 }
3017 None => {
3018 self.final_prompts.remove(&def.name);
3019 }
3020 }
3021 if !existed {
3022 self.prompt_order.push(def.name);
3023 }
3024 self.advance_final_catalog_revision();
3025 Ok(())
3026 }
3027
3028 #[must_use]
3030 pub fn tools(&self) -> Vec<Tool> {
3031 self.tool_order
3032 .iter()
3033 .filter_map(|name| self.tools.get(name))
3034 .filter(|entry| entry.legacy_enabled)
3035 .map(|entry| entry.definition.clone())
3036 .collect()
3037 }
3038
3039 #[must_use]
3044 pub fn tools_filtered(
3045 &self,
3046 session_state: Option<&SessionState>,
3047 tag_filters: Option<&TagFilters<'_>>,
3048 ) -> Vec<Tool> {
3049 self.tool_order
3050 .iter()
3051 .filter_map(|name| self.tools.get(name))
3052 .filter_map(|entry| {
3053 if !entry.legacy_enabled {
3054 return None;
3055 }
3056 let def = &entry.definition;
3057 if let Some(state) = session_state {
3059 if !state.is_tool_enabled(&def.name) {
3060 return None;
3061 }
3062 }
3063 if let Some(filters) = tag_filters {
3065 if !filters.matches(&def.tags) {
3066 return None;
3067 }
3068 }
3069 Some(def.clone())
3070 })
3071 .collect()
3072 }
3073
3074 #[must_use]
3076 pub fn resources(&self) -> Vec<Resource> {
3077 self.resource_order
3078 .iter()
3079 .filter_map(|uri| self.resources.get(uri))
3080 .filter(|handler| {
3081 let uri = handler.definition().uri;
3082 !self.final_only_resources.contains(&uri)
3083 })
3084 .map(|h| h.definition())
3085 .collect()
3086 }
3087
3088 #[must_use]
3093 pub fn resources_filtered(
3094 &self,
3095 session_state: Option<&SessionState>,
3096 tag_filters: Option<&TagFilters<'_>>,
3097 ) -> Vec<Resource> {
3098 self.resource_order
3099 .iter()
3100 .filter_map(|uri| self.resources.get(uri))
3101 .filter_map(|h| {
3102 let def = h.definition();
3103 if self.final_only_resources.contains(&def.uri) {
3104 return None;
3105 }
3106 if let Some(state) = session_state {
3108 if !state.is_resource_enabled(&def.uri) {
3109 return None;
3110 }
3111 }
3112 if let Some(filters) = tag_filters {
3114 if !filters.matches(&def.tags) {
3115 return None;
3116 }
3117 }
3118 Some(def)
3119 })
3120 .collect()
3121 }
3122
3123 #[must_use]
3125 pub fn resource_templates(&self) -> Vec<ResourceTemplate> {
3126 self.resource_template_order
3127 .iter()
3128 .filter_map(|t| self.resource_templates.get(t))
3129 .filter(|entry| entry.legacy_enabled)
3130 .map(|entry| entry.template.clone())
3131 .collect()
3132 }
3133
3134 #[must_use]
3139 pub fn resource_templates_filtered(
3140 &self,
3141 session_state: Option<&SessionState>,
3142 tag_filters: Option<&TagFilters<'_>>,
3143 ) -> Vec<ResourceTemplate> {
3144 self.resource_template_order
3145 .iter()
3146 .filter_map(|t| self.resource_templates.get(t))
3147 .filter_map(|entry| {
3148 if !entry.legacy_enabled {
3149 return None;
3150 }
3151 if let Some(state) = session_state {
3153 if !state.is_resource_enabled(&entry.template.uri_template) {
3154 return None;
3155 }
3156 }
3157 if let Some(filters) = tag_filters {
3159 if !filters.matches(&entry.template.tags) {
3160 return None;
3161 }
3162 }
3163 Some(entry.template.clone())
3164 })
3165 .collect()
3166 }
3167
3168 #[must_use]
3170 pub fn prompts(&self) -> Vec<Prompt> {
3171 self.prompt_order
3172 .iter()
3173 .filter_map(|name| self.prompts.get(name))
3174 .filter(|handler| {
3175 let name = handler.definition().name;
3176 !self.final_only_prompts.contains(&name)
3177 })
3178 .map(|h| h.definition())
3179 .collect()
3180 }
3181
3182 #[must_use]
3187 pub fn prompts_filtered(
3188 &self,
3189 session_state: Option<&SessionState>,
3190 tag_filters: Option<&TagFilters<'_>>,
3191 ) -> Vec<Prompt> {
3192 self.prompt_order
3193 .iter()
3194 .filter_map(|name| self.prompts.get(name))
3195 .filter_map(|h| {
3196 let def = h.definition();
3197 if self.final_only_prompts.contains(&def.name) {
3198 return None;
3199 }
3200 if let Some(state) = session_state {
3202 if !state.is_prompt_enabled(&def.name) {
3203 return None;
3204 }
3205 }
3206 if let Some(filters) = tag_filters {
3208 if !filters.matches(&def.tags) {
3209 return None;
3210 }
3211 }
3212 Some(def)
3213 })
3214 .collect()
3215 }
3216
3217 #[must_use]
3219 pub fn tools_count(&self) -> usize {
3220 self.tools.len()
3221 }
3222
3223 #[must_use]
3225 pub fn resources_count(&self) -> usize {
3226 self.resources.len()
3227 }
3228
3229 #[must_use]
3231 pub fn resource_templates_count(&self) -> usize {
3232 self.resource_templates.len()
3233 }
3234
3235 #[must_use]
3237 pub fn prompts_count(&self) -> usize {
3238 self.prompts.len()
3239 }
3240
3241 #[must_use]
3250 pub(crate) fn server_discovery_behavior_registry(&self) -> ServerBehaviorRegistry {
3251 let mut behaviors = Vec::with_capacity(11);
3252 behaviors.push(ServerBehavior::SubscriptionsListen);
3253 if self.has_final_completion_handler() {
3254 behaviors.push(ServerBehavior::CompletionComplete);
3255 }
3256 if self
3257 .tool_order
3258 .iter()
3259 .filter_map(|name| self.tools.get(name))
3260 .any(|entry| entry.final_registration.is_some())
3261 {
3262 behaviors.push(ServerBehavior::ToolsList);
3263 behaviors.push(ServerBehavior::ToolsListChangedNotification);
3264 }
3265 if !self.final_resources.is_empty()
3266 || self
3267 .resource_template_order
3268 .iter()
3269 .filter_map(|key| self.resource_templates.get(key))
3270 .any(|entry| entry.final_definition.is_some())
3271 {
3272 behaviors.push(ServerBehavior::ResourcesList);
3273 behaviors.push(ServerBehavior::ResourcesListChangedNotification);
3274 }
3275 if !self.final_resources.is_empty() {
3276 behaviors.push(ServerBehavior::ResourceUpdateDelivery);
3277 }
3278 if !self.final_prompts.is_empty() {
3279 behaviors.push(ServerBehavior::PromptsList);
3280 behaviors.push(ServerBehavior::PromptsListChangedNotification);
3281 }
3282 ServerBehaviorRegistry::from_behaviors(behaviors)
3283 }
3284
3285 #[must_use]
3287 pub fn get_tool(&self, name: &str) -> Option<&BoxedToolHandler> {
3288 self.tools.get(name).map(|entry| &entry.handler)
3289 }
3290
3291 #[must_use]
3293 pub fn get_resource(&self, uri: &str) -> Option<&BoxedResourceHandler> {
3294 self.resources.get(uri)
3295 }
3296
3297 #[must_use]
3299 pub fn get_resource_template(&self, uri_template: &str) -> Option<&ResourceTemplate> {
3300 self.resource_templates
3301 .get(uri_template)
3302 .map(|entry| &entry.template)
3303 }
3304
3305 #[must_use]
3307 pub fn resource_exists(&self, uri: &str) -> bool {
3308 self.resolve_resource(uri).is_some()
3309 }
3310
3311 pub(crate) fn notify_resource_subscribed(&self, ctx: &McpContext, uri: &str) -> McpResult<()> {
3313 if let Some(resolved) = self.resolve_resource(uri) {
3314 resolved.handler.on_subscribe(ctx, uri)?;
3315 }
3316 Ok(())
3317 }
3318
3319 pub(crate) fn notify_resource_unsubscribed(
3321 &self,
3322 ctx: &McpContext,
3323 uri: &str,
3324 ) -> McpResult<()> {
3325 if let Some(resolved) = self.resolve_resource(uri) {
3326 resolved.handler.on_unsubscribe(ctx, uri)?;
3327 }
3328 Ok(())
3329 }
3330
3331 fn resolve_resource(&self, uri: &str) -> Option<ResolvedResource<'_>> {
3332 self.resolve_resource_for_era(uri, None)
3333 }
3334
3335 #[cfg(test)]
3340 fn resolve_tool_for_era(
3341 &self,
3342 name: &str,
3343 era: Option<ProtocolEra>,
3344 ) -> Option<&AdmittedToolRegistration> {
3345 let registration = self.tools.get(name)?;
3346 let visible = era.is_none_or(|era| match era {
3347 ProtocolEra::Modern2026 => registration.final_registration.is_some(),
3348 ProtocolEra::Legacy2024 => registration.legacy_enabled,
3349 });
3350 visible.then_some(registration)
3351 }
3352
3353 fn resolve_resource_for_era(
3360 &self,
3361 uri: &str,
3362 era: Option<ProtocolEra>,
3363 ) -> Option<ResolvedResource<'_>> {
3364 if let Some(handler) = self.resources.get(uri) {
3365 let resolved = ResolvedResource {
3366 handler,
3367 params: UriParams::new(),
3368 final_enabled: self.final_resources.contains_key(uri),
3369 legacy_enabled: !self.final_only_resources.contains(uri),
3370 uri_use_policy: self
3371 .final_resources
3372 .get(uri)
3373 .map_or_else(ResourceUriUsePolicy::server_mediated, |entry| {
3374 entry.uri_use_policy
3375 }),
3376 };
3377 if era.is_none_or(|era| resolved.is_enabled_in(era)) {
3378 return Some(resolved);
3379 }
3380 }
3381
3382 'templates: for key in &self.sorted_template_keys {
3384 let entry = &self.resource_templates[key];
3385 let Some(handler) = entry.handler.as_ref() else {
3386 continue;
3387 };
3388 let legacy_params = || {
3389 entry
3390 .legacy_matcher
3391 .as_ref()
3392 .and_then(|matcher| matcher.matches(uri))
3393 };
3394 let final_params = || {
3395 let values = entry.matcher.as_ref()?.match_uri(uri).ok()??;
3396 let mut params = UriParams::with_capacity(values.len());
3397 for (name, value) in values {
3398 let TemplateValue::Scalar(value) = value else {
3399 return None;
3400 };
3401 params.insert(name, value);
3402 }
3403 Some(params)
3404 };
3405 let Some(params) = (match era {
3406 Some(ProtocolEra::Legacy2024) => legacy_params(),
3407 Some(ProtocolEra::Modern2026) => final_params(),
3408 None => final_params().or_else(legacy_params),
3409 }) else {
3410 continue 'templates;
3411 };
3412 let resolved = ResolvedResource {
3413 handler,
3414 params,
3415 final_enabled: entry.final_definition.is_some(),
3416 legacy_enabled: entry.legacy_enabled,
3417 uri_use_policy: entry.uri_use_policy,
3418 };
3419 if era.is_none_or(|era| resolved.is_enabled_in(era)) {
3420 return Some(resolved);
3421 }
3422 }
3423
3424 None
3425 }
3426
3427 #[must_use]
3429 pub fn get_prompt(&self, name: &str) -> Option<&BoxedPromptHandler> {
3430 self.prompts.get(name)
3431 }
3432
3433 pub fn handle_initialize(
3439 &self,
3440 request_ctx: &McpContext,
3441 session: &mut Session,
3442 params: InitializeParams,
3443 instructions: Option<&str>,
3444 ) -> McpResult<InitializeResult> {
3445 if let Some(error) = budget_error(request_ctx) {
3446 return Err(error);
3447 }
3448
3449 debug!(
3450 target: targets::SESSION,
3451 "preparing session initialization; client_key={}",
3452 safe_log_label(¶ms.client_info.name)
3453 );
3454
3455 session.initialize(
3457 params.client_info,
3458 params.capabilities,
3459 PROTOCOL_VERSION.to_string(),
3460 );
3461
3462 Ok(InitializeResult {
3463 protocol_version: PROTOCOL_VERSION.to_string(),
3464 capabilities: session.server_capabilities().clone(),
3465 server_info: session.server_info().clone(),
3466 instructions: instructions.map(String::from),
3467 })
3468 }
3469
3470 pub(crate) fn dispatch_legacy_completion(
3477 &self,
3478 request_ctx: &McpContext,
3479 request: &JsonRpcRequest,
3480 ) -> McpResult<serde_json::Value> {
3481 if request.method != COMPLETION_COMPLETE {
3482 return Err(McpError::method_not_found(&request.method));
3483 }
3484
3485 let request = CoreRequest::decode(
3486 ProtocolEra::Legacy2024,
3487 COMPLETION_COMPLETE,
3488 request.params.as_ref(),
3489 )
3490 .map_err(|error| McpError::invalid_params(error.to_string()))?;
3491 let CoreRequest::Legacy(LegacyCoreRequest::Completion(params)) = request else {
3492 return Err(McpError::internal_error(
3493 "legacy completion dispatch selected another core request",
3494 ));
3495 };
3496
3497 serde_json::to_value(self.handle_completion_legacy(request_ctx, params)?)
3498 .map_err(McpError::from)
3499 }
3500
3501 pub(crate) async fn dispatch_legacy_completion_in_request(
3502 &self,
3503 request_ctx: &McpContext,
3504 request_cx: &Cx,
3505 request: &JsonRpcRequest,
3506 ) -> McpResult<serde_json::Value> {
3507 if request.method != COMPLETION_COMPLETE {
3508 return Err(McpError::method_not_found(&request.method));
3509 }
3510
3511 let request = CoreRequest::decode(
3512 ProtocolEra::Legacy2024,
3513 COMPLETION_COMPLETE,
3514 request.params.as_ref(),
3515 )
3516 .map_err(|error| McpError::invalid_params(error.to_string()))?;
3517 let CoreRequest::Legacy(LegacyCoreRequest::Completion(params)) = request else {
3518 return Err(McpError::internal_error(
3519 "legacy completion dispatch selected another core request",
3520 ));
3521 };
3522
3523 serde_json::to_value(
3524 self.handle_completion_legacy_in_request(request_ctx, request_cx, params)
3525 .await?,
3526 )
3527 .map_err(McpError::from)
3528 }
3529
3530 pub fn handle_completion_legacy(
3532 &self,
3533 request_ctx: &McpContext,
3534 params: LegacyCompletionParams,
3535 ) -> McpResult<LegacyCompletionResult> {
3536 block_on(self.handle_completion_legacy_in_request(request_ctx, request_ctx.cx(), params))
3537 }
3538
3539 pub(crate) async fn handle_completion_legacy_in_request(
3540 &self,
3541 request_ctx: &McpContext,
3542 request_cx: &Cx,
3543 params: LegacyCompletionParams,
3544 ) -> McpResult<LegacyCompletionResult> {
3545 let dispatch_started_at = request_ctx.cx().now();
3546 if let Some(error) = budget_error(request_ctx) {
3547 return Err(error);
3548 }
3549
3550 let target_handler = match ¶ms.reference {
3551 fastmcp_protocol::LegacyCompletionReference::Prompt { name } => {
3552 self.legacy_prompt_completion_handlers.get(name)
3553 }
3554 fastmcp_protocol::LegacyCompletionReference::Resource { uri } => {
3555 self.legacy_resource_template_completion_handlers.get(uri)
3556 }
3557 };
3558 let handler = target_handler
3559 .or(self.completion_handler.as_ref())
3560 .ok_or_else(|| McpError::method_not_found(COMPLETION_COMPLETE))?;
3561 let handler_ctx =
3562 derive_handler_context(request_ctx, None, None, None, ProtocolEra::Legacy2024);
3563 let handler_timeout =
3564 read_handler_timeout(request_ctx.cx(), "completion_timeout", || handler.timeout())?;
3565 let effective_budget = compose_handler_budget(
3566 request_ctx.cx().budget(),
3567 request_ctx.budget(),
3568 handler_timeout,
3569 dispatch_started_at,
3570 );
3571 let handler_ctx = handler_ctx.with_operation_deadline(effective_budget.deadline);
3572 let outcome = run_handler_in_request(
3573 &handler_ctx,
3574 request_cx,
3575 effective_budget,
3576 "completion",
3577 |child_cx| handler.complete_legacy_async_in_request(&handler_ctx, child_cx, params),
3578 )
3579 .await?;
3580
3581 let completion = match outcome {
3582 Outcome::Ok(completion) => completion,
3583 Outcome::Err(error) => {
3584 return Err(sanitize_handler_error(
3585 request_ctx.cx(),
3586 "completion",
3587 error,
3588 ));
3589 }
3590 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
3591 Outcome::Panicked(_payload) => {
3592 return Err(sanitized_handler_panic(request_ctx.cx(), "completion"));
3593 }
3594 };
3595
3596 Ok(LegacyCompletionResult {
3597 completion,
3598 meta: None,
3599 })
3600 }
3601
3602 async fn handle_completion_final_in_request(
3603 &self,
3604 request_ctx: &McpContext,
3605 request_cx: &Cx,
3606 params: FinalCompletionParams,
3607 ) -> McpResult<FinalCompletionResult> {
3608 let dispatch_started_at = request_ctx.cx().now();
3609 if request_cx.is_cancel_requested() {
3610 return Err(McpError::request_cancelled());
3611 }
3612 if let Some(error) = budget_error(request_ctx) {
3613 return Err(error);
3614 }
3615 if !self.has_final_completion_handler() {
3616 return Err(McpError::method_not_found(COMPLETION_COMPLETE));
3617 }
3618
3619 let provider_handler = match ¶ms.reference {
3620 FinalCompletionReference::Prompt { name }
3621 | FinalCompletionReference::PromptWithTitle { name, .. } => {
3622 if !request_ctx.is_prompt_enabled(name) {
3627 return Err(McpError::invalid_params(
3628 "completion prompt reference is not registered",
3629 ));
3630 }
3631 let prompt = self.final_prompts.get(name).ok_or_else(|| {
3632 McpError::invalid_params("completion prompt reference is not registered")
3633 })?;
3634 if !prompt
3635 .definition
3636 .arguments
3637 .as_deref()
3638 .unwrap_or_default()
3639 .iter()
3640 .any(|argument| argument.name == params.argument.name)
3641 {
3642 return Err(McpError::invalid_params(
3643 "completion argument is not declared by the referenced target",
3644 ));
3645 }
3646 self.final_prompt_completion_handlers.get(name)
3647 }
3648 FinalCompletionReference::Resource { uri } => {
3649 if !request_ctx.is_resource_enabled(uri) {
3654 return Err(McpError::invalid_params(
3655 "completion resource reference is not registered",
3656 ));
3657 }
3658 let template = self
3659 .resource_templates
3660 .get(uri)
3661 .filter(|entry| entry.final_definition.is_some())
3662 .ok_or_else(|| {
3663 McpError::invalid_params("completion resource reference is not registered")
3664 })?;
3665 let template = fastmcp_protocol::UriTemplate::parse(
3666 &template.template.uri_template,
3667 )
3668 .map_err(|_| {
3669 McpError::internal_error(
3670 "admitted completion resource template is no longer valid",
3671 )
3672 })?;
3673 if !template.parts().iter().any(|part| {
3674 matches!(part, UriTemplatePart::Expression(expression)
3675 if expression
3676 .variables()
3677 .iter()
3678 .any(|variable| variable.name() == params.argument.name))
3679 }) {
3680 return Err(McpError::invalid_params(
3681 "completion argument is not declared by the referenced target",
3682 ));
3683 }
3684 self.final_resource_template_completion_handlers.get(uri)
3685 }
3686 };
3687
3688 let handler = match provider_handler {
3689 Some(handler) => handler,
3690 None if self.default_final_completion_enabled => self
3691 .completion_handler
3692 .as_ref()
3693 .ok_or_else(|| McpError::method_not_found(COMPLETION_COMPLETE))?,
3694 None => {
3695 return Err(McpError::invalid_params(
3696 "no final completion provider is registered for the referenced target",
3697 ));
3698 }
3699 };
3700 let handler_ctx =
3701 derive_handler_context(request_ctx, None, None, None, ProtocolEra::Modern2026);
3702 let handler_timeout =
3703 read_handler_timeout(request_ctx.cx(), "completion_timeout", || handler.timeout())?;
3704 let effective_budget = compose_handler_budget(
3705 request_ctx.cx().budget(),
3706 request_ctx.budget(),
3707 handler_timeout,
3708 dispatch_started_at,
3709 );
3710 let handler_ctx = handler_ctx.with_operation_deadline(effective_budget.deadline);
3711 let outcome = run_handler_in_request(
3712 &handler_ctx,
3713 request_cx,
3714 effective_budget,
3715 "completion",
3716 |child_cx| handler.complete_final_async_in_request(&handler_ctx, child_cx, params),
3717 )
3718 .await?;
3719
3720 let completion = match outcome {
3721 Outcome::Ok(completion) => {
3722 if completion.values.len() > fastmcp_protocol::MAX_COMPLETION_VALUES {
3723 return Err(McpError::internal_error(
3724 "completion handler returned more than 100 values",
3725 ));
3726 }
3727 completion.validate().map_err(McpError::internal_error)?;
3728 completion
3729 }
3730 Outcome::Err(error) => {
3731 return Err(sanitize_handler_error(
3732 request_ctx.cx(),
3733 "completion",
3734 error,
3735 ));
3736 }
3737 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
3738 Outcome::Panicked(_payload) => {
3739 return Err(sanitized_handler_panic(request_ctx.cx(), "completion"));
3740 }
3741 };
3742
3743 Ok(FinalCompletionResult { completion })
3744 }
3745
3746 pub(crate) fn dispatch_stateless(
3758 &self,
3759 request_ctx: &McpContext,
3760 request: &JsonRpcRequest,
3761 ) -> McpResult<serde_json::Value> {
3762 let continuation_cancellation = fastmcp_core::McpRequestCancellation::new();
3767 self.dispatch_stateless_with_continuation_cancellation(
3768 request_ctx,
3769 request,
3770 &continuation_cancellation,
3771 )
3772 }
3773
3774 pub(crate) fn dispatch_stateless_with_raw_params(
3780 &self,
3781 request_ctx: &McpContext,
3782 request: &JsonRpcRequest,
3783 raw_params: Option<&str>,
3784 ) -> McpResult<serde_json::Value> {
3785 let continuation_cancellation = fastmcp_core::McpRequestCancellation::new();
3786 self.dispatch_stateless_with_continuation_cancellation_and_raw_params(
3787 request_ctx,
3788 request,
3789 raw_params,
3790 &continuation_cancellation,
3791 )
3792 }
3793
3794 pub(crate) fn dispatch_stateless_with_continuation_cancellation(
3795 &self,
3796 request_ctx: &McpContext,
3797 request: &JsonRpcRequest,
3798 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
3799 ) -> McpResult<serde_json::Value> {
3800 self.dispatch_stateless_with_continuation_cancellation_and_raw_params(
3801 request_ctx,
3802 request,
3803 None,
3804 continuation_cancellation,
3805 )
3806 }
3807
3808 pub(crate) fn dispatch_stateless_with_continuation_cancellation_and_raw_params(
3811 &self,
3812 request_ctx: &McpContext,
3813 request: &JsonRpcRequest,
3814 raw_params: Option<&str>,
3815 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
3816 ) -> McpResult<serde_json::Value> {
3817 block_on(self.dispatch_stateless_in_request(
3818 request_ctx,
3819 request_ctx.cx(),
3820 request,
3821 raw_params,
3822 continuation_cancellation,
3823 ))
3824 }
3825
3826 pub(crate) async fn dispatch_stateless_owned(
3835 self: Arc<Self>,
3836 request_ctx: McpContext,
3837 request: JsonRpcRequest,
3838 ) -> McpResult<serde_json::Value> {
3839 self.dispatch_stateless_owned_with_continuation_cancellation(
3840 request_ctx,
3841 request,
3842 fastmcp_core::McpRequestCancellation::new(),
3843 )
3844 .await
3845 }
3846
3847 pub(crate) async fn dispatch_stateless_owned_with_continuation_cancellation(
3853 self: Arc<Self>,
3854 request_ctx: McpContext,
3855 request: JsonRpcRequest,
3856 continuation_cancellation: fastmcp_core::McpRequestCancellation,
3857 ) -> McpResult<serde_json::Value> {
3858 self.dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
3859 request_ctx,
3860 request,
3861 None,
3862 continuation_cancellation,
3863 )
3864 .await
3865 }
3866
3867 pub(crate) async fn dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
3871 self: Arc<Self>,
3872 request_ctx: McpContext,
3873 request: JsonRpcRequest,
3874 raw_params: Option<Arc<str>>,
3875 continuation_cancellation: fastmcp_core::McpRequestCancellation,
3876 ) -> McpResult<serde_json::Value> {
3877 if let Some(error) = budget_error(&request_ctx) {
3878 return Err(error);
3879 }
3880
3881 let join_cx = request_ctx.cx().clone();
3882 let dispatch_ctx = request_ctx.clone();
3883 let spawn_self = Arc::clone(&self);
3884 let spawn_request = request.clone();
3885 let spawn_raw_params = raw_params.clone();
3886 let spawn_continuation_cancellation = continuation_cancellation.clone();
3887 let mut task = match request_ctx.cx().spawn(move |child_cx| async move {
3888 spawn_self
3889 .dispatch_stateless_in_request(
3890 &dispatch_ctx,
3891 &child_cx,
3892 &spawn_request,
3893 spawn_raw_params.as_deref(),
3894 &spawn_continuation_cancellation,
3895 )
3896 .await
3897 }) {
3898 Ok(task) => task,
3899 Err(asupersync::runtime::state::SpawnError::RuntimeUnavailable) => {
3905 return self
3906 .dispatch_stateless_in_request(
3907 &request_ctx,
3908 request_ctx.cx(),
3909 &request,
3910 raw_params.as_deref(),
3911 &continuation_cancellation,
3912 )
3913 .await;
3914 }
3915 Err(_error) => {
3916 return Err(McpError::internal_error(
3917 "request-owned modern dispatch could not be scheduled",
3918 ));
3919 }
3920 };
3921
3922 match task.join(&join_cx).await {
3923 Ok(result) => result,
3924 Err(asupersync::runtime::JoinError::Panicked(_payload)) => {
3925 Err(sanitized_handler_panic(&join_cx, "modern_dispatch"))
3926 }
3927 Err(_error) => Err(McpError::request_cancelled()),
3928 }
3929 }
3930
3931 async fn dispatch_stateless_in_request(
3932 &self,
3933 request_ctx: &McpContext,
3934 request_cx: &Cx,
3935 request: &JsonRpcRequest,
3936 raw_params: Option<&str>,
3937 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
3938 ) -> McpResult<serde_json::Value> {
3939 if request_cx.is_cancel_requested() {
3940 return Err(McpError::request_cancelled());
3941 }
3942 if let Some(error) = budget_error(request_ctx) {
3943 return Err(error);
3944 }
3945
3946 let params = request.params.as_ref();
3947 let result = match request.method.as_str() {
3948 "ping" => serde_json::json!({}),
3952 COMPLETION_COMPLETE => {
3953 let request =
3954 CoreRequest::decode(ProtocolEra::Modern2026, COMPLETION_COMPLETE, params)
3955 .map_err(|error| McpError::invalid_params(error.to_string()))?;
3956 let CoreRequest::Final(FinalCoreRequest::Completion(params)) = request else {
3957 return Err(McpError::internal_error(
3958 "modern completion dispatch selected another core request",
3959 ));
3960 };
3961 encode_stateless_handler_result(
3962 self.handle_completion_final_in_request(request_ctx, request_cx, params)
3963 .await,
3964 )?
3965 }
3966 "tools/list" => {
3967 let request = CoreRequest::decode(ProtocolEra::Modern2026, "tools/list", params)
3968 .map_err(|error| McpError::invalid_params(error.to_string()))?;
3969 let CoreRequest::Final(FinalCoreRequest::ToolsList(params)) = request else {
3970 return Err(McpError::internal_error(
3971 "modern tools/list dispatch selected another core request",
3972 ));
3973 };
3974 encode_final_core_result(
3975 self.handle_final_tools_list(request_ctx, params),
3976 |result| FinalCoreResult::ToolsList {
3977 result,
3978 diagnostic: None,
3979 },
3980 )?
3981 }
3982 "tools/call" => {
3983 self.admit_final_mrtr_response_map(params)?;
3984 let request = CoreRequest::decode_with_raw_params(
3985 ProtocolEra::Modern2026,
3986 "tools/call",
3987 params,
3988 raw_params,
3989 )
3990 .map_err(|error| McpError::invalid_params(error.to_string()))?;
3991 let CoreRequest::Final(FinalCoreRequest::ToolsCall(params)) = request else {
3992 return Err(McpError::internal_error(
3993 "modern tools/call dispatch selected another core request",
3994 ));
3995 };
3996 let binding = final_mrtr_binding(
3997 request_ctx,
3998 "tools/call",
3999 params.name.clone(),
4000 ¶ms.arguments,
4001 )?;
4002 match self.resolve_final_mrtr_retry(
4003 params.request_state.as_deref(),
4004 params.input_responses.as_ref(),
4005 binding.as_ref(),
4006 )? {
4007 FinalMrtrDispatch::InputRequired(result) => result,
4008 FinalMrtrDispatch::Fresh => {
4009 self.dispatch_final_tools_call(
4010 request_ctx,
4011 request_cx,
4012 params,
4013 binding,
4014 None,
4015 continuation_cancellation,
4016 )
4017 .await?
4018 }
4019 FinalMrtrDispatch::Resume(resume_inputs) => {
4020 self.dispatch_final_tools_call(
4021 request_ctx,
4022 request_cx,
4023 params,
4024 binding,
4025 Some(resume_inputs),
4026 continuation_cancellation,
4027 )
4028 .await?
4029 }
4030 }
4031 }
4032 "resources/list" => {
4033 let request =
4034 CoreRequest::decode(ProtocolEra::Modern2026, "resources/list", params)
4035 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4036 let CoreRequest::Final(FinalCoreRequest::ResourcesList(params)) = &request else {
4037 return Err(McpError::internal_error(
4038 "modern resources/list dispatch selected another core request",
4039 ));
4040 };
4041 encode_final_core_result(
4042 self.handle_final_resources_list(request_ctx, params.clone()),
4043 |result| FinalCoreResult::ResourcesList {
4044 result,
4045 diagnostic: None,
4046 },
4047 )?
4048 }
4049 "resources/templates/list" => {
4050 let request = CoreRequest::decode(
4051 ProtocolEra::Modern2026,
4052 "resources/templates/list",
4053 params,
4054 )
4055 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4056 let CoreRequest::Final(FinalCoreRequest::ResourceTemplatesList(params)) = &request
4057 else {
4058 return Err(McpError::internal_error(
4059 "modern resources/templates/list dispatch selected another core request",
4060 ));
4061 };
4062 encode_final_core_result(
4063 self.handle_final_resource_templates_list(request_ctx, params.clone()),
4064 |result| FinalCoreResult::ResourceTemplatesList {
4065 result,
4066 diagnostic: None,
4067 },
4068 )?
4069 }
4070 "resources/read" => {
4071 self.admit_final_mrtr_response_map(params)?;
4072 let request = CoreRequest::decode_with_raw_params(
4073 ProtocolEra::Modern2026,
4074 "resources/read",
4075 params,
4076 raw_params,
4077 )
4078 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4079 let CoreRequest::Final(FinalCoreRequest::ResourcesRead(params)) = &request else {
4080 return Err(McpError::internal_error(
4081 "modern resources/read dispatch selected another core request",
4082 ));
4083 };
4084 let binding = final_mrtr_binding(
4085 request_ctx,
4086 "resources/read",
4087 params.uri.as_str().to_owned(),
4088 &(),
4089 )?;
4090 match self.resolve_final_mrtr_retry(
4091 params.request_state.as_deref(),
4092 params.input_responses.as_ref(),
4093 binding.as_ref(),
4094 )? {
4095 FinalMrtrDispatch::InputRequired(result) => result,
4096 FinalMrtrDispatch::Fresh => {
4097 self.dispatch_final_resources_read(
4098 request_ctx,
4099 request_cx,
4100 params.clone(),
4101 binding,
4102 None,
4103 continuation_cancellation,
4104 )
4105 .await?
4106 }
4107 FinalMrtrDispatch::Resume(resume_inputs) => {
4108 self.dispatch_final_resources_read(
4109 request_ctx,
4110 request_cx,
4111 params.clone(),
4112 binding,
4113 Some(resume_inputs),
4114 continuation_cancellation,
4115 )
4116 .await?
4117 }
4118 }
4119 }
4120 "prompts/list" => {
4121 let request = CoreRequest::decode(ProtocolEra::Modern2026, "prompts/list", params)
4122 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4123 let CoreRequest::Final(FinalCoreRequest::PromptsList(params)) = &request else {
4124 return Err(McpError::internal_error(
4125 "modern prompts/list dispatch selected another core request",
4126 ));
4127 };
4128 encode_final_core_result(
4129 self.handle_final_prompts_list(request_ctx, params.clone()),
4130 |result| FinalCoreResult::PromptsList {
4131 result,
4132 diagnostic: None,
4133 },
4134 )?
4135 }
4136 "prompts/get" => {
4137 self.admit_final_mrtr_response_map(params)?;
4138 let request = CoreRequest::decode_with_raw_params(
4139 ProtocolEra::Modern2026,
4140 "prompts/get",
4141 params,
4142 raw_params,
4143 )
4144 .map_err(|error| McpError::invalid_params(error.to_string()))?;
4145 let CoreRequest::Final(FinalCoreRequest::PromptsGet(params)) = request else {
4146 return Err(McpError::internal_error(
4147 "modern prompts/get dispatch selected another core request",
4148 ));
4149 };
4150 let binding = final_mrtr_binding(
4151 request_ctx,
4152 "prompts/get",
4153 params.name.clone(),
4154 ¶ms.arguments,
4155 )?;
4156 match self.resolve_final_mrtr_retry(
4157 params.request_state.as_deref(),
4158 params.input_responses.as_ref(),
4159 binding.as_ref(),
4160 )? {
4161 FinalMrtrDispatch::InputRequired(result) => result,
4162 FinalMrtrDispatch::Fresh => {
4163 self.dispatch_final_prompts_get(
4164 request_ctx,
4165 request_cx,
4166 params,
4167 binding,
4168 None,
4169 continuation_cancellation,
4170 )
4171 .await?
4172 }
4173 FinalMrtrDispatch::Resume(resume_inputs) => {
4174 self.dispatch_final_prompts_get(
4175 request_ctx,
4176 request_cx,
4177 params,
4178 binding,
4179 Some(resume_inputs),
4180 continuation_cancellation,
4181 )
4182 .await?
4183 }
4184 }
4185 }
4186 _ => return Err(McpError::method_not_found(&request.method)),
4187 };
4188
4189 if request_cx.is_cancel_requested() {
4190 return Err(McpError::request_cancelled());
4191 }
4192 if let Some(error) = budget_error(request_ctx) {
4193 return Err(error);
4194 }
4195 Ok(result)
4196 }
4197
4198 fn admit_final_mrtr_response_map(&self, params: Option<&serde_json::Value>) -> McpResult<()> {
4201 let Some(params) = params else {
4202 return Ok(());
4203 };
4204 admit_mrtr_raw_json_value(params, MAX_MRTR_RAW_PARAMS_BYTES)?;
4205 let Some(input_responses) = params
4206 .as_object()
4207 .and_then(|members| members.get("inputResponses"))
4208 else {
4209 return Ok(());
4210 };
4211 admit_mrtr_raw_json_value(input_responses, MAX_MRTR_RAW_INPUT_RESPONSES_BYTES)?;
4212 let Some(input_responses) = input_responses.as_object() else {
4213 return Ok(());
4214 };
4215 if input_responses.len() > self.mrtr_exchanges.max_inputs_per_round() {
4216 return Err(McpError::invalid_params(
4217 "MRTR inputResponses exceeds the configured bound",
4218 ));
4219 }
4220 Ok(())
4221 }
4222
4223 #[cfg(feature = "tasks")]
4224 fn admit_final_task_tool(&self, metadata: &OpenMetadata) -> McpResult<&FinalTaskRuntime> {
4225 require_final_tasks_capability(metadata)?;
4226 let runtime = self.final_task_runtime.as_ref().ok_or_else(|| {
4227 McpError::internal_error("task-capable tool requires an installed final Tasks runtime")
4228 })?;
4229 runtime.ensure_task_service_ready()?;
4230 Ok(runtime)
4231 }
4232
4233 fn issue_final_mrtr_input_required(
4234 &self,
4235 request_ctx: &McpContext,
4236 continuation_cancellation: fastmcp_core::McpRequestCancellation,
4237 binding: MrtrExchangeBinding,
4238 handler_result: InputRequiredResult,
4239 ) -> McpResult<serde_json::Value> {
4240 let input_requests = handler_mrtr_input_requests(request_ctx, &handler_result)?;
4244 let required =
4245 self.mrtr_exchanges
4246 .issue_bound(continuation_cancellation, binding, input_requests)?;
4247 encode_mrtr_input_required_result(required)
4248 }
4249
4250 fn resolve_final_mrtr_retry(
4256 &self,
4257 request_state: Option<&str>,
4258 input_responses: Option<&FinalInputResponses>,
4259 binding: Option<&MrtrExchangeBinding>,
4260 ) -> McpResult<FinalMrtrDispatch> {
4261 match (request_state, input_responses) {
4262 (None, None) => Ok(FinalMrtrDispatch::Fresh),
4263 (Some(request_state), None) => {
4264 let binding = binding.ok_or_else(|| {
4265 McpError::invalid_params("MRTR retries require session state")
4266 })?;
4267 match self
4268 .mrtr_exchanges
4269 .accept_state_only_bound(request_state, binding)?
4270 {
4271 MrtrRetry::Complete(inputs) => Ok(FinalMrtrDispatch::Resume(inputs)),
4272 MrtrRetry::InputRequired(_) => Err(McpError::internal_error(
4273 "state-only MRTR retry cannot issue further input requests",
4274 )),
4275 }
4276 }
4277 (Some(request_state), Some(input_responses)) => {
4278 let binding = binding.ok_or_else(|| {
4279 McpError::invalid_params("MRTR retries require session state")
4280 })?;
4281 match self.mrtr_exchanges.accept_final_input_responses_bound(
4282 request_state,
4283 binding,
4284 input_responses,
4285 )? {
4286 MrtrRetry::Complete(inputs) => Ok(FinalMrtrDispatch::Resume(inputs)),
4287 MrtrRetry::InputRequired(result) => encode_mrtr_input_required_result(result)
4288 .map(FinalMrtrDispatch::InputRequired),
4289 }
4290 }
4291 _ => Err(McpError::invalid_params(
4292 "final MRTR inputResponses require requestState",
4293 )),
4294 }
4295 }
4296
4297 async fn dispatch_final_tools_call(
4298 &self,
4299 request_ctx: &McpContext,
4300 request_cx: &Cx,
4301 params: FinalCallToolParams,
4302 binding: Option<MrtrExchangeBinding>,
4303 resume_inputs: Option<MrtrCompletedInputs>,
4304 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4305 ) -> McpResult<serde_json::Value> {
4306 #[cfg(feature = "tasks")]
4307 let request_metadata = params.meta.clone();
4308 let session_state = request_ctx
4309 .session_state()
4310 .cloned()
4311 .unwrap_or_else(SessionState::new);
4312 let outcome = self
4313 .handle_tools_call_final_in_request(
4314 request_ctx,
4315 request_cx,
4316 params,
4317 session_state,
4318 None,
4319 None,
4320 resume_inputs.as_ref(),
4321 )
4322 .await?;
4323 match outcome {
4324 FinalToolOutcome::Complete(result) => encode_final_tools_call_result(Ok(result)),
4325 FinalToolOutcome::InputRequired(result) => {
4326 let binding = binding.ok_or_else(|| {
4327 McpError::internal_error(
4328 "MRTR input_required requires session state to bind retries",
4329 )
4330 })?;
4331 self.issue_final_mrtr_input_required(
4332 request_ctx,
4333 continuation_cancellation.clone(),
4334 binding,
4335 result,
4336 )
4337 }
4338 #[cfg(feature = "tasks")]
4339 FinalToolOutcome::CreateTask {
4340 work_descriptor,
4341 status_message,
4342 } => {
4343 #[cfg(all(feature = "proxy", feature = "tasks"))]
4344 if let Some(relay) = self.final_task_relay.as_ref() {
4345 require_final_tasks_capability(&request_metadata)?;
4350 if let Some(result) = relay.admit_carried_task(&work_descriptor)? {
4351 return encode_final_task_result(result);
4352 }
4353 return Err(McpError::internal_error(
4354 "a proxy final Tasks relay received a non-relayed CreateTask outcome",
4355 ));
4356 }
4357 let runtime = self.admit_final_task_tool(&request_metadata)?;
4362 encode_final_task_result(
4363 runtime.create_task_with_work(work_descriptor, status_message)?,
4364 )
4365 }
4366 }
4367 }
4368
4369 async fn dispatch_final_resources_read(
4370 &self,
4371 request_ctx: &McpContext,
4372 request_cx: &Cx,
4373 params: FinalReadResourceParams,
4374 binding: Option<MrtrExchangeBinding>,
4375 resume_inputs: Option<MrtrCompletedInputs>,
4376 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4377 ) -> McpResult<serde_json::Value> {
4378 let session_state = request_ctx
4379 .session_state()
4380 .cloned()
4381 .unwrap_or_else(SessionState::new);
4382 match self
4383 .handle_resources_read_final_in_request(
4384 request_ctx,
4385 request_cx,
4386 params,
4387 session_state,
4388 None,
4389 None,
4390 resume_inputs.as_ref(),
4391 )
4392 .await?
4393 {
4394 FinalMethodOutcome::Complete(result) => encode_final_resources_read_result(Ok(result)),
4395 FinalMethodOutcome::InputRequired(result) => {
4396 let binding = binding.ok_or_else(|| {
4397 McpError::internal_error(
4398 "MRTR input_required requires session state to bind retries",
4399 )
4400 })?;
4401 self.issue_final_mrtr_input_required(
4402 request_ctx,
4403 continuation_cancellation.clone(),
4404 binding,
4405 result,
4406 )
4407 }
4408 }
4409 }
4410
4411 async fn dispatch_final_prompts_get(
4412 &self,
4413 request_ctx: &McpContext,
4414 request_cx: &Cx,
4415 params: FinalGetPromptParams,
4416 binding: Option<MrtrExchangeBinding>,
4417 resume_inputs: Option<MrtrCompletedInputs>,
4418 continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4419 ) -> McpResult<serde_json::Value> {
4420 let session_state = request_ctx
4421 .session_state()
4422 .cloned()
4423 .unwrap_or_else(SessionState::new);
4424 match self
4425 .handle_prompts_get_final_in_request(
4426 request_ctx,
4427 request_cx,
4428 params,
4429 session_state,
4430 None,
4431 None,
4432 resume_inputs.as_ref(),
4433 )
4434 .await?
4435 {
4436 FinalMethodOutcome::Complete(result) => encode_final_prompts_get_result(Ok(result)),
4437 FinalMethodOutcome::InputRequired(result) => {
4438 let binding = binding.ok_or_else(|| {
4439 McpError::internal_error(
4440 "MRTR input_required requires session state to bind retries",
4441 )
4442 })?;
4443 self.issue_final_mrtr_input_required(
4444 request_ctx,
4445 continuation_cancellation.clone(),
4446 binding,
4447 result,
4448 )
4449 }
4450 }
4451 }
4452
4453 pub fn handle_tools_list(
4458 &self,
4459 request_ctx: &McpContext,
4460 params: ListToolsParams,
4461 session_state: Option<&SessionState>,
4462 ) -> McpResult<ListToolsResult> {
4463 if let Some(error) = budget_error(request_ctx) {
4464 return Err(error);
4465 }
4466
4467 let tag_filters =
4468 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4469 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
4470 Some(&tag_filters)
4471 } else {
4472 None
4473 };
4474 let tools =
4475 crate::catch_extension_unwind(|| self.tools_filtered(session_state, tag_filters))
4476 .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "tool_definition"))?;
4477 let Some(page_size) = self.list_page_size else {
4478 return Ok(ListToolsResult {
4479 tools,
4480 next_cursor: None,
4481 });
4482 };
4483 let query = FinalCatalogQuery::from_tag_filters(
4484 params.include_tags.as_deref(),
4485 params.exclude_tags.as_deref(),
4486 );
4487 let (tools, next_cursor) = page_final_catalog(
4488 tools,
4489 params.cursor.as_deref(),
4490 Some(page_size),
4491 FinalCatalogKind::Tools,
4492 self.final_catalog_revision,
4493 &query,
4494 )?;
4495 Ok(ListToolsResult { tools, next_cursor })
4496 }
4497
4498 fn handle_final_tools_list(
4502 &self,
4503 request_ctx: &McpContext,
4504 params: FinalListParams,
4505 ) -> McpResult<FinalListToolsResult> {
4506 if let Some(error) = budget_error(request_ctx) {
4507 return Err(error);
4508 }
4509
4510 let query = FinalCatalogQuery::from_final_list_params(¶ms);
4511 let tag_filters =
4512 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4513 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
4514 Some(&tag_filters)
4515 } else {
4516 None
4517 };
4518 let tools = crate::catch_extension_unwind(|| {
4519 self.tool_order
4520 .iter()
4521 .filter_map(|name| self.tools.get(name))
4522 .filter(|entry| entry.final_registration.is_some())
4523 .map(|entry| entry.definition.clone())
4524 .filter(|tool| tag_filters.is_none_or(|filters| filters.matches(&tool.tags)))
4525 .collect::<Vec<_>>()
4526 })
4527 .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "tool_definition"))?;
4528
4529 let (tools, next_cursor) = page_final_catalog(
4530 tools,
4531 params.cursor.as_deref(),
4532 self.list_page_size,
4533 FinalCatalogKind::Tools,
4534 self.final_catalog_revision,
4535 &query,
4536 )?;
4537 let result = ListToolsResult { tools, next_cursor };
4538 self.project_final_tools_list(request_ctx, result, self.final_cache_hints.clone())
4539 }
4540
4541 fn project_final_tools_list(
4542 &self,
4543 _request_ctx: &McpContext,
4544 result: ListToolsResult,
4545 cache_hints: FinalCacheHintPolicy,
4546 ) -> McpResult<FinalListToolsResult> {
4547 let tools = result
4548 .tools
4549 .into_iter()
4550 .map(|tool| {
4551 let entry = self.tools.get(&tool.name).ok_or_else(|| {
4552 McpError::internal_error("listed tool is absent from the router catalog")
4553 })?;
4554 let final_registration = entry.final_registration.as_ref().ok_or_else(|| {
4555 McpError::internal_error(
4556 "legacy-only tool reached the final catalog projection",
4557 )
4558 })?;
4559
4560 Ok(final_registration.final_definition.clone())
4561 })
4562 .collect::<McpResult<Vec<_>>>()?;
4563 Ok(FinalListToolsResult {
4564 tools,
4565 next_cursor: result.next_cursor,
4566 ttl_ms: cache_hints.list_ttl_ms,
4567 cache_scope: cache_hints.scope,
4568 })
4569 }
4570
4571 fn handle_final_resources_list(
4572 &self,
4573 request_ctx: &McpContext,
4574 params: FinalListParams,
4575 ) -> McpResult<FinalListResourcesResult> {
4576 if let Some(error) = budget_error(request_ctx) {
4577 return Err(error);
4578 }
4579 let query = FinalCatalogQuery::from_final_list_params(¶ms);
4580 let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4581 let filters =
4582 (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4583 let resources = self
4584 .resource_order
4585 .iter()
4586 .filter_map(|uri| self.final_resources.get(uri).map(|entry| (uri, entry)))
4587 .filter(|(_, entry)| {
4588 filters
4589 .as_ref()
4590 .is_none_or(|filters| filters.matches(&entry.tags))
4591 })
4592 .map(|(_, entry)| {
4593 admit_final_resource_uri(
4594 entry.uri_use_policy,
4595 &entry.definition.uri,
4596 FinalResourceUriUse::CatalogResource,
4597 )?;
4598 Ok(entry.definition.clone())
4599 })
4600 .collect::<McpResult<Vec<_>>>()?;
4601 let (resources, next_cursor) = page_final_catalog(
4602 resources,
4603 params.cursor.as_deref(),
4604 self.list_page_size,
4605 FinalCatalogKind::Resources,
4606 self.final_catalog_revision,
4607 &query,
4608 )?;
4609 Ok(FinalListResourcesResult {
4610 resources,
4611 next_cursor,
4612 ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4613 cache_scope: self.final_cache_hints.scope,
4614 })
4615 }
4616
4617 fn handle_final_resource_templates_list(
4618 &self,
4619 request_ctx: &McpContext,
4620 params: FinalListParams,
4621 ) -> McpResult<FinalListResourceTemplatesResult> {
4622 if let Some(error) = budget_error(request_ctx) {
4623 return Err(error);
4624 }
4625 let query = FinalCatalogQuery::from_final_list_params(¶ms);
4626 let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4627 let filters =
4628 (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4629 let resource_templates = self
4630 .resource_template_order
4631 .iter()
4632 .filter_map(|key| self.resource_templates.get(key).map(|entry| (key, entry)))
4633 .filter_map(|(_, entry)| {
4634 entry
4635 .final_definition
4636 .as_ref()
4637 .map(|definition| (definition, &entry.template.tags, entry.uri_use_policy))
4638 })
4639 .filter(|(_, tags, _)| filters.as_ref().is_none_or(|filters| filters.matches(tags)))
4640 .map(|(definition, _, uri_use_policy)| {
4641 admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
4642 Ok(definition.clone())
4643 })
4644 .collect::<McpResult<Vec<_>>>()?;
4645 let (resource_templates, next_cursor) = page_final_catalog(
4646 resource_templates,
4647 params.cursor.as_deref(),
4648 self.list_page_size,
4649 FinalCatalogKind::ResourceTemplates,
4650 self.final_catalog_revision,
4651 &query,
4652 )?;
4653 Ok(FinalListResourceTemplatesResult {
4654 resource_templates,
4655 next_cursor,
4656 ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4657 cache_scope: self.final_cache_hints.scope,
4658 })
4659 }
4660
4661 fn handle_final_prompts_list(
4662 &self,
4663 request_ctx: &McpContext,
4664 params: FinalListParams,
4665 ) -> McpResult<FinalListPromptsResult> {
4666 if let Some(error) = budget_error(request_ctx) {
4667 return Err(error);
4668 }
4669 let query = FinalCatalogQuery::from_final_list_params(¶ms);
4670 let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4671 let filters =
4672 (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4673 let prompts = self
4674 .prompt_order
4675 .iter()
4676 .filter_map(|name| self.final_prompts.get(name).map(|entry| (name, entry)))
4677 .filter(|(_, entry)| {
4678 filters
4679 .as_ref()
4680 .is_none_or(|filters| filters.matches(&entry.tags))
4681 })
4682 .map(|(_, entry)| entry.definition.clone())
4683 .collect();
4684 let (prompts, next_cursor) = page_final_catalog(
4685 prompts,
4686 params.cursor.as_deref(),
4687 self.list_page_size,
4688 FinalCatalogKind::Prompts,
4689 self.final_catalog_revision,
4690 &query,
4691 )?;
4692 Ok(FinalListPromptsResult {
4693 prompts,
4694 next_cursor,
4695 ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4696 cache_scope: self.final_cache_hints.scope,
4697 })
4698 }
4699
4700 pub fn handle_tools_call(
4710 &self,
4711 request_ctx: &McpContext,
4712 params: CallToolParams,
4713 session_state: SessionState,
4714 notification_sender: Option<&NotificationSender>,
4715 bidirectional_senders: Option<&BidirectionalSenders>,
4716 ) -> McpResult<CallToolResult> {
4717 block_on(self.handle_tools_call_in_request(
4718 request_ctx,
4719 request_ctx.cx(),
4720 params,
4721 session_state,
4722 notification_sender,
4723 bidirectional_senders,
4724 ))
4725 }
4726
4727 pub(crate) async fn handle_tools_call_in_request(
4728 &self,
4729 request_ctx: &McpContext,
4730 request_cx: &Cx,
4731 params: CallToolParams,
4732 session_state: SessionState,
4733 notification_sender: Option<&NotificationSender>,
4734 bidirectional_senders: Option<&BidirectionalSenders>,
4735 ) -> McpResult<CallToolResult> {
4736 debug!(
4737 target: targets::HANDLER,
4738 "calling modern tool; tool_key={}; arguments_present={}",
4739 safe_log_label(¶ms.name),
4740 params.arguments.is_some()
4741 );
4742
4743 let dispatch_started_at = request_ctx.cx().now();
4744 if let Some(error) = budget_error(request_ctx) {
4745 return Err(error);
4746 }
4747 if !session_state.is_tool_enabled(¶ms.name) {
4748 return Err(McpError::new(
4749 McpErrorCode::MethodNotFound,
4750 format!("Tool '{}' is disabled for this session", params.name),
4751 ));
4752 }
4753
4754 let entry = self
4755 .tools
4756 .get(¶ms.name)
4757 .ok_or_else(|| McpError::method_not_found(&format!("tool: {}", params.name)))?;
4758 if !entry.legacy_enabled {
4759 return Err(McpError::method_not_found(&format!(
4760 "tool: {}",
4761 params.name
4762 )));
4763 }
4764 let handler = &entry.handler;
4765 let arguments = params.arguments.unwrap_or_else(|| serde_json::json!({}));
4766 let validation_result = if self.strict_input_validation {
4767 validate_strict(&entry.definition.input_schema, &arguments)
4768 } else {
4769 validate(&entry.definition.input_schema, &arguments)
4770 };
4771 if let Err(validation_errors) = validation_result {
4772 let error_messages: Vec<String> = validation_errors
4773 .iter()
4774 .map(|error| format!("{}: {}", error.path, error.message))
4775 .collect();
4776 return Err(McpError::invalid_params(format!(
4777 "Input validation failed: {}",
4778 error_messages.join("; ")
4779 )));
4780 }
4781
4782 let progress_marker = params
4783 .meta
4784 .as_ref()
4785 .and_then(|meta| meta.progress_marker.clone());
4786 let ctx = derive_handler_context(
4787 request_ctx,
4788 progress_marker,
4789 notification_sender,
4790 bidirectional_senders,
4791 ProtocolEra::Legacy2024,
4792 );
4793 let handler_timeout =
4794 read_handler_timeout(request_ctx.cx(), "tool_timeout", || handler.timeout())?;
4795 let effective_budget = compose_handler_budget(
4796 request_ctx.cx().budget(),
4797 request_ctx.budget(),
4798 handler_timeout,
4799 dispatch_started_at,
4800 );
4801 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
4802 let outcome =
4803 run_handler_in_request(&ctx, request_cx, effective_budget, "tool", |child_cx| {
4804 handler.call_async_in_request(&ctx, child_cx, arguments)
4805 })
4806 .await?;
4807
4808 match outcome {
4809 Outcome::Ok(content) => Ok(CallToolResult {
4810 content: legacy_contents_from_handler(content)?,
4811 is_error: false,
4812 meta: None,
4813 additional: BTreeMap::new(),
4814 }),
4815 Outcome::Err(error) => {
4816 let error = sanitize_handler_error(request_ctx.cx(), "tool", error);
4817 if is_framework_terminal_tool_error(error.code) {
4818 return Err(error);
4819 }
4820 Ok(CallToolResult {
4821 content: vec![LegacyContent::Text {
4822 text: error.message,
4823 annotations: None,
4824 additional: BTreeMap::new(),
4825 }],
4826 is_error: true,
4827 meta: None,
4828 additional: BTreeMap::new(),
4829 })
4830 }
4831 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
4832 Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "tool")),
4833 }
4834 }
4835
4836 async fn handle_tools_call_final_in_request(
4843 &self,
4844 request_ctx: &McpContext,
4845 request_cx: &Cx,
4846 params: FinalCallToolParams,
4847 session_state: SessionState,
4848 notification_sender: Option<&NotificationSender>,
4849 bidirectional_senders: Option<&BidirectionalSenders>,
4850 resume_inputs: Option<&MrtrCompletedInputs>,
4851 ) -> McpResult<FinalToolOutcome> {
4852 debug!(
4853 target: targets::HANDLER,
4854 "calling final tool; tool_key={}; arguments_present={}",
4855 safe_log_label(¶ms.name),
4856 !params.arguments.is_absent()
4857 );
4858
4859 let dispatch_started_at = request_ctx.cx().now();
4860 if request_cx.is_cancel_requested() {
4861 return Err(McpError::request_cancelled());
4862 }
4863 if let Some(error) = budget_error(request_ctx) {
4864 return Err(error);
4865 }
4866 let progress_marker = final_progress_marker(¶ms.meta)?;
4867 if !session_state.is_tool_enabled(¶ms.name)
4872 || !request_ctx.is_tool_enabled(¶ms.name)
4873 {
4874 return Err(McpError::new(
4875 McpErrorCode::MethodNotFound,
4876 format!("Tool '{}' is disabled for this session", params.name),
4877 ));
4878 }
4879
4880 let entry = self
4881 .tools
4882 .get(¶ms.name)
4883 .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {}", params.name)))?;
4884 let final_registration = entry
4885 .final_registration
4886 .as_ref()
4887 .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {}", params.name)))?;
4888 let handler = &entry.handler;
4889 if handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none() {
4890 return Err(McpError::invalid_params(
4891 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
4892 ));
4893 }
4894 let input_schema = final_registration.schemas.input.as_ref();
4895 let output_schema = final_registration.schemas.output.as_ref();
4896 #[cfg(feature = "tasks")]
4899 let declares_final_tasks = final_registration.declares_final_tasks;
4900 let arguments = params
4901 .arguments
4902 .into_value()
4903 .unwrap_or_else(|| serde_json::json!({}));
4904 let input_validation_failed = match input_schema {
4905 Some(schema) => {
4906 let validation = if self.strict_input_validation {
4907 validate_strict(schema.schema(), &arguments)
4908 } else {
4909 schema.validate(&arguments)
4910 };
4911 validation.is_err()
4912 }
4913 None if self.strict_input_validation => {
4914 validate_strict(
4919 &final_registration.final_definition.input_schema,
4920 &arguments,
4921 )
4922 .is_err()
4923 }
4924 None => false,
4925 };
4926 if input_validation_failed {
4927 let mut result = crate::handler::promote_legacy_tool_content(vec![Content::text(
4928 "Tool arguments do not match the declared input schema.",
4929 )])?;
4930 result.payload.is_error = true;
4931 result.payload.structured_content = final_registration
4932 .schemas
4933 .errors
4934 .as_ref()
4935 .map(|errors| errors.input_validation.clone());
4936 return Ok(FinalToolOutcome::Complete(result));
4937 }
4938
4939 #[cfg(all(feature = "proxy", feature = "tasks"))]
4945 if declares_final_tasks && self.final_task_relay.is_some() {
4946 require_final_tasks_capability(¶ms.meta)?;
4947 }
4948
4949 let ctx = derive_handler_context(
4950 request_ctx,
4951 progress_marker,
4952 notification_sender,
4953 bidirectional_senders,
4954 ProtocolEra::Modern2026,
4955 );
4956 let handler_timeout =
4957 read_handler_timeout(request_ctx.cx(), "tool_timeout", || handler.timeout())?;
4958 let effective_budget = compose_handler_budget(
4959 request_ctx.cx().budget(),
4960 request_ctx.budget(),
4961 handler_timeout,
4962 dispatch_started_at,
4963 );
4964 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
4965 let outcome =
4966 run_handler_in_request(&ctx, request_cx, effective_budget, "tool", |child_cx| {
4967 handler.call_final_outcome_async_resuming_in_request(
4972 &ctx,
4973 child_cx,
4974 arguments,
4975 resume_inputs,
4976 )
4977 })
4978 .await?;
4979
4980 match outcome {
4981 Outcome::Ok(result) => {
4982 match &result {
4983 FinalToolOutcome::Complete(result) => {
4984 if let Some(output_schema) = output_schema {
4985 let structured_content = result
4986 .payload
4987 .structured_content
4988 .as_ref()
4989 .ok_or_else(|| {
4990 McpError::internal_error(
4991 "tool output is missing structuredContent required by the declared output schema",
4992 )
4993 })?;
4994 if output_schema.validate(structured_content).is_err() {
4995 return Err(McpError::internal_error(
4996 "tool output does not match the declared output schema",
4997 ));
4998 }
4999 }
5000 }
5001 #[cfg(feature = "tasks")]
5002 FinalToolOutcome::CreateTask { .. } if !declares_final_tasks => {
5003 return Err(McpError::invalid_request(
5004 "tool returned CreateTask without declaring final Tasks capability",
5005 ));
5006 }
5007 FinalToolOutcome::InputRequired(_) => {}
5008 #[cfg(feature = "tasks")]
5009 FinalToolOutcome::CreateTask { .. } => {}
5010 }
5011 Ok(result)
5012 }
5013 Outcome::Err(error) => {
5014 let error = sanitize_handler_error(request_ctx.cx(), "tool", error);
5015 if is_framework_terminal_tool_error(error.code) {
5016 return Err(error);
5017 }
5018 let mut result =
5019 crate::handler::promote_legacy_tool_content(vec![Content::Text {
5020 text: error.message,
5021 }])?;
5022 result.payload.is_error = true;
5023 result.payload.structured_content = final_registration
5024 .schemas
5025 .errors
5026 .as_ref()
5027 .map(|errors| errors.handler.clone());
5028 Ok(FinalToolOutcome::Complete(result))
5029 }
5030 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5031 Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "tool")),
5032 }
5033 }
5034
5035 pub fn handle_resources_list(
5040 &self,
5041 request_ctx: &McpContext,
5042 params: ListResourcesParams,
5043 session_state: Option<&SessionState>,
5044 ) -> McpResult<ListResourcesResult> {
5045 if let Some(error) = budget_error(request_ctx) {
5046 return Err(error);
5047 }
5048
5049 let tag_filters =
5050 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5051 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5052 Some(&tag_filters)
5053 } else {
5054 None
5055 };
5056 let resources = crate::catch_extension_unwind(|| {
5057 self.resources_filtered(session_state, tag_filters)
5058 })
5059 .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "resource_definition"))?;
5060 let Some(page_size) = self.list_page_size else {
5061 return Ok(ListResourcesResult {
5062 resources,
5063 next_cursor: None,
5064 });
5065 };
5066 let query = FinalCatalogQuery::from_tag_filters(
5067 params.include_tags.as_deref(),
5068 params.exclude_tags.as_deref(),
5069 );
5070 let (resources, next_cursor) = page_final_catalog(
5071 resources,
5072 params.cursor.as_deref(),
5073 Some(page_size),
5074 FinalCatalogKind::Resources,
5075 self.final_catalog_revision,
5076 &query,
5077 )?;
5078 Ok(ListResourcesResult {
5079 resources,
5080 next_cursor,
5081 })
5082 }
5083
5084 pub fn handle_resource_templates_list(
5089 &self,
5090 request_ctx: &McpContext,
5091 params: ListResourceTemplatesParams,
5092 session_state: Option<&SessionState>,
5093 ) -> McpResult<ListResourceTemplatesResult> {
5094 if let Some(error) = budget_error(request_ctx) {
5095 return Err(error);
5096 }
5097
5098 let tag_filters =
5099 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5100 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5101 Some(&tag_filters)
5102 } else {
5103 None
5104 };
5105 let templates = self.resource_templates_filtered(session_state, tag_filters);
5106 let Some(page_size) = self.list_page_size else {
5107 return Ok(ListResourceTemplatesResult {
5108 resource_templates: templates,
5109 next_cursor: None,
5110 });
5111 };
5112 let query = FinalCatalogQuery::from_tag_filters(
5113 params.include_tags.as_deref(),
5114 params.exclude_tags.as_deref(),
5115 );
5116 let (resource_templates, next_cursor) = page_final_catalog(
5117 templates,
5118 params.cursor.as_deref(),
5119 Some(page_size),
5120 FinalCatalogKind::ResourceTemplates,
5121 self.final_catalog_revision,
5122 &query,
5123 )?;
5124 Ok(ListResourceTemplatesResult {
5125 resource_templates,
5126 next_cursor,
5127 })
5128 }
5129
5130 pub fn handle_resources_read(
5140 &self,
5141 request_ctx: &McpContext,
5142 params: &ReadResourceParams,
5143 session_state: SessionState,
5144 notification_sender: Option<&NotificationSender>,
5145 bidirectional_senders: Option<&BidirectionalSenders>,
5146 ) -> McpResult<ReadResourceResult> {
5147 block_on(self.handle_resources_read_in_request(
5148 request_ctx,
5149 request_ctx.cx(),
5150 params,
5151 session_state,
5152 notification_sender,
5153 bidirectional_senders,
5154 ))
5155 }
5156
5157 pub(crate) async fn handle_resources_read_in_request(
5158 &self,
5159 request_ctx: &McpContext,
5160 request_cx: &Cx,
5161 params: &ReadResourceParams,
5162 session_state: SessionState,
5163 notification_sender: Option<&NotificationSender>,
5164 bidirectional_senders: Option<&BidirectionalSenders>,
5165 ) -> McpResult<ReadResourceResult> {
5166 debug!(
5167 target: targets::HANDLER,
5168 "reading modern resource; resource_key={}",
5169 safe_log_label(¶ms.uri)
5170 );
5171
5172 let dispatch_started_at = request_ctx.cx().now();
5173 if let Some(error) = budget_error(request_ctx) {
5174 return Err(error);
5175 }
5176 if !session_state.is_resource_enabled(¶ms.uri) {
5177 return Err(McpError::new(
5178 McpErrorCode::ResourceNotFound,
5179 format!("Resource '{}' is disabled for this session", params.uri),
5180 ));
5181 }
5182
5183 let resolved = self
5184 .resolve_resource_for_era(¶ms.uri, Some(ProtocolEra::Legacy2024))
5185 .ok_or_else(|| McpError::resource_not_found(¶ms.uri))?;
5186 if !resolved.legacy_enabled {
5187 return Err(McpError::resource_not_found(¶ms.uri));
5188 }
5189 let progress_marker = params
5190 .meta
5191 .as_ref()
5192 .and_then(|meta| meta.progress_marker.clone());
5193 let ctx = derive_handler_context(
5194 request_ctx,
5195 progress_marker,
5196 notification_sender,
5197 bidirectional_senders,
5198 ProtocolEra::Legacy2024,
5199 );
5200 let handler_timeout = read_handler_timeout(request_ctx.cx(), "resource_timeout", || {
5201 resolved.handler.timeout()
5202 })?;
5203 let effective_budget = compose_handler_budget(
5204 request_ctx.cx().budget(),
5205 request_ctx.budget(),
5206 handler_timeout,
5207 dispatch_started_at,
5208 );
5209 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5210 let outcome =
5211 run_handler_in_request(&ctx, request_cx, effective_budget, "resource", |child_cx| {
5212 resolved.handler.read_async_with_uri_in_request(
5213 &ctx,
5214 child_cx,
5215 ¶ms.uri,
5216 &resolved.params,
5217 )
5218 })
5219 .await?;
5220
5221 let contents = match outcome {
5222 Outcome::Ok(contents) => contents,
5223 Outcome::Err(error) => {
5224 return Err(sanitize_handler_error(request_ctx.cx(), "resource", error));
5225 }
5226 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
5227 Outcome::Panicked(_payload) => {
5228 return Err(sanitized_handler_panic(request_ctx.cx(), "resource"));
5229 }
5230 };
5231
5232 Ok(ReadResourceResult {
5233 contents: legacy_resource_contents_from_handler(contents)?,
5234 meta: None,
5235 additional: BTreeMap::new(),
5236 })
5237 }
5238
5239 async fn handle_resources_read_final_in_request(
5246 &self,
5247 request_ctx: &McpContext,
5248 request_cx: &Cx,
5249 params: FinalReadResourceParams,
5250 session_state: SessionState,
5251 notification_sender: Option<&NotificationSender>,
5252 bidirectional_senders: Option<&BidirectionalSenders>,
5253 resume_inputs: Option<&MrtrCompletedInputs>,
5254 ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
5255 let progress_marker = final_progress_marker(¶ms.meta)?;
5256 let uri = params.uri.as_str();
5257 debug!(
5258 target: targets::HANDLER,
5259 "reading final resource; resource_key={}",
5260 safe_log_label(uri)
5261 );
5262
5263 let dispatch_started_at = request_ctx.cx().now();
5264 if request_cx.is_cancel_requested() {
5265 return Err(McpError::request_cancelled());
5266 }
5267 if let Some(error) = budget_error(request_ctx) {
5268 return Err(error);
5269 }
5270 if !session_state.is_resource_enabled(uri) || !request_ctx.is_resource_enabled(uri) {
5273 return Err(McpError::new(
5274 McpErrorCode::ResourceNotFound,
5275 format!("Resource '{uri}' is disabled for this session"),
5276 ));
5277 }
5278
5279 let resolved = self
5280 .resolve_resource_for_era(uri, Some(ProtocolEra::Modern2026))
5281 .ok_or_else(|| {
5282 McpError::with_data(
5283 McpErrorCode::InvalidParams,
5284 "Resource not found",
5285 serde_json::json!({"uri": uri}),
5286 )
5287 })?;
5288 if !resolved.final_enabled {
5289 return Err(McpError::invalid_params(
5290 "resource is registered only for exact MCP 2024-11-05 dispatch",
5291 ));
5292 }
5293 if resolved.handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none()
5294 {
5295 return Err(McpError::invalid_params(
5296 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
5297 ));
5298 }
5299 admit_final_resource_uri(
5300 resolved.uri_use_policy,
5301 ¶ms.uri,
5302 FinalResourceUriUse::ResourceReadTarget,
5303 )?;
5304 let cache_hint_provenance = crate::catch_extension_unwind(|| {
5305 resolved.handler.final_resource_read_cache_hint_provenance()
5306 })
5307 .map_err(|_payload| {
5308 McpError::internal_error("resource cache-hint provenance hook panicked during dispatch")
5309 })?;
5310 let ctx = derive_handler_context(
5311 request_ctx,
5312 progress_marker,
5313 notification_sender,
5314 bidirectional_senders,
5315 ProtocolEra::Modern2026,
5316 );
5317 let handler_timeout = read_handler_timeout(request_ctx.cx(), "resource_timeout", || {
5318 resolved.handler.timeout()
5319 })?;
5320 let effective_budget = compose_handler_budget(
5321 request_ctx.cx().budget(),
5322 request_ctx.budget(),
5323 handler_timeout,
5324 dispatch_started_at,
5325 );
5326 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5327 let outcome =
5328 run_handler_in_request(&ctx, request_cx, effective_budget, "resource", |child_cx| {
5329 if let Some(resume_inputs) = resume_inputs {
5330 resolved
5331 .handler
5332 .read_final_outcome_async_with_uri_resuming_in_request(
5333 &ctx,
5334 child_cx,
5335 uri,
5336 &resolved.params,
5337 Some(resume_inputs),
5338 )
5339 } else {
5340 resolved
5341 .handler
5342 .read_final_outcome_async_with_uri_in_request(
5343 &ctx,
5344 child_cx,
5345 uri,
5346 &resolved.params,
5347 )
5348 }
5349 })
5350 .await?;
5351
5352 match outcome {
5353 Outcome::Ok(mut result) => {
5354 admit_final_resource_read_outcome(resolved.uri_use_policy, &result)?;
5355 if let FinalMethodOutcome::Complete(complete) = &mut result
5360 && cache_hint_provenance == FinalResourceReadCacheHintProvenance::RouterPolicy
5361 {
5362 complete.payload.ttl_ms = self.final_cache_hints.resource_read_ttl_ms.clone();
5363 complete.payload.cache_scope = self.final_cache_hints.scope;
5364 }
5365 Ok(result)
5366 }
5367 Outcome::Err(error) => Err(sanitize_handler_error(request_ctx.cx(), "resource", error)),
5368 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5369 Outcome::Panicked(_payload) => {
5370 Err(sanitized_handler_panic(request_ctx.cx(), "resource"))
5371 }
5372 }
5373 }
5374
5375 pub fn handle_prompts_list(
5380 &self,
5381 request_ctx: &McpContext,
5382 params: ListPromptsParams,
5383 session_state: Option<&SessionState>,
5384 ) -> McpResult<ListPromptsResult> {
5385 if let Some(error) = budget_error(request_ctx) {
5386 return Err(error);
5387 }
5388
5389 let tag_filters =
5390 TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5391 let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5392 Some(&tag_filters)
5393 } else {
5394 None
5395 };
5396 let prompts =
5397 crate::catch_extension_unwind(|| self.prompts_filtered(session_state, tag_filters))
5398 .map_err(|_payload| {
5399 sanitized_handler_panic(request_ctx.cx(), "prompt_definition")
5400 })?;
5401 let Some(page_size) = self.list_page_size else {
5402 return Ok(ListPromptsResult {
5403 prompts,
5404 next_cursor: None,
5405 });
5406 };
5407 let query = FinalCatalogQuery::from_tag_filters(
5408 params.include_tags.as_deref(),
5409 params.exclude_tags.as_deref(),
5410 );
5411 let (prompts, next_cursor) = page_final_catalog(
5412 prompts,
5413 params.cursor.as_deref(),
5414 Some(page_size),
5415 FinalCatalogKind::Prompts,
5416 self.final_catalog_revision,
5417 &query,
5418 )?;
5419 Ok(ListPromptsResult {
5420 prompts,
5421 next_cursor,
5422 })
5423 }
5424
5425 pub fn handle_prompts_get(
5435 &self,
5436 request_ctx: &McpContext,
5437 params: GetPromptParams,
5438 session_state: SessionState,
5439 notification_sender: Option<&NotificationSender>,
5440 bidirectional_senders: Option<&BidirectionalSenders>,
5441 ) -> McpResult<GetPromptResult> {
5442 block_on(self.handle_prompts_get_in_request(
5443 request_ctx,
5444 request_ctx.cx(),
5445 params,
5446 session_state,
5447 notification_sender,
5448 bidirectional_senders,
5449 ))
5450 }
5451
5452 pub(crate) async fn handle_prompts_get_in_request(
5453 &self,
5454 request_ctx: &McpContext,
5455 request_cx: &Cx,
5456 params: GetPromptParams,
5457 session_state: SessionState,
5458 notification_sender: Option<&NotificationSender>,
5459 bidirectional_senders: Option<&BidirectionalSenders>,
5460 ) -> McpResult<GetPromptResult> {
5461 debug!(
5462 target: targets::HANDLER,
5463 "getting modern prompt; prompt_key={}; arguments_present={}",
5464 safe_log_label(¶ms.name),
5465 params.arguments.is_some()
5466 );
5467
5468 let dispatch_started_at = request_ctx.cx().now();
5469 if let Some(error) = budget_error(request_ctx) {
5470 return Err(error);
5471 }
5472 if !session_state.is_prompt_enabled(¶ms.name) {
5473 return Err(McpError::new(
5474 McpErrorCode::PromptNotFound,
5475 format!("Prompt '{}' is disabled for this session", params.name),
5476 ));
5477 }
5478
5479 let handler = self.prompts.get(¶ms.name).ok_or_else(|| {
5480 McpError::new(
5481 McpErrorCode::PromptNotFound,
5482 format!("Prompt not found: {}", params.name),
5483 )
5484 })?;
5485 if self.final_only_prompts.contains(¶ms.name) {
5486 return Err(McpError::new(
5487 McpErrorCode::PromptNotFound,
5488 format!("Prompt not found: {}", params.name),
5489 ));
5490 }
5491 let description = crate::catch_extension_unwind(|| handler.definition().description)
5492 .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "prompt_definition"))?;
5493 let progress_marker = params
5494 .meta
5495 .as_ref()
5496 .and_then(|meta| meta.progress_marker.clone());
5497 let ctx = derive_handler_context(
5498 request_ctx,
5499 progress_marker,
5500 notification_sender,
5501 bidirectional_senders,
5502 ProtocolEra::Legacy2024,
5503 );
5504 let handler_timeout =
5505 read_handler_timeout(request_ctx.cx(), "prompt_timeout", || handler.timeout())?;
5506 let effective_budget = compose_handler_budget(
5507 request_ctx.cx().budget(),
5508 request_ctx.budget(),
5509 handler_timeout,
5510 dispatch_started_at,
5511 );
5512 let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5513 let arguments = params.arguments.unwrap_or_default();
5514 let outcome =
5515 run_handler_in_request(&ctx, request_cx, effective_budget, "prompt", |child_cx| {
5516 handler.get_async_in_request(&ctx, child_cx, arguments)
5517 })
5518 .await?;
5519
5520 let messages = match outcome {
5521 Outcome::Ok(messages) => messages,
5522 Outcome::Err(error) => {
5523 return Err(sanitize_handler_error(request_ctx.cx(), "prompt", error));
5524 }
5525 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
5526 Outcome::Panicked(_payload) => {
5527 return Err(sanitized_handler_panic(request_ctx.cx(), "prompt"));
5528 }
5529 };
5530
5531 Ok(GetPromptResult {
5532 description,
5533 messages: legacy_prompt_messages_from_handler(messages)?,
5534 meta: None,
5535 additional: BTreeMap::new(),
5536 })
5537 }
5538
5539 async fn handle_prompts_get_final_in_request(
5546 &self,
5547 request_ctx: &McpContext,
5548 request_cx: &Cx,
5549 params: FinalGetPromptParams,
5550 session_state: SessionState,
5551 notification_sender: Option<&NotificationSender>,
5552 bidirectional_senders: Option<&BidirectionalSenders>,
5553 resume_inputs: Option<&MrtrCompletedInputs>,
5554 ) -> McpResult<FinalMethodOutcome<FinalGetPromptResult>> {
5555 debug!(
5556 target: targets::HANDLER,
5557 "getting final prompt; prompt_key={}; arguments_present={}",
5558 safe_log_label(¶ms.name),
5559 !params.arguments.is_absent()
5560 );
5561
5562 let dispatch_started_at = request_ctx.cx().now();
5563 if request_cx.is_cancel_requested() {
5564 return Err(McpError::request_cancelled());
5565 }
5566 if let Some(error) = budget_error(request_ctx) {
5567 return Err(error);
5568 }
5569 let progress_marker = final_progress_marker(¶ms.meta)?;
5570 if !session_state.is_prompt_enabled(¶ms.name)
5573 || !request_ctx.is_prompt_enabled(¶ms.name)
5574 {
5575 return Err(McpError::new(
5576 McpErrorCode::PromptNotFound,
5577 format!("Prompt '{}' is disabled for this session", params.name),
5578 ));
5579 }
5580
5581 let handler = self
5582 .prompts
5583 .get(¶ms.name)
5584 .ok_or_else(|| McpError::invalid_params(format!("Unknown prompt: {}", params.name)))?;
5585 let final_registration = self.final_prompts.get(¶ms.name).ok_or_else(|| {
5586 McpError::invalid_params("prompt is registered only for exact MCP 2024-11-05 dispatch")
5587 })?;
5588 if handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none() {
5589 return Err(McpError::invalid_params(
5590 MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
5591 ));
5592 }
5593 let arguments = params.arguments.into_value().unwrap_or_default();
5596 if final_registration
5597 .definition
5598 .arguments
5599 .as_deref()
5600 .unwrap_or_default()
5601 .iter()
5602 .any(|argument| {
5603 argument.required == Some(true) && !arguments.contains_key(&argument.name)
5604 })
5605 {
5606 return Err(McpError::invalid_params("Missing required prompt argument"));
5607 }
5608 if arguments.keys().any(|name| {
5609 !final_registration
5610 .definition
5611 .arguments
5612 .as_deref()
5613 .unwrap_or_default()
5614 .iter()
5615 .any(|argument| &argument.name == name)
5616 }) {
5617 return Err(McpError::invalid_params("Unknown prompt argument"));
5618 }
5619 let ctx = derive_handler_context(
5620 request_ctx,
5621 progress_marker,
5622 notification_sender,
5623 bidirectional_senders,
5624 ProtocolEra::Modern2026,
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 = arguments.into_iter().collect();
5636 let outcome =
5637 run_handler_in_request(&ctx, request_cx, effective_budget, "prompt", |child_cx| {
5638 if let Some(resume_inputs) = resume_inputs {
5639 handler.get_final_outcome_async_resuming_in_request(
5640 &ctx,
5641 child_cx,
5642 arguments,
5643 Some(resume_inputs),
5644 )
5645 } else {
5646 handler.get_final_outcome_async_in_request(&ctx, child_cx, arguments)
5647 }
5648 })
5649 .await?;
5650
5651 match outcome {
5652 Outcome::Ok(result) => {
5653 admit_final_prompt_outcome(final_registration.uri_use_policy, &result)?;
5654 Ok(result)
5655 }
5656 Outcome::Err(error) => Err(sanitize_handler_error(request_ctx.cx(), "prompt", error)),
5657 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5658 Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "prompt")),
5659 }
5660 }
5661}
5662
5663impl Default for Router {
5664 fn default() -> Self {
5665 Self::new()
5666 }
5667}
5668
5669#[derive(Debug, Default)]
5675pub struct MountResult {
5676 pub tools: usize,
5678 pub resources: usize,
5680 pub resource_templates: usize,
5682 pub prompts: usize,
5684 pub warnings: Vec<String>,
5686 pub errors: Vec<String>,
5690}
5691
5692impl MountResult {
5693 #[must_use]
5695 pub fn has_components(&self) -> bool {
5696 self.tools > 0 || self.resources > 0 || self.resource_templates > 0 || self.prompts > 0
5697 }
5698
5699 #[must_use]
5701 pub fn is_success(&self) -> bool {
5702 self.errors.is_empty()
5703 }
5704
5705 fn merge(&mut self, other: Self) {
5706 self.tools += other.tools;
5707 self.resources += other.resources;
5708 self.resource_templates += other.resource_templates;
5709 self.prompts += other.prompts;
5710 self.warnings.extend(other.warnings);
5711 self.errors.extend(other.errors);
5712 }
5713}
5714
5715#[derive(Clone, Copy)]
5716enum MountSelection {
5717 All,
5718 Tools,
5719 Resources,
5720 Prompts,
5721}
5722
5723impl MountSelection {
5724 const fn includes_tools(self) -> bool {
5725 matches!(self, Self::All | Self::Tools)
5726 }
5727
5728 const fn includes_resources(self) -> bool {
5729 matches!(self, Self::All | Self::Resources)
5730 }
5731
5732 const fn includes_prompts(self) -> bool {
5733 matches!(self, Self::All | Self::Prompts)
5734 }
5735}
5736
5737impl Router {
5738 fn apply_prefix(name: &str, prefix: Option<&str>) -> String {
5740 match prefix {
5741 Some(p) if !p.is_empty() => format!("{}/{}", p, name),
5742 _ => name.to_string(),
5743 }
5744 }
5745
5746 fn prefix_preserves_keys(prefix: Option<&str>) -> bool {
5753 prefix.is_none_or(str::is_empty)
5754 }
5755
5756 fn validate_prefix(prefix: &str) -> Result<(), String> {
5761 if prefix.is_empty() {
5762 return Ok(());
5763 }
5764 if prefix.contains('/') {
5765 return Err("Invalid mount prefix: slashes are not permitted".to_string());
5766 }
5767 for ch in prefix.chars() {
5769 if !ch.is_alphanumeric() && ch != '_' && ch != '-' {
5770 return Err(
5771 "Invalid mount prefix: invalid characters are not permitted".to_string()
5772 );
5773 }
5774 }
5775 Ok(())
5776 }
5777
5778 fn mount_preflight(
5779 &self,
5780 other: &Self,
5781 prefix: Option<&str>,
5782 behavior: crate::DuplicateBehavior,
5783 selection: MountSelection,
5784 ) -> MountResult {
5785 let mut result = MountResult::default();
5786
5787 if let Some(prefix) = prefix {
5788 if let Err(error) = Self::validate_prefix(prefix) {
5789 result.warnings.push(error.clone());
5792 result.errors.push(error);
5793 return result;
5794 }
5795 }
5796
5797 if behavior == crate::DuplicateBehavior::Error {
5798 let mut conflicts = Vec::new();
5799 if selection.includes_tools() {
5800 for name in other.tools.keys() {
5801 let mounted_name = Self::apply_prefix(name, prefix);
5802 if self.tools.contains_key(&mounted_name) {
5803 conflicts.push(("Tool", mounted_name));
5804 }
5805 }
5806 }
5807 if selection.includes_resources() {
5808 for uri in other.resources.keys() {
5809 let mounted_uri = Self::apply_prefix(uri, prefix);
5810 if self.resources.contains_key(&mounted_uri) {
5811 conflicts.push(("Resource", mounted_uri));
5812 }
5813 }
5814 for uri_template in other.resource_templates.keys() {
5815 let mounted_uri_template = Self::apply_prefix(uri_template, prefix);
5816 if self.resource_templates.contains_key(&mounted_uri_template) {
5817 conflicts.push(("Resource template", mounted_uri_template));
5818 }
5819 }
5820 }
5821 if selection.includes_prompts() {
5822 for name in other.prompts.keys() {
5823 let mounted_name = Self::apply_prefix(name, prefix);
5824 if self.prompts.contains_key(&mounted_name) {
5825 conflicts.push(("Prompt", mounted_name));
5826 }
5827 }
5828 }
5829
5830 conflicts.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(&b.1)));
5831 result
5832 .errors
5833 .extend(conflicts.into_iter().map(|(kind, key)| {
5834 format!(
5835 "Mount rejected because {kind} already exists; component_key={}",
5836 safe_log_label(&key)
5837 )
5838 }));
5839 }
5840
5841 self.preflight_final_resource_route_collisions(
5842 other,
5843 prefix,
5844 behavior,
5845 selection,
5846 &mut result,
5847 );
5848 self.preflight_mcp_apps_mount_bindings(other, prefix, behavior, selection, &mut result);
5849 result
5850 }
5851
5852 fn preflight_final_resource_route_collisions(
5858 &self,
5859 other: &Self,
5860 prefix: Option<&str>,
5861 behavior: crate::DuplicateBehavior,
5862 selection: MountSelection,
5863 result: &mut MountResult,
5864 ) {
5865 if !selection.includes_resources() {
5866 return;
5867 }
5868
5869 let mut final_resources: HashSet<String> = self.final_resources.keys().cloned().collect();
5870 let mut final_templates: HashMap<String, ReversibleResourceTemplate> = self
5871 .resource_templates
5872 .iter()
5873 .filter_map(|(uri_template, entry)| {
5874 entry
5875 .final_definition
5876 .as_ref()
5877 .zip(entry.matcher.as_ref())
5878 .map(|(_, matcher)| (uri_template.clone(), matcher.clone()))
5879 })
5880 .collect();
5881
5882 for uri in other.resources.keys() {
5883 let mounted_uri = Self::apply_prefix(uri, prefix);
5884 let replaces_destination = !self.resources.contains_key(&mounted_uri)
5885 || behavior == crate::DuplicateBehavior::Replace;
5886 if !replaces_destination {
5887 continue;
5888 }
5889 if Self::prefix_preserves_keys(prefix) && other.final_resources.contains_key(uri) {
5890 final_resources.insert(mounted_uri);
5891 } else {
5892 final_resources.remove(&mounted_uri);
5893 }
5894 }
5895
5896 for (uri_template, entry) in &other.resource_templates {
5897 let mounted_uri_template = Self::apply_prefix(uri_template, prefix);
5898 let replaces_destination = !self.resource_templates.contains_key(&mounted_uri_template)
5899 || behavior == crate::DuplicateBehavior::Replace;
5900 if !replaces_destination {
5901 continue;
5902 }
5903 if Self::prefix_preserves_keys(prefix)
5904 && let Some(matcher) = entry
5905 .final_definition
5906 .as_ref()
5907 .zip(entry.matcher.as_ref())
5908 .map(|(_, matcher)| matcher.clone())
5909 {
5910 final_templates.insert(mounted_uri_template, matcher);
5911 } else {
5912 final_templates.remove(&mounted_uri_template);
5913 }
5914 }
5915
5916 let mut template_routes: Vec<_> = final_templates.iter().collect();
5917 template_routes.sort_unstable_by_key(|(uri, _)| *uri);
5918 let mut exact_routes: Vec<_> = final_resources.iter().collect();
5919 exact_routes.sort_unstable();
5920 let mut collisions = Vec::new();
5921
5922 for (template_uri, matcher) in &template_routes {
5923 for exact_uri in &exact_routes {
5924 match matcher.match_uri(exact_uri) {
5925 Ok(Some(_)) => collisions.push(format!(
5926 "Mount rejected because a final resource template collides with an exact final resource; template_key={}; resource_key={}",
5927 safe_log_label(template_uri),
5928 safe_log_label(exact_uri)
5929 )),
5930 Ok(None) => {}
5931 Err(_) => collisions.push(format!(
5932 "Mount rejected because a final resource template lost match admission; template_key={}",
5933 safe_log_label(template_uri)
5934 )),
5935 }
5936 }
5937 }
5938
5939 for (index, (left_uri, left)) in template_routes.iter().enumerate() {
5940 for (right_uri, right) in template_routes.iter().skip(index + 1) {
5941 match reversible_templates_may_overlap(left, right) {
5942 Ok(true) => collisions.push(format!(
5943 "Mount rejected because final resource template languages collide; left_template_key={}; right_template_key={}",
5944 safe_log_label(left_uri),
5945 safe_log_label(right_uri)
5946 )),
5947 Ok(false) => {}
5948 Err(_) => collisions.push(format!(
5949 "Mount rejected because a final resource template lost match admission; template_key={}",
5950 safe_log_label(left_uri)
5951 )),
5952 }
5953 }
5954 }
5955
5956 collisions.sort_unstable();
5957 collisions.dedup();
5958 result.errors.extend(collisions);
5959 }
5960
5961 fn preflight_mcp_apps_mount_bindings(
5962 &self,
5963 other: &Self,
5964 prefix: Option<&str>,
5965 behavior: crate::DuplicateBehavior,
5966 selection: MountSelection,
5967 result: &mut MountResult,
5968 ) {
5969 let mut final_resources: HashMap<String, FinalResource> = self
5970 .final_resources
5971 .iter()
5972 .map(|(uri, registration)| (uri.clone(), registration.definition.clone()))
5973 .collect();
5974 let mut final_tools: HashMap<String, FinalTool> = self
5975 .tools
5976 .iter()
5977 .filter_map(|(name, registration)| {
5978 registration
5979 .final_registration
5980 .as_ref()
5981 .map(|final_registration| {
5982 (name.clone(), final_registration.final_definition.clone())
5983 })
5984 })
5985 .collect();
5986
5987 if selection.includes_resources() {
5988 for uri in other.resources.keys() {
5989 let mounted_uri = Self::apply_prefix(uri, prefix);
5990 let replacing = !self.resources.contains_key(&mounted_uri)
5991 || matches!(behavior, crate::DuplicateBehavior::Replace);
5992 if !replacing {
5993 continue;
5994 }
5995 let source_final = other.final_resources.get(uri);
5996 if !Self::prefix_preserves_keys(prefix)
5997 && source_final.is_some_and(|registration| {
5998 Self::mcp_apps_ui_resource_requires_special_admission(
5999 ®istration.definition,
6000 )
6001 .unwrap_or(true)
6002 })
6003 {
6004 result.errors.push(format!(
6005 "Mount rejected because a final-only MCP Apps UI resource cannot be prefixed; resource_key={}",
6006 safe_log_label(uri)
6007 ));
6008 continue;
6009 }
6010 if Self::prefix_preserves_keys(prefix) {
6011 if let Some(registration) = source_final {
6012 final_resources.insert(mounted_uri, registration.definition.clone());
6013 } else {
6014 final_resources.remove(&mounted_uri);
6015 }
6016 } else {
6017 final_resources.remove(&mounted_uri);
6018 }
6019 }
6020 }
6021
6022 if selection.includes_tools() {
6023 for (name, registration) in &other.tools {
6024 let mounted_name = Self::apply_prefix(name, prefix);
6025 let replacing = !self.tools.contains_key(&mounted_name)
6026 || matches!(behavior, crate::DuplicateBehavior::Replace);
6027 if !replacing {
6028 continue;
6029 }
6030 if let Some(final_registration) = registration.final_registration.as_ref() {
6031 let mut definition = final_registration.final_definition.clone();
6032 definition.name.clone_from(&mounted_name);
6033 final_tools.insert(mounted_name, definition);
6034 } else {
6035 final_tools.remove(&mounted_name);
6036 }
6037 }
6038 }
6039
6040 for (name, tool) in final_tools {
6041 let binding = match tool.mcp_apps_resource_binding() {
6042 Ok(binding) => binding,
6043 Err(error) => {
6044 result.errors.push(format!(
6045 "Mount rejected because tool has invalid MCP Apps metadata; tool_key={}; error={error}",
6046 safe_log_label(&name)
6047 ));
6048 continue;
6049 }
6050 };
6051 let Some(binding) = binding else {
6052 continue;
6053 };
6054 let Some(resource) = final_resources.get(binding.resource_uri.as_str()) else {
6055 result.errors.push(format!(
6056 "Mount rejected because an MCP Apps tool binding has no final HTML resource; tool_key={}; resource_uri={}",
6057 safe_log_label(&name),
6058 binding.resource_uri.as_str()
6059 ));
6060 continue;
6061 };
6062 if let Err(error) = binding.validate_resource(resource) {
6063 result.errors.push(format!(
6064 "Mount rejected because an MCP Apps tool binding is invalid; tool_key={}; error={error}",
6065 safe_log_label(&name)
6066 ));
6067 }
6068 }
6069 }
6070
6071 fn should_mount_duplicate(
6072 behavior: crate::DuplicateBehavior,
6073 kind: &'static str,
6074 key: &str,
6075 result: &mut MountResult,
6076 ) -> bool {
6077 match behavior {
6078 crate::DuplicateBehavior::Error => {
6079 result.errors.push(format!(
6080 "Mount rejected because {kind} already exists; component_key={}",
6081 safe_log_label(key)
6082 ));
6083 false
6084 }
6085 crate::DuplicateBehavior::Warn => {
6086 result.warnings.push(format!(
6087 "{kind} already exists, keeping original; component_key={}",
6088 safe_log_label(key)
6089 ));
6090 false
6091 }
6092 crate::DuplicateBehavior::Replace => {
6093 result.warnings.push(format!(
6094 "{kind} already exists, replacing original; component_key={}",
6095 safe_log_label(key)
6096 ));
6097 true
6098 }
6099 crate::DuplicateBehavior::Ignore => false,
6100 }
6101 }
6102
6103 pub fn mount(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6119 self.mount_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6120 }
6121
6122 pub fn mount_with_behavior(
6128 &mut self,
6129 other: Router,
6130 prefix: Option<&str>,
6131 behavior: crate::DuplicateBehavior,
6132 ) -> MountResult {
6133 let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::All);
6134 if !preflight.is_success() {
6135 return preflight;
6136 }
6137
6138 let mut result = preflight;
6139
6140 let Router {
6141 tools,
6142 tool_order,
6143 resources,
6144 final_only_resources,
6145 final_resources,
6146 resource_order,
6147 prompts,
6148 final_only_prompts,
6149 final_prompts,
6150 prompt_order,
6151 resource_templates,
6152 resource_template_order,
6153 ..
6154 } = other;
6155
6156 result.merge(self.mount_tools_from(tools, tool_order, prefix, behavior));
6158
6159 result.merge(self.mount_resources_from(
6161 resources,
6162 final_only_resources,
6163 final_resources,
6164 resource_order,
6165 prefix,
6166 behavior,
6167 ));
6168
6169 result.merge(self.mount_resource_templates_from(
6171 resource_templates,
6172 resource_template_order,
6173 prefix,
6174 behavior,
6175 ));
6176
6177 result.merge(self.mount_prompts_from(
6179 prompts,
6180 final_only_prompts,
6181 final_prompts,
6182 prompt_order,
6183 prefix,
6184 behavior,
6185 ));
6186
6187 if result.has_components() {
6189 debug!(
6190 target: targets::HANDLER,
6191 "mounted {} tools, {} resources, {} templates, {} prompts; prefix_present={}; prefix_key={}",
6192 result.tools,
6193 result.resources,
6194 result.resource_templates,
6195 result.prompts,
6196 prefix.is_some(),
6197 safe_log_label(prefix.unwrap_or_default())
6198 );
6199 self.advance_final_catalog_revision();
6200 }
6201
6202 result
6203 }
6204
6205 pub fn mount_namespaced_with_behavior(
6212 &mut self,
6213 other: Router,
6214 prefix: Option<&str>,
6215 behavior: crate::DuplicateBehavior,
6216 ) -> MountResult {
6217 let mut preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Tools);
6218 preflight.merge(self.mount_preflight(&other, prefix, behavior, MountSelection::Prompts));
6219 preflight.merge(self.mount_preflight(&other, None, behavior, MountSelection::Resources));
6220 if !preflight.is_success() {
6221 return preflight;
6222 }
6223
6224 let mut result = preflight;
6225 let Router {
6226 tools,
6227 tool_order,
6228 resources,
6229 final_only_resources,
6230 final_resources,
6231 resource_order,
6232 prompts,
6233 final_only_prompts,
6234 final_prompts,
6235 prompt_order,
6236 resource_templates,
6237 resource_template_order,
6238 ..
6239 } = other;
6240
6241 result.merge(self.mount_tools_from(tools, tool_order, prefix, behavior));
6242 result.merge(self.mount_resources_from(
6243 resources,
6244 final_only_resources,
6245 final_resources,
6246 resource_order,
6247 None,
6248 behavior,
6249 ));
6250 result.merge(self.mount_resource_templates_from(
6251 resource_templates,
6252 resource_template_order,
6253 None,
6254 behavior,
6255 ));
6256 result.merge(self.mount_prompts_from(
6257 prompts,
6258 final_only_prompts,
6259 final_prompts,
6260 prompt_order,
6261 prefix,
6262 behavior,
6263 ));
6264
6265 if result.has_components() {
6266 debug!(
6267 target: targets::HANDLER,
6268 "mounted namespaced {} tools, {} resources, {} templates, {} prompts; prefix_present={}; prefix_key={}",
6269 result.tools,
6270 result.resources,
6271 result.resource_templates,
6272 result.prompts,
6273 prefix.is_some(),
6274 safe_log_label(prefix.unwrap_or_default())
6275 );
6276 self.advance_final_catalog_revision();
6277 }
6278
6279 result
6280 }
6281
6282 pub fn mount_tools(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6284 self.mount_tools_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6285 }
6286
6287 pub fn mount_tools_with_behavior(
6289 &mut self,
6290 other: Router,
6291 prefix: Option<&str>,
6292 behavior: crate::DuplicateBehavior,
6293 ) -> MountResult {
6294 let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Tools);
6295 if !preflight.is_success() {
6296 return preflight;
6297 }
6298 let result = self.mount_tools_from(other.tools, other.tool_order, prefix, behavior);
6299 if result.has_components() {
6300 self.advance_final_catalog_revision();
6301 }
6302 result
6303 }
6304
6305 fn mount_tools_from(
6307 &mut self,
6308 mut tools: HashMap<String, AdmittedToolRegistration>,
6309 tool_order: Vec<String>,
6310 prefix: Option<&str>,
6311 behavior: crate::DuplicateBehavior,
6312 ) -> MountResult {
6313 let mut result = MountResult::default();
6314
6315 for name in tool_order {
6316 let Some(entry) = tools.remove(&name) else {
6317 continue;
6318 };
6319 let mounted_name = Self::apply_prefix(&name, prefix);
6320 trace!(
6321 target: targets::HANDLER,
6322 "mounting tool; source_key={}; mounted_key={}",
6323 safe_log_label(&name),
6324 safe_log_label(&mounted_name)
6325 );
6326
6327 let existed = self.tools.contains_key(&mounted_name);
6329 if existed
6330 && !Self::should_mount_duplicate(behavior, "Tool", &mounted_name, &mut result)
6331 {
6332 continue;
6333 }
6334
6335 let mounted = entry.with_mounted_name(mounted_name.clone());
6339 let needs_order_push = !existed && !self.tool_order.iter().any(|n| n == &mounted_name);
6340 self.tools.insert(mounted_name.clone(), mounted);
6341 if needs_order_push {
6342 self.tool_order.push(mounted_name);
6343 }
6344 result.tools += 1;
6345 }
6346
6347 if !tools.is_empty() {
6348 let mut remaining: Vec<(String, AdmittedToolRegistration)> =
6351 tools.into_iter().collect();
6352 remaining.sort_by(|a, b| a.0.cmp(&b.0));
6353 for (name, entry) in remaining {
6354 let mounted_name = Self::apply_prefix(&name, prefix);
6355
6356 let existed = self.tools.contains_key(&mounted_name);
6357 if existed
6358 && !Self::should_mount_duplicate(behavior, "Tool", &mounted_name, &mut result)
6359 {
6360 continue;
6361 }
6362
6363 let mounted = entry.with_mounted_name(mounted_name.clone());
6364 self.tools.insert(mounted_name.clone(), mounted);
6365 if !existed && !self.tool_order.iter().any(|n| n == &mounted_name) {
6366 self.tool_order.push(mounted_name);
6367 }
6368 result.tools += 1;
6369 }
6370 }
6371
6372 result
6373 }
6374
6375 pub fn mount_resources(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6377 self.mount_resources_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6378 }
6379
6380 pub fn mount_resources_with_behavior(
6383 &mut self,
6384 other: Router,
6385 prefix: Option<&str>,
6386 behavior: crate::DuplicateBehavior,
6387 ) -> MountResult {
6388 let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Resources);
6389 if !preflight.is_success() {
6390 return preflight;
6391 }
6392
6393 let Router {
6394 resources,
6395 final_only_resources,
6396 final_resources,
6397 resource_order,
6398 resource_templates,
6399 resource_template_order,
6400 ..
6401 } = other;
6402 let mut result = preflight;
6403 result.merge(self.mount_resources_from(
6404 resources,
6405 final_only_resources,
6406 final_resources,
6407 resource_order,
6408 prefix,
6409 behavior,
6410 ));
6411 let template_result = self.mount_resource_templates_from(
6412 resource_templates,
6413 resource_template_order,
6414 prefix,
6415 behavior,
6416 );
6417 result.merge(template_result);
6418 if result.has_components() {
6419 self.advance_final_catalog_revision();
6420 }
6421 result
6422 }
6423
6424 fn mount_resources_from(
6426 &mut self,
6427 mut resources: HashMap<String, BoxedResourceHandler>,
6428 final_only_resources: HashSet<String>,
6429 mut final_resources: HashMap<String, AdmittedFinalResourceRegistration>,
6430 resource_order: Vec<String>,
6431 prefix: Option<&str>,
6432 behavior: crate::DuplicateBehavior,
6433 ) -> MountResult {
6434 use crate::handler::MountedResourceHandler;
6435
6436 let mut result = MountResult::default();
6437
6438 for uri in resource_order {
6439 let Some(handler) = resources.remove(&uri) else {
6440 continue;
6441 };
6442 let mounted_uri = Self::apply_prefix(&uri, prefix);
6443 trace!(
6444 target: targets::HANDLER,
6445 "mounting resource; source_key={}; mounted_key={}",
6446 safe_log_label(&uri),
6447 safe_log_label(&mounted_uri)
6448 );
6449
6450 let existed = self.resources.contains_key(&mounted_uri);
6452 if existed
6453 && !Self::should_mount_duplicate(behavior, "Resource", &mounted_uri, &mut result)
6454 {
6455 continue;
6456 }
6457
6458 let mounted = MountedResourceHandler::new(handler, uri.clone(), mounted_uri.clone());
6460 let needs_order_push =
6461 !existed && !self.resource_order.iter().any(|u| u == &mounted_uri);
6462 self.resources
6463 .insert(mounted_uri.clone(), Box::new(mounted));
6464 if Self::prefix_preserves_keys(prefix) {
6465 if let Some(final_registration) = final_resources.remove(&uri) {
6466 self.final_resources
6467 .insert(mounted_uri.clone(), final_registration);
6468 } else {
6469 self.final_resources.remove(&mounted_uri);
6470 }
6471 } else {
6472 self.final_resources.remove(&mounted_uri);
6476 }
6477 if Self::prefix_preserves_keys(prefix) && final_only_resources.contains(&uri) {
6478 self.final_only_resources.insert(mounted_uri.clone());
6479 } else {
6480 self.final_only_resources.remove(&mounted_uri);
6481 }
6482 if needs_order_push {
6483 self.resource_order.push(mounted_uri);
6484 }
6485 result.resources += 1;
6486 }
6487
6488 if !resources.is_empty() {
6489 let mut remaining: Vec<(String, BoxedResourceHandler)> =
6490 resources.into_iter().collect();
6491 remaining.sort_by(|a, b| a.0.cmp(&b.0));
6492 for (uri, handler) in remaining {
6493 let mounted_uri = Self::apply_prefix(&uri, prefix);
6494
6495 let existed = self.resources.contains_key(&mounted_uri);
6496 if existed
6497 && !Self::should_mount_duplicate(
6498 behavior,
6499 "Resource",
6500 &mounted_uri,
6501 &mut result,
6502 )
6503 {
6504 continue;
6505 }
6506
6507 let mounted =
6508 MountedResourceHandler::new(handler, uri.clone(), mounted_uri.clone());
6509 self.resources
6510 .insert(mounted_uri.clone(), Box::new(mounted));
6511 if Self::prefix_preserves_keys(prefix) {
6512 if let Some(final_registration) = final_resources.remove(&uri) {
6513 self.final_resources
6514 .insert(mounted_uri.clone(), final_registration);
6515 } else {
6516 self.final_resources.remove(&mounted_uri);
6517 }
6518 } else {
6519 self.final_resources.remove(&mounted_uri);
6520 }
6521 if Self::prefix_preserves_keys(prefix) && final_only_resources.contains(&uri) {
6522 self.final_only_resources.insert(mounted_uri.clone());
6523 } else {
6524 self.final_only_resources.remove(&mounted_uri);
6525 }
6526 if !existed && !self.resource_order.iter().any(|u| u == &mounted_uri) {
6527 self.resource_order.push(mounted_uri);
6528 }
6529 result.resources += 1;
6530 }
6531 }
6532
6533 result
6534 }
6535
6536 fn mount_resource_templates_from(
6538 &mut self,
6539 mut templates: HashMap<String, ResourceTemplateEntry>,
6540 resource_template_order: Vec<String>,
6541 prefix: Option<&str>,
6542 behavior: crate::DuplicateBehavior,
6543 ) -> MountResult {
6544 use crate::handler::MountedResourceHandler;
6545
6546 let mut result = MountResult::default();
6547
6548 for uri_template in resource_template_order {
6549 let Some(entry) = templates.remove(&uri_template) else {
6550 continue;
6551 };
6552 let mounted_uri_template = Self::apply_prefix(&uri_template, prefix);
6553 trace!(
6554 target: targets::HANDLER,
6555 "mounting resource template; source_key={}; mounted_key={}",
6556 safe_log_label(&uri_template),
6557 safe_log_label(&mounted_uri_template)
6558 );
6559
6560 let existed = self.resource_templates.contains_key(&mounted_uri_template);
6562 if existed
6563 && !Self::should_mount_duplicate(
6564 behavior,
6565 "Resource template",
6566 &mounted_uri_template,
6567 &mut result,
6568 )
6569 {
6570 continue;
6571 }
6572
6573 let mut mounted_template = entry.template.clone();
6575 mounted_template.uri_template = mounted_uri_template.clone();
6576
6577 let mounted_handler = entry.handler.map(|h| {
6579 let wrapped: BoxedResourceHandler =
6580 Box::new(MountedResourceHandler::with_template(
6581 h,
6582 uri_template.clone(),
6583 mounted_uri_template.clone(),
6584 mounted_template.clone(),
6585 ));
6586 wrapped
6587 });
6588
6589 let legacy_matcher = if entry.legacy_enabled {
6591 match admit_legacy_resource_template(&mounted_uri_template) {
6592 Ok(matcher) => Some(matcher),
6593 Err(error) => {
6594 result.errors.push(format!(
6595 "Mount rejected exact-2024 resource template; template_key={}; code={:?}",
6596 safe_log_label(&mounted_uri_template),
6597 error.code
6598 ));
6599 continue;
6600 }
6601 }
6602 } else {
6603 None
6604 };
6605 let final_enabled =
6606 Self::prefix_preserves_keys(prefix) && entry.final_definition.is_some();
6607 let (matcher, specificity) = if final_enabled {
6608 match admit_resource_template(&mounted_uri_template) {
6609 Ok((matcher, specificity)) => (Some(matcher), specificity),
6610 Err(error) => {
6611 result.errors.push(format!(
6612 "Mount rejected resource template; template_key={}; code={:?}",
6613 safe_log_label(&mounted_uri_template),
6614 error.code
6615 ));
6616 continue;
6617 }
6618 }
6619 } else {
6620 let specificity = legacy_matcher.as_ref().map_or(
6621 entry.specificity,
6622 LegacyResourceTemplateMatcher::specificity,
6623 );
6624 (None, specificity)
6625 };
6626 let mounted_entry = ResourceTemplateEntry {
6627 matcher,
6628 legacy_matcher,
6629 specificity,
6630 template: mounted_template,
6631 handler: mounted_handler,
6632 final_definition: if Self::prefix_preserves_keys(prefix) {
6638 entry.final_definition.map(|mut definition| {
6639 definition.uri_template = mounted_uri_template.clone();
6640 definition
6641 })
6642 } else {
6643 None
6644 },
6645 uri_use_policy: entry.uri_use_policy,
6646 legacy_enabled: entry.legacy_enabled,
6647 };
6648
6649 let needs_order_push = !existed
6650 && !self
6651 .resource_template_order
6652 .iter()
6653 .any(|t| t == &mounted_uri_template);
6654 self.resource_templates
6655 .insert(mounted_uri_template.clone(), mounted_entry);
6656 if needs_order_push {
6657 self.resource_template_order.push(mounted_uri_template);
6658 }
6659 result.resource_templates += 1;
6660 }
6661
6662 if !templates.is_empty() {
6663 let mut remaining: Vec<(String, ResourceTemplateEntry)> =
6664 templates.into_iter().collect();
6665 remaining.sort_by(|a, b| a.0.cmp(&b.0));
6666 for (uri_template, entry) in remaining {
6667 let mounted_uri_template = Self::apply_prefix(&uri_template, prefix);
6668
6669 let existed = self.resource_templates.contains_key(&mounted_uri_template);
6670 if existed
6671 && !Self::should_mount_duplicate(
6672 behavior,
6673 "Resource template",
6674 &mounted_uri_template,
6675 &mut result,
6676 )
6677 {
6678 continue;
6679 }
6680
6681 let mut mounted_template = entry.template.clone();
6682 mounted_template.uri_template = mounted_uri_template.clone();
6683
6684 let mounted_handler = entry.handler.map(|h| {
6685 let wrapped: BoxedResourceHandler =
6686 Box::new(MountedResourceHandler::with_template(
6687 h,
6688 uri_template,
6689 mounted_uri_template.clone(),
6690 mounted_template.clone(),
6691 ));
6692 wrapped
6693 });
6694
6695 let legacy_matcher = if entry.legacy_enabled {
6696 match admit_legacy_resource_template(&mounted_uri_template) {
6697 Ok(matcher) => Some(matcher),
6698 Err(error) => {
6699 result.errors.push(format!(
6700 "Mount rejected exact-2024 resource template; template_key={}; code={:?}",
6701 safe_log_label(&mounted_uri_template),
6702 error.code
6703 ));
6704 continue;
6705 }
6706 }
6707 } else {
6708 None
6709 };
6710 let final_enabled =
6711 Self::prefix_preserves_keys(prefix) && entry.final_definition.is_some();
6712 let (matcher, specificity) = if final_enabled {
6713 match admit_resource_template(&mounted_uri_template) {
6714 Ok((matcher, specificity)) => (Some(matcher), specificity),
6715 Err(error) => {
6716 result.errors.push(format!(
6717 "Mount rejected resource template; template_key={}; code={:?}",
6718 safe_log_label(&mounted_uri_template),
6719 error.code
6720 ));
6721 continue;
6722 }
6723 }
6724 } else {
6725 let specificity = legacy_matcher.as_ref().map_or(
6726 entry.specificity,
6727 LegacyResourceTemplateMatcher::specificity,
6728 );
6729 (None, specificity)
6730 };
6731 let mounted_entry = ResourceTemplateEntry {
6732 matcher,
6733 legacy_matcher,
6734 specificity,
6735 template: mounted_template,
6736 handler: mounted_handler,
6737 final_definition: if Self::prefix_preserves_keys(prefix) {
6742 entry.final_definition.map(|mut definition| {
6743 definition.uri_template = mounted_uri_template.clone();
6744 definition
6745 })
6746 } else {
6747 None
6748 },
6749 uri_use_policy: entry.uri_use_policy,
6750 legacy_enabled: entry.legacy_enabled,
6751 };
6752
6753 self.resource_templates
6754 .insert(mounted_uri_template.clone(), mounted_entry);
6755 if !existed
6756 && !self
6757 .resource_template_order
6758 .iter()
6759 .any(|t| t == &mounted_uri_template)
6760 {
6761 self.resource_template_order
6762 .push(mounted_uri_template.clone());
6763 }
6764 result.resource_templates += 1;
6765 }
6766 }
6767
6768 if result.resource_templates > 0 {
6770 self.rebuild_sorted_template_keys();
6771 }
6772
6773 result
6774 }
6775
6776 pub fn mount_prompts(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6778 self.mount_prompts_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6779 }
6780
6781 pub fn mount_prompts_with_behavior(
6783 &mut self,
6784 other: Router,
6785 prefix: Option<&str>,
6786 behavior: crate::DuplicateBehavior,
6787 ) -> MountResult {
6788 let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Prompts);
6789 if !preflight.is_success() {
6790 return preflight;
6791 }
6792 let result = self.mount_prompts_from(
6793 other.prompts,
6794 other.final_only_prompts,
6795 other.final_prompts,
6796 other.prompt_order,
6797 prefix,
6798 behavior,
6799 );
6800 if result.has_components() {
6801 self.advance_final_catalog_revision();
6802 }
6803 result
6804 }
6805
6806 fn mount_prompts_from(
6808 &mut self,
6809 mut prompts: HashMap<String, BoxedPromptHandler>,
6810 final_only_prompts: HashSet<String>,
6811 mut final_prompts: HashMap<String, AdmittedFinalPromptRegistration>,
6812 prompt_order: Vec<String>,
6813 prefix: Option<&str>,
6814 behavior: crate::DuplicateBehavior,
6815 ) -> MountResult {
6816 use crate::handler::MountedPromptHandler;
6817
6818 let mut result = MountResult::default();
6819
6820 for name in prompt_order {
6821 let Some(handler) = prompts.remove(&name) else {
6822 continue;
6823 };
6824 let mounted_name = Self::apply_prefix(&name, prefix);
6825 trace!(
6826 target: targets::HANDLER,
6827 "mounting prompt; source_key={}; mounted_key={}",
6828 safe_log_label(&name),
6829 safe_log_label(&mounted_name)
6830 );
6831
6832 let existed = self.prompts.contains_key(&mounted_name);
6834 if existed
6835 && !Self::should_mount_duplicate(behavior, "Prompt", &mounted_name, &mut result)
6836 {
6837 continue;
6838 }
6839
6840 let mounted = MountedPromptHandler::new(handler, mounted_name.clone());
6842 let needs_order_push =
6843 !existed && !self.prompt_order.iter().any(|n| n == &mounted_name);
6844 self.prompts.insert(mounted_name.clone(), Box::new(mounted));
6845 match final_prompts.remove(&name) {
6846 Some(mut final_registration) => {
6847 final_registration.definition.name.clone_from(&mounted_name);
6848 self.final_prompts
6849 .insert(mounted_name.clone(), final_registration);
6850 }
6851 None => {
6852 self.final_prompts.remove(&mounted_name);
6853 }
6854 }
6855 if final_only_prompts.contains(&name) {
6856 self.final_only_prompts.insert(mounted_name.clone());
6857 } else {
6858 self.final_only_prompts.remove(&mounted_name);
6859 }
6860 if needs_order_push {
6861 self.prompt_order.push(mounted_name);
6862 }
6863 result.prompts += 1;
6864 }
6865
6866 if !prompts.is_empty() {
6867 let mut remaining: Vec<(String, BoxedPromptHandler)> = prompts.into_iter().collect();
6868 remaining.sort_by(|a, b| a.0.cmp(&b.0));
6869 for (name, handler) in remaining {
6870 let mounted_name = Self::apply_prefix(&name, prefix);
6871
6872 let existed = self.prompts.contains_key(&mounted_name);
6873 if existed
6874 && !Self::should_mount_duplicate(behavior, "Prompt", &mounted_name, &mut result)
6875 {
6876 continue;
6877 }
6878
6879 let mounted = MountedPromptHandler::new(handler, mounted_name.clone());
6880 self.prompts.insert(mounted_name.clone(), Box::new(mounted));
6881 match final_prompts.remove(&name) {
6882 Some(mut final_registration) => {
6883 final_registration.definition.name.clone_from(&mounted_name);
6884 self.final_prompts
6885 .insert(mounted_name.clone(), final_registration);
6886 }
6887 None => {
6888 self.final_prompts.remove(&mounted_name);
6889 }
6890 }
6891 if final_only_prompts.contains(&name) {
6892 self.final_only_prompts.insert(mounted_name.clone());
6893 } else {
6894 self.final_only_prompts.remove(&mounted_name);
6895 }
6896 if !existed && !self.prompt_order.iter().any(|n| n == &mounted_name) {
6897 self.prompt_order.push(mounted_name);
6898 }
6899 result.prompts += 1;
6900 }
6901 }
6902
6903 result
6904 }
6905
6906 #[must_use]
6910 #[allow(dead_code)]
6911 pub(crate) fn into_parts(
6912 self,
6913 ) -> (
6914 HashMap<String, BoxedToolHandler>,
6915 HashMap<String, BoxedResourceHandler>,
6916 HashMap<String, ResourceTemplateEntry>,
6917 HashMap<String, BoxedPromptHandler>,
6918 ) {
6919 let tools = self
6920 .tools
6921 .into_iter()
6922 .map(|(name, entry)| (name, entry.handler))
6923 .collect();
6924 (tools, self.resources, self.resource_templates, self.prompts)
6925 }
6926}
6927
6928struct ResolvedResource<'a> {
6929 handler: &'a BoxedResourceHandler,
6930 params: UriParams,
6931 final_enabled: bool,
6932 legacy_enabled: bool,
6933 uri_use_policy: ResourceUriUsePolicy,
6934}
6935
6936impl ResolvedResource<'_> {
6937 const fn is_enabled_in(&self, era: ProtocolEra) -> bool {
6938 match era {
6939 ProtocolEra::Legacy2024 => self.legacy_enabled,
6940 ProtocolEra::Modern2026 => self.final_enabled,
6941 }
6942 }
6943}
6944
6945pub(crate) struct ResourceTemplateEntry {
6947 pub(crate) matcher: Option<ReversibleResourceTemplate>,
6948 legacy_matcher: Option<LegacyResourceTemplateMatcher>,
6949 specificity: (usize, usize, usize),
6950 pub(crate) template: ResourceTemplate,
6951 pub(crate) handler: Option<BoxedResourceHandler>,
6952 final_definition: Option<FinalResourceTemplate>,
6953 uri_use_policy: ResourceUriUsePolicy,
6954 legacy_enabled: bool,
6955}
6956
6957#[derive(Debug, Clone)]
6962struct LegacyResourceTemplateMatcher {
6963 segments: Vec<LegacyResourceTemplateSegment>,
6964}
6965
6966#[derive(Debug, Clone)]
6967enum LegacyResourceTemplateSegment {
6968 Literal(String),
6969 Parameter(String),
6970}
6971
6972impl LegacyResourceTemplateMatcher {
6973 fn parse(pattern: &str) -> Result<Self, ()> {
6974 let mut segments = Vec::new();
6975 let mut literal = String::new();
6976 let mut chars = pattern.chars().peekable();
6977 let mut names = HashSet::new();
6978
6979 while let Some(character) = chars.next() {
6980 match character {
6981 '{' if matches!(chars.peek(), Some('{')) => {
6982 let _ = chars.next();
6983 literal.push('{');
6984 }
6985 '{' => {
6986 if !literal.is_empty() {
6987 segments.push(LegacyResourceTemplateSegment::Literal(std::mem::take(
6988 &mut literal,
6989 )));
6990 }
6991 let mut expression = String::new();
6992 let mut closed = false;
6993 for next in chars.by_ref() {
6994 if next == '}' {
6995 closed = true;
6996 break;
6997 }
6998 expression.push(next);
6999 }
7000 if !closed {
7001 return Err(());
7002 }
7003
7004 let name = expression.strip_prefix('+').unwrap_or(&expression);
7005 if name.is_empty()
7006 || name.starts_with('+')
7007 || matches!(
7008 expression.chars().next(),
7009 Some('#' | '.' | '/' | ';' | '?' | '&')
7010 )
7011 || name
7012 .chars()
7013 .any(|character| matches!(character, '*' | ':' | ','))
7014 || !names.insert(name.to_owned())
7015 {
7016 return Err(());
7017 }
7018 segments.push(LegacyResourceTemplateSegment::Parameter(name.to_owned()));
7019 }
7020 '}' if matches!(chars.peek(), Some('}')) => {
7021 let _ = chars.next();
7022 literal.push('}');
7023 }
7024 '}' => return Err(()),
7025 character => literal.push(character),
7026 }
7027 }
7028
7029 if !literal.is_empty() {
7030 segments.push(LegacyResourceTemplateSegment::Literal(literal));
7031 }
7032 Ok(Self { segments })
7033 }
7034
7035 fn specificity(&self) -> (usize, usize, usize) {
7036 let mut literal_bytes = 0usize;
7037 let mut literal_parts = 0usize;
7038 for segment in &self.segments {
7039 if let LegacyResourceTemplateSegment::Literal(literal) = segment {
7040 literal_bytes = literal_bytes.saturating_add(literal.len());
7041 literal_parts = literal_parts.saturating_add(1);
7042 }
7043 }
7044 (literal_bytes, literal_parts, self.segments.len())
7045 }
7046
7047 fn matches(&self, uri: &str) -> Option<UriParams> {
7048 let mut params = UriParams::new();
7049 let mut remainder = uri;
7050 let mut segments = self.segments.iter().peekable();
7051
7052 while let Some(segment) = segments.next() {
7053 match segment {
7054 LegacyResourceTemplateSegment::Literal(literal) => {
7055 remainder = remainder.strip_prefix(literal)?;
7056 }
7057 LegacyResourceTemplateSegment::Parameter(name) => {
7058 let next_literal = segments.peek().and_then(|next| match next {
7059 LegacyResourceTemplateSegment::Literal(literal) => Some(literal.as_str()),
7060 LegacyResourceTemplateSegment::Parameter(_) => None,
7061 });
7062 if next_literal.is_none() && segments.peek().is_some() {
7063 return None;
7064 }
7065
7066 let value = if let Some(literal) = next_literal {
7067 let index = remainder.find(literal)?;
7068 let value = &remainder[..index];
7069 remainder = &remainder[index..];
7070 value
7071 } else {
7072 if remainder.is_empty() {
7073 return None;
7074 }
7075 let parameter_count = self
7076 .segments
7077 .iter()
7078 .filter(|segment| {
7079 matches!(segment, LegacyResourceTemplateSegment::Parameter(_))
7080 })
7081 .count();
7082 let end = if parameter_count == 1 {
7083 remainder.len()
7084 } else {
7085 remainder.find('/').unwrap_or(remainder.len())
7086 };
7087 let value = &remainder[..end];
7088 remainder = &remainder[end..];
7089 value
7090 };
7091 if value.is_empty() {
7092 return None;
7093 }
7094 params.insert(name.clone(), legacy_percent_decode(value)?);
7095 }
7096 }
7097 }
7098
7099 remainder.is_empty().then_some(params)
7100 }
7101}
7102
7103fn admit_legacy_resource_template(source: &str) -> McpResult<LegacyResourceTemplateMatcher> {
7104 LegacyResourceTemplateMatcher::parse(source).map_err(|()| {
7105 McpError::invalid_params(
7106 "exact-2024 resource templates only admit unmodified {name} and {+name} parameters",
7107 )
7108 })
7109}
7110
7111fn legacy_percent_decode(input: &str) -> Option<String> {
7112 if !input.as_bytes().contains(&b'%') {
7113 return Some(input.to_owned());
7114 }
7115 let bytes = input.as_bytes();
7116 let mut output = Vec::with_capacity(bytes.len());
7117 let mut index = 0usize;
7118 while index < bytes.len() {
7119 match bytes[index] {
7120 b'%' if index + 2 < bytes.len() => {
7121 let high = legacy_hex_value(bytes[index + 1])?;
7122 let low = legacy_hex_value(bytes[index + 2])?;
7123 output.push((high << 4) | low);
7124 index += 3;
7125 }
7126 b'%' => return None,
7127 byte => {
7128 output.push(byte);
7129 index += 1;
7130 }
7131 }
7132 }
7133 String::from_utf8(output).ok()
7134}
7135
7136const fn legacy_hex_value(byte: u8) -> Option<u8> {
7137 match byte {
7138 b'0'..=b'9' => Some(byte - b'0'),
7139 b'a'..=b'f' => Some(byte - b'a' + 10),
7140 b'A'..=b'F' => Some(byte - b'A' + 10),
7141 _ => None,
7142 }
7143}
7144
7145fn admit_resource_template(
7148 source: &str,
7149) -> McpResult<(ReversibleResourceTemplate, (usize, usize, usize))> {
7150 let template = fastmcp_protocol::UriTemplate::parse(source)
7151 .map_err(|_| McpError::invalid_params("resource template is not valid RFC 6570"))?;
7152 let specificity = resource_template_specificity(&template);
7153 let matcher = template
7154 .compile_reversible()
7155 .map_err(|_| McpError::invalid_params("resource template cannot be matched reversibly"))?;
7156 Ok((matcher, specificity))
7157}
7158
7159fn resource_template_specificity(
7160 template: &fastmcp_protocol::UriTemplate,
7161) -> (usize, usize, usize) {
7162 let mut literal_bytes = 0usize;
7163 let mut literal_parts = 0usize;
7164 for part in template.parts() {
7165 if let fastmcp_protocol::UriTemplatePart::Literal(literal) = part {
7166 literal_bytes = literal_bytes.saturating_add(literal.len());
7167 literal_parts = literal_parts.saturating_add(1);
7168 }
7169 }
7170 (literal_bytes, literal_parts, template.parts().len())
7171}
7172
7173fn reversible_templates_may_overlap(
7181 left: &ReversibleResourceTemplate,
7182 right: &ReversibleResourceTemplate,
7183) -> McpResult<bool> {
7184 let left_prefix = reversible_template_leading_literal_prefix(left)?;
7185 let right_prefix = reversible_template_leading_literal_prefix(right)?;
7186 Ok(left_prefix.starts_with(&right_prefix) || right_prefix.starts_with(&left_prefix))
7187}
7188
7189fn reversible_template_leading_literal_prefix(
7190 matcher: &ReversibleResourceTemplate,
7191) -> McpResult<String> {
7192 let mut literal = String::new();
7193 for part in matcher.template().parts() {
7194 let UriTemplatePart::Literal(part) = part else {
7195 break;
7196 };
7197 literal.push_str(part);
7198 }
7199 fastmcp_protocol::UriTemplate::parse(&literal)
7200 .and_then(|template| template.expand(&fastmcp_protocol::TemplateValues::new()))
7201 .map_err(|_| McpError::internal_error("admitted resource template lost literal prefix"))
7202}
7203
7204use fastmcp_core::{
7209 MAX_RESOURCE_READ_DEPTH, ResourceContentItem, ResourceReadResult, ResourceReader,
7210};
7211use std::pin::Pin;
7212
7213#[derive(Clone)]
7218enum RouterAccess {
7219 Shared(Arc<Router>),
7220 RequestScoped(Weak<Router>),
7221}
7222
7223impl RouterAccess {
7224 fn upgrade(&self) -> McpResult<Arc<Router>> {
7225 match self {
7226 Self::Shared(router) => Ok(Arc::clone(router)),
7227 Self::RequestScoped(router) => router.upgrade().ok_or_else(|| {
7228 McpError::new(
7229 McpErrorCode::RequestCancelled,
7230 "Request router is no longer available",
7231 )
7232 }),
7233 }
7234 }
7235}
7236
7237pub(crate) struct RouterResourceReader {
7238 router: RouterAccess,
7240 session_state: SessionState,
7242}
7243
7244impl RouterResourceReader {
7245 #[must_use]
7247 pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
7248 Self {
7249 router: RouterAccess::Shared(router),
7250 session_state,
7251 }
7252 }
7253
7254 pub(crate) fn request_scoped(router: Weak<Router>, session_state: SessionState) -> Self {
7255 Self {
7256 router: RouterAccess::RequestScoped(router),
7257 session_state,
7258 }
7259 }
7260
7261 fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
7262 Self {
7263 router,
7264 session_state,
7265 }
7266 }
7267}
7268
7269impl ResourceReader for RouterResourceReader {
7270 fn read_resource<'a>(
7271 &'a self,
7272 parent_ctx: &'a McpContext,
7273 uri: &'a str,
7274 depth: u32,
7275 ) -> Pin<
7276 Box<
7277 dyn std::future::Future<Output = fastmcp_core::McpResult<ResourceReadResult>>
7278 + Send
7279 + 'a,
7280 >,
7281 > {
7282 if depth > MAX_RESOURCE_READ_DEPTH {
7284 return Box::pin(async move {
7285 Err(McpError::new(
7286 McpErrorCode::InternalError,
7287 format!(
7288 "Maximum resource read depth ({}) exceeded",
7289 MAX_RESOURCE_READ_DEPTH
7290 ),
7291 ))
7292 });
7293 }
7294
7295 let parent_ctx = parent_ctx.clone();
7297 let uri = uri.to_string();
7298 let router_access = self.router.clone();
7299 let session_state = self.session_state.clone();
7300
7301 Box::pin(async move {
7302 debug!(
7303 target: targets::HANDLER,
7304 "cross-component resource read; resource_key={}; depth={}; request={}",
7305 safe_log_label(&uri),
7306 depth,
7307 parent_ctx.request_id()
7308 );
7309 let router = router_access.upgrade()?;
7310 let operation_started_at = parent_ctx.cx().now();
7311 if let Some(error) = budget_error(&parent_ctx) {
7312 return Err(error);
7313 }
7314 if !session_state.is_resource_enabled(&uri) {
7315 return Err(McpError::new(
7316 McpErrorCode::ResourceNotFound,
7317 format!("Resource '{}' is disabled for this session", uri),
7318 ));
7319 }
7320
7321 let resolved = router.resolve_resource(&uri).ok_or_else(|| {
7323 McpError::new(
7324 McpErrorCode::ResourceNotFound,
7325 format!("Resource not found: {}", uri),
7326 )
7327 })?;
7328 let handler_timeout =
7329 read_handler_timeout(parent_ctx.cx(), "resource_timeout", || {
7330 resolved.handler.timeout()
7331 })?;
7332 let effective_budget = compose_handler_budget(
7333 parent_ctx.cx().budget(),
7334 parent_ctx.budget(),
7335 handler_timeout,
7336 operation_started_at,
7337 );
7338
7339 let child_ctx = with_nested_component_access(
7342 parent_ctx
7343 .clone()
7344 .with_operation_deadline(effective_budget.deadline),
7345 router_access.clone(),
7346 session_state.clone(),
7347 depth,
7348 );
7349
7350 let outcome = run_handler_in_request(
7354 &child_ctx,
7355 parent_ctx.cx(),
7356 effective_budget,
7357 "resource",
7358 |request_cx| {
7359 resolved.handler.read_async_with_uri_in_request(
7360 &child_ctx,
7361 request_cx,
7362 &uri,
7363 &resolved.params,
7364 )
7365 },
7366 )
7367 .await?;
7368
7369 let contents = match outcome {
7371 Outcome::Ok(contents) => contents,
7372 Outcome::Err(error) => {
7373 return Err(sanitize_handler_error(parent_ctx.cx(), "resource", error));
7374 }
7375 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
7376 Outcome::Panicked(_payload) => {
7377 return Err(sanitized_handler_panic(parent_ctx.cx(), "resource"));
7378 }
7379 };
7380
7381 let items: Vec<ResourceContentItem> = contents
7383 .into_iter()
7384 .map(|c| ResourceContentItem {
7385 uri: c.uri,
7386 mime_type: c.mime_type,
7387 text: c.text,
7388 blob: c.blob,
7389 })
7390 .collect();
7391
7392 Ok(ResourceReadResult::new(items))
7393 })
7394 }
7395}
7396
7397use fastmcp_core::{MAX_TOOL_CALL_DEPTH, ToolCallResult, ToolCaller, ToolContentItem};
7402
7403pub(crate) struct RouterToolCaller {
7408 router: RouterAccess,
7410 session_state: SessionState,
7412}
7413
7414impl RouterToolCaller {
7415 #[must_use]
7417 pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
7418 Self {
7419 router: RouterAccess::Shared(router),
7420 session_state,
7421 }
7422 }
7423
7424 pub(crate) fn request_scoped(router: Weak<Router>, session_state: SessionState) -> Self {
7425 Self {
7426 router: RouterAccess::RequestScoped(router),
7427 session_state,
7428 }
7429 }
7430
7431 fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
7432 Self {
7433 router,
7434 session_state,
7435 }
7436 }
7437}
7438
7439impl ToolCaller for RouterToolCaller {
7440 fn call_tool<'a>(
7441 &'a self,
7442 parent_ctx: &'a McpContext,
7443 name: &'a str,
7444 args: serde_json::Value,
7445 depth: u32,
7446 ) -> Pin<
7447 Box<dyn std::future::Future<Output = fastmcp_core::McpResult<ToolCallResult>> + Send + 'a>,
7448 > {
7449 if depth > MAX_TOOL_CALL_DEPTH {
7451 return Box::pin(async move {
7452 Err(McpError::new(
7453 McpErrorCode::InternalError,
7454 format!("Maximum tool call depth ({}) exceeded", MAX_TOOL_CALL_DEPTH),
7455 ))
7456 });
7457 }
7458
7459 let parent_ctx = parent_ctx.clone();
7461 let name = name.to_string();
7462 let router_access = self.router.clone();
7463 let session_state = self.session_state.clone();
7464
7465 Box::pin(async move {
7466 debug!(
7467 target: targets::HANDLER,
7468 "cross-component tool call; tool_key={}; depth={}; request={}",
7469 safe_log_label(&name),
7470 depth,
7471 parent_ctx.request_id()
7472 );
7473 let router = router_access.upgrade()?;
7474 let operation_started_at = parent_ctx.cx().now();
7475 if let Some(error) = budget_error(&parent_ctx) {
7476 return Err(error);
7477 }
7478 if !session_state.is_tool_enabled(&name) {
7479 return Err(McpError::new(
7480 McpErrorCode::MethodNotFound,
7481 format!("Tool '{}' is disabled for this session", name),
7482 ));
7483 }
7484
7485 let entry = router
7487 .tools
7488 .get(&name)
7489 .ok_or_else(|| McpError::method_not_found(&format!("tool: {}", name)))?;
7490 if !entry.legacy_enabled {
7491 return Err(McpError::method_not_found(&format!("tool: {}", name)));
7492 }
7493 let handler = &entry.handler;
7494
7495 let validation_result = if router.strict_input_validation {
7498 validate_strict(&entry.definition.input_schema, &args)
7499 } else {
7500 validate(&entry.definition.input_schema, &args)
7501 };
7502
7503 if let Err(validation_errors) = validation_result {
7504 let error_messages: Vec<String> = validation_errors
7505 .iter()
7506 .map(|e| format!("{}: {}", e.path, e.message))
7507 .collect();
7508 return Err(McpError::invalid_params(format!(
7509 "Input validation failed: {}",
7510 error_messages.join("; ")
7511 )));
7512 }
7513 let handler_timeout =
7514 read_handler_timeout(parent_ctx.cx(), "tool_timeout", || handler.timeout())?;
7515 let effective_budget = compose_handler_budget(
7516 parent_ctx.cx().budget(),
7517 parent_ctx.budget(),
7518 handler_timeout,
7519 operation_started_at,
7520 );
7521
7522 let child_ctx = with_nested_component_access(
7525 parent_ctx
7526 .clone()
7527 .with_operation_deadline(effective_budget.deadline),
7528 router_access.clone(),
7529 session_state.clone(),
7530 depth,
7531 );
7532
7533 let outcome = run_handler_in_request(
7537 &child_ctx,
7538 parent_ctx.cx(),
7539 effective_budget,
7540 "tool",
7541 |request_cx| handler.call_async_in_request(&child_ctx, request_cx, args),
7542 )
7543 .await?;
7544
7545 match outcome {
7547 Outcome::Ok(content) => {
7548 let items: Vec<ToolContentItem> = content
7550 .into_iter()
7551 .map(|c| match c {
7552 Content::Text { text } => ToolContentItem::Text { text },
7553 Content::Image { data, mime_type } => {
7554 ToolContentItem::Image { data, mime_type }
7555 }
7556 Content::Audio { data, mime_type } => {
7557 ToolContentItem::Audio { data, mime_type }
7558 }
7559 Content::Resource { resource } => ToolContentItem::Resource {
7560 uri: resource.uri,
7561 mime_type: resource.mime_type,
7562 text: resource.text,
7563 blob: resource.blob,
7564 },
7565 })
7566 .collect();
7567
7568 Ok(ToolCallResult::success(items))
7569 }
7570 Outcome::Err(e) => {
7571 let e = sanitize_handler_error(parent_ctx.cx(), "tool", e);
7572 if is_framework_terminal_tool_error(e.code) {
7573 return Err(e);
7574 }
7575 Ok(ToolCallResult::error(e.message))
7577 }
7578 Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
7579 Outcome::Panicked(_payload) => {
7580 Err(sanitized_handler_panic(parent_ctx.cx(), "tool"))
7581 }
7582 }
7583 })
7584 }
7585}
7586
7587fn with_nested_component_access(
7588 ctx: McpContext,
7589 router: RouterAccess,
7590 state: SessionState,
7591 depth: u32,
7592) -> McpContext {
7593 ctx.with_resource_read_depth(depth)
7594 .with_tool_call_depth(depth)
7595 .with_prompt_get_depth(depth)
7596 .with_tool_caller(Arc::new(RouterToolCaller::from_access(
7597 router.clone(),
7598 state.clone(),
7599 )))
7600 .with_resource_reader(Arc::new(RouterResourceReader::from_access(
7601 router.clone(),
7602 state.clone(),
7603 )))
7604 .with_prompt_caller(Arc::new(RouterPromptCaller::from_access(router, state)))
7605}
7606
7607pub(crate) struct RouterPromptCaller {
7612 router: RouterAccess,
7613 session_state: SessionState,
7614}
7615
7616impl RouterPromptCaller {
7617 #[must_use]
7618 pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
7619 Self {
7620 router: RouterAccess::Shared(router),
7621 session_state,
7622 }
7623 }
7624
7625 fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
7626 Self {
7627 router,
7628 session_state,
7629 }
7630 }
7631}
7632
7633impl PromptCaller for RouterPromptCaller {
7634 fn get_prompt<'a>(
7635 &'a self,
7636 parent_ctx: &'a McpContext,
7637 name: &'a str,
7638 arguments: HashMap<String, String>,
7639 depth: u32,
7640 ) -> Pin<
7641 Box<dyn std::future::Future<Output = fastmcp_core::McpResult<PromptGetResult>> + Send + 'a>,
7642 > {
7643 if depth > fastmcp_core::MAX_PROMPT_GET_DEPTH {
7644 return Box::pin(async move {
7645 Err(McpError::new(
7646 McpErrorCode::InternalError,
7647 format!(
7648 "Maximum prompt get depth ({}) exceeded",
7649 fastmcp_core::MAX_PROMPT_GET_DEPTH
7650 ),
7651 ))
7652 });
7653 }
7654
7655 let parent_ctx = parent_ctx.clone();
7656 let name = name.to_string();
7657 let router_access = self.router.clone();
7658 let session_state = self.session_state.clone();
7659
7660 Box::pin(async move {
7661 debug!(
7662 target: targets::HANDLER,
7663 "cross-component prompt get; prompt_key={}; depth={}; request={}",
7664 safe_log_label(&name),
7665 depth,
7666 parent_ctx.request_id()
7667 );
7668 let router = router_access.upgrade()?;
7669 let operation_started_at = parent_ctx.cx().now();
7670 if let Some(error) = budget_error(&parent_ctx) {
7671 return Err(error);
7672 }
7673 if !session_state.is_prompt_enabled(&name) {
7674 return Err(McpError::new(
7675 McpErrorCode::PromptNotFound,
7676 format!("Prompt '{name}' is disabled for this session"),
7677 ));
7678 }
7679 let handler = router.prompts.get(&name).ok_or_else(|| {
7680 McpError::new(
7681 McpErrorCode::PromptNotFound,
7682 format!("Prompt not found: {name}"),
7683 )
7684 })?;
7685 if router.final_only_prompts.contains(&name) {
7686 return Err(McpError::new(
7687 McpErrorCode::PromptNotFound,
7688 format!("Prompt not found: {name}"),
7689 ));
7690 }
7691 let description = crate::catch_extension_unwind(|| handler.definition().description)
7692 .map_err(|_payload| {
7693 sanitized_handler_panic(parent_ctx.cx(), "prompt_definition")
7694 })?;
7695 let handler_timeout =
7696 read_handler_timeout(parent_ctx.cx(), "prompt_timeout", || handler.timeout())?;
7697 let effective_budget = compose_handler_budget(
7698 parent_ctx.cx().budget(),
7699 parent_ctx.budget(),
7700 handler_timeout,
7701 operation_started_at,
7702 );
7703 let child_ctx = with_nested_component_access(
7704 parent_ctx
7705 .clone()
7706 .with_operation_deadline(effective_budget.deadline),
7707 router_access,
7708 session_state,
7709 depth,
7710 );
7711 let outcome = run_handler_in_request(
7712 &child_ctx,
7713 parent_ctx.cx(),
7714 effective_budget,
7715 "prompt",
7716 |request_cx| handler.get_async_in_request(&child_ctx, request_cx, arguments),
7717 )
7718 .await?;
7719 let messages = match outcome {
7720 Outcome::Ok(messages) => messages,
7721 Outcome::Err(error) => {
7722 return Err(sanitize_handler_error(parent_ctx.cx(), "prompt", error));
7723 }
7724 Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
7725 Outcome::Panicked(_payload) => {
7726 return Err(sanitized_handler_panic(parent_ctx.cx(), "prompt"));
7727 }
7728 };
7729 Ok(PromptGetResult {
7730 description,
7731 messages: messages
7732 .into_iter()
7733 .map(|message| PromptMessageItem {
7734 role: match message.role {
7735 Role::User => PromptMessageRole::User,
7736 Role::Assistant => PromptMessageRole::Assistant,
7737 },
7738 text: match message.content {
7739 Content::Text { text } => Some(text),
7740 _ => None,
7741 },
7742 })
7743 .collect(),
7744 })
7745 })
7746 }
7747}
7748
7749#[cfg(test)]
7750mod safe_log_label_tests {
7751 use super::{LOG_LABEL_HASH_INPUT_LIMIT, safe_log_label};
7752
7753 #[test]
7754 fn safe_label_is_deterministic_non_verbatim_metadata() {
7755 let canary = "router-log-canary-secret";
7756 let first = format!("{}", safe_log_label(canary));
7757 let second = format!("{:?}", safe_log_label(canary));
7758
7759 assert_eq!(first, second);
7760 assert!(first.contains(&format!("bytes={}", canary.len())));
7761 assert!(first.contains("sha256_prefix="));
7762 assert!(!first.contains(canary));
7763 assert_ne!(first, format!("{}", safe_log_label("different-label")));
7764 }
7765
7766 #[test]
7767 fn safe_label_hashing_is_bounded_for_oversized_input() {
7768 let oversized = "x".repeat(LOG_LABEL_HASH_INPUT_LIMIT + 37);
7769 let rendered = format!("{}", safe_log_label(&oversized));
7770
7771 assert!(rendered.contains(&format!("bytes={}", oversized.len())));
7772 assert!(rendered.contains(&format!("hashed_prefix_bytes={LOG_LABEL_HASH_INPUT_LIMIT}")));
7773 assert!(!rendered.contains(&oversized));
7774 }
7775
7776 #[test]
7777 fn source_has_no_verbatim_argument_or_label_log_formats() {
7778 let source = include_str!("router.rs");
7779 let forbidden = [
7780 concat!("Tool ", "arguments:"),
7781 concat!("Prompt ", "arguments:"),
7782 concat!("Calling ", "tool: {}"),
7783 concat!("Reading ", "resource: {}"),
7784 concat!("Getting ", "prompt: {}"),
7785 concat!("Cross-component tool ", "call: {}"),
7786 concat!("Cross-component resource ", "read: {}"),
7787 concat!("Invalid URI template ", "'{}'"),
7788 ];
7789
7790 for format in forbidden {
7791 assert!(!source.contains(format), "raw log format remains: {format}");
7792 }
7793 }
7794}
7795
7796#[cfg(test)]
7797mod cursor_tests {
7798 use super::{
7799 FinalCatalogKind, FinalCatalogQuery, decode_cursor_offset,
7800 decode_final_catalog_cursor_offset, encode_cursor_offset, encode_final_catalog_cursor,
7801 };
7802
7803 #[test]
7804 fn roundtrip_zero() {
7805 let encoded = encode_cursor_offset(0);
7806 let decoded = decode_cursor_offset(Some(&encoded)).unwrap();
7807 assert_eq!(decoded, 0);
7808 }
7809
7810 #[test]
7811 fn roundtrip_large_offset() {
7812 let encoded = encode_cursor_offset(12345);
7813 let decoded = decode_cursor_offset(Some(&encoded)).unwrap();
7814 assert_eq!(decoded, 12345);
7815 }
7816
7817 #[test]
7818 fn none_cursor_returns_zero() {
7819 assert_eq!(decode_cursor_offset(None).unwrap(), 0);
7820 }
7821
7822 #[test]
7823 fn invalid_base64_returns_error() {
7824 let err = decode_cursor_offset(Some("not-valid-base64!!!")).unwrap_err();
7825 assert!(err.message.contains("base64"));
7826 }
7827
7828 #[test]
7829 fn valid_base64_but_not_json_returns_error() {
7830 let encoded =
7831 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"not json");
7832 let err = decode_cursor_offset(Some(&encoded)).unwrap_err();
7833 assert!(err.message.contains("JSON"));
7834 }
7835
7836 #[test]
7837 fn valid_json_but_no_offset_returns_error() {
7838 let payload = serde_json::json!({"other": 1});
7839 let bytes = serde_json::to_vec(&payload).unwrap();
7840 let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
7841 let err = decode_cursor_offset(Some(&encoded)).unwrap_err();
7842 assert!(err.message.contains("offset"));
7843 }
7844
7845 #[test]
7846 fn final_catalog_cursor_binds_the_catalog_revision_and_query() {
7847 let include_tags = vec!["Visible".to_owned(), "visible".to_owned()];
7848 let exclude_tags = vec!["excluded".to_owned()];
7849 let query = FinalCatalogQuery::from_tag_filters(Some(&include_tags), Some(&exclude_tags));
7850 let cursor = encode_final_catalog_cursor(FinalCatalogKind::Resources, 41, &query, 7);
7851 let equivalent_include_tags = vec!["visible".to_owned()];
7852 let equivalent_query = FinalCatalogQuery::from_tag_filters(
7853 Some(&equivalent_include_tags),
7854 Some(&exclude_tags),
7855 );
7856 assert_eq!(
7857 decode_final_catalog_cursor_offset(
7858 Some(&cursor),
7859 FinalCatalogKind::Resources,
7860 41,
7861 &equivalent_query,
7862 8,
7863 )
7864 .expect("a final cursor accepts a semantically equivalent canonical query"),
7865 7
7866 );
7867 let stale = decode_final_catalog_cursor_offset(
7868 Some(&cursor),
7869 FinalCatalogKind::Resources,
7870 42,
7871 &query,
7872 8,
7873 )
7874 .expect_err("changing only the catalog revision rejects a stale continuation");
7875 assert!(stale.message.contains("stale catalog revision"));
7876
7877 let wrong_kind = decode_final_catalog_cursor_offset(
7878 Some(&cursor),
7879 FinalCatalogKind::Prompts,
7880 41,
7881 &query,
7882 8,
7883 )
7884 .expect_err("changing only the list method rejects a cross-catalog continuation");
7885 assert!(wrong_kind.message.contains("another list method"));
7886
7887 let other_include_tags = vec!["other".to_owned()];
7888 let other_query =
7889 FinalCatalogQuery::from_tag_filters(Some(&other_include_tags), Some(&exclude_tags));
7890 let wrong_query = decode_final_catalog_cursor_offset(
7891 Some(&cursor),
7892 FinalCatalogKind::Resources,
7893 41,
7894 &other_query,
7895 8,
7896 )
7897 .expect_err("changing only the request filters rejects the continuation");
7898 assert!(wrong_query.message.contains("query filters"));
7899
7900 let out_of_range = encode_final_catalog_cursor(FinalCatalogKind::Resources, 41, &query, 8);
7901 let range_error = decode_final_catalog_cursor_offset(
7902 Some(&out_of_range),
7903 FinalCatalogKind::Resources,
7904 41,
7905 &query,
7906 8,
7907 )
7908 .expect_err("an offset at the end of a catalog is never a router-minted continuation");
7909 assert!(
7910 range_error
7911 .message
7912 .contains("outside the requested catalog page")
7913 );
7914 }
7915}
7916
7917#[cfg(test)]
7918mod tag_filter_tests {
7919 use super::TagFilters;
7920
7921 #[test]
7922 fn no_filters_matches_anything() {
7923 let f = TagFilters::default();
7924 assert!(f.matches(&[]));
7925 assert!(f.matches(&["a".to_string()]));
7926 }
7927
7928 #[test]
7929 fn include_filter_requires_all_tags() {
7930 let include = vec!["a".to_string(), "b".to_string()];
7931 let f = TagFilters::new(Some(&include), None);
7932 assert!(f.matches(&["a".to_string(), "b".to_string(), "c".to_string()]));
7933 assert!(!f.matches(&["a".to_string()])); }
7935
7936 #[test]
7937 fn exclude_filter_rejects_any_tag() {
7938 let exclude = vec!["x".to_string()];
7939 let f = TagFilters::new(None, Some(&exclude));
7940 assert!(f.matches(&["a".to_string(), "b".to_string()]));
7941 assert!(!f.matches(&["a".to_string(), "x".to_string()]));
7942 }
7943
7944 #[test]
7945 fn include_and_exclude_combined() {
7946 let include = vec!["a".to_string()];
7947 let exclude = vec!["b".to_string()];
7948 let f = TagFilters::new(Some(&include), Some(&exclude));
7949 assert!(f.matches(&["a".to_string()]));
7950 assert!(!f.matches(&["a".to_string(), "b".to_string()])); assert!(!f.matches(&["c".to_string()])); }
7953
7954 #[test]
7955 fn case_insensitive_matching() {
7956 let include = vec!["Alpha".to_string()];
7957 let f = TagFilters::new(Some(&include), None);
7958 assert!(f.matches(&["alpha".to_string()]));
7959 assert!(f.matches(&["ALPHA".to_string()]));
7960 }
7961
7962 #[test]
7963 fn empty_include_array_passes_all() {
7964 let include: Vec<String> = vec![];
7965 let f = TagFilters::new(Some(&include), None);
7966 assert!(f.matches(&[]));
7967 assert!(f.matches(&["anything".to_string()]));
7968 }
7969
7970 #[test]
7971 fn tag_filters_debug() {
7972 let f = TagFilters::default();
7973 let debug = format!("{:?}", f);
7974 assert!(debug.contains("TagFilters"));
7975 }
7976}
7977
7978#[cfg(test)]
7979mod router_tests {
7980 use super::*;
7981 use crate::bidirectional::MrtrInputResponse;
7982 use crate::handler::{
7983 CompletionHandler, DEFAULT_FINAL_RESOURCE_TTL_MS, FinalElicitationContextExt,
7984 FinalToolSchemaAuthority, PromptHandler, ResourceHandler, ToolHandler,
7985 UpstreamFinalToolSchemaRegistration,
7986 };
7987 use crate::http_admission::{HttpAdmissionLimits, HttpEndpointConfig, admit_modern_post};
7988 #[cfg(feature = "tasks")]
7989 use crate::tasks::{
7990 ApplicationTaskSupervisor, FinalTaskSupervisorFuture, FinalTaskSupervisorHandoff,
7991 FinalTaskWorkDescriptor,
7992 };
7993 #[cfg(feature = "tasks")]
7994 use crate::{FinalTaskRuntimeConfig, FinalTaskStore, InMemoryFinalTaskStore};
7995 use asupersync::channel::oneshot;
7996 use asupersync::runtime::{RuntimeBuilder, RuntimeHandle};
7997 use asupersync::types::CancelKind;
7998 use fastmcp_core::{ClientCapabilityInfo, McpContext, McpResult, SessionState};
7999 use fastmcp_protocol::common_types::{
8000 Annotations, ContentBlock, EmbeddedResourceContents, OpenMetadata, RawIcon,
8001 };
8002 use fastmcp_protocol::{
8003 CompleteResult, CompletionValues, Content, CoreResultDiscriminatorPolicy, DecodedResult,
8004 FinalCallToolResult, FinalCompletionParams, FinalGetPromptResult, FinalPromptMessage,
8005 LegacyCompletionParams, LegacyResourceContent, Prompt, PromptArgument, PromptMessage,
8006 Resource, ResourceContent, ResourceTemplate, ResultPeerEra, Tool, decode_peer_result,
8007 };
8008 use std::collections::BTreeMap;
8009 use std::fmt;
8010 #[cfg(feature = "tasks")]
8011 use std::future::Future;
8012 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8013 use std::sync::{Arc, Mutex};
8014 use std::task::Poll;
8015
8016 fn request_context(
8017 cx: &Cx,
8018 request_id: u64,
8019 budget: Budget,
8020 state: &SessionState,
8021 ) -> McpContext {
8022 McpContext::with_state(cx.clone(), request_id, state.clone()).with_budget_ceiling(budget)
8023 }
8024
8025 async fn yield_once() {
8026 let mut yielded = false;
8027 std::future::poll_fn(|task_cx| {
8028 if std::mem::replace(&mut yielded, true) {
8029 Poll::Ready(())
8030 } else {
8031 task_cx.waker().wake_by_ref();
8032 Poll::Pending
8033 }
8034 })
8035 .await;
8036 }
8037
8038 #[test]
8039 fn legacy_result_adapters_emit_exact_2024_defaults() {
8040 let tool = CallToolResult {
8041 content: legacy_contents_from_handler(vec![
8042 Content::Text {
8043 text: "ready".to_owned(),
8044 },
8045 Content::Image {
8046 data: "aGVsbG8=".to_owned(),
8047 mime_type: "image/png".to_owned(),
8048 },
8049 Content::Resource {
8050 resource: ResourceContent {
8051 uri: "file:///tool.txt".to_owned(),
8052 mime_type: Some("text/plain".to_owned()),
8053 text: Some("tool resource".to_owned()),
8054 blob: None,
8055 },
8056 },
8057 ])
8058 .expect("handler content is representable by the exact legacy union"),
8059 is_error: false,
8060 meta: None,
8061 additional: BTreeMap::new(),
8062 };
8063 assert_eq!(
8064 serde_json::to_value(tool).expect("legacy tool result serializes"),
8065 serde_json::json!({
8066 "content": [
8067 {"type": "text", "text": "ready"},
8068 {"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"},
8069 {
8070 "type": "resource",
8071 "resource": {
8072 "uri": "file:///tool.txt",
8073 "mimeType": "text/plain",
8074 "text": "tool resource"
8075 }
8076 }
8077 ]
8078 })
8079 );
8080
8081 let resource = ReadResourceResult {
8082 contents: legacy_resource_contents_from_handler(vec![ResourceContent {
8083 uri: "file:///report.bin".to_owned(),
8084 mime_type: Some("application/octet-stream".to_owned()),
8085 text: None,
8086 blob: Some("AAEC".to_owned()),
8087 }])
8088 .expect("handler resource is representable by the exact legacy union"),
8089 meta: None,
8090 additional: BTreeMap::new(),
8091 };
8092 assert_eq!(
8093 serde_json::to_value(resource).expect("legacy resource result serializes"),
8094 serde_json::json!({
8095 "contents": [{
8096 "uri": "file:///report.bin",
8097 "mimeType": "application/octet-stream",
8098 "blob": "AAEC"
8099 }]
8100 })
8101 );
8102
8103 let prompt = GetPromptResult {
8104 description: Some("ask a question".to_owned()),
8105 messages: legacy_prompt_messages_from_handler(vec![PromptMessage {
8106 role: fastmcp_protocol::Role::User,
8107 content: Content::Text {
8108 text: "summarize".to_owned(),
8109 },
8110 }])
8111 .expect("handler prompt message is representable by the exact legacy union"),
8112 meta: None,
8113 additional: BTreeMap::new(),
8114 };
8115 assert_eq!(
8116 serde_json::to_value(prompt).expect("legacy prompt result serializes"),
8117 serde_json::json!({
8118 "description": "ask a question",
8119 "messages": [{
8120 "role": "user",
8121 "content": {"type": "text", "text": "summarize"}
8122 }]
8123 })
8124 );
8125 }
8126
8127 #[test]
8128 fn legacy_resource_adapter_preserves_open_members_when_promoted() {
8129 let resource = LegacyResourceContent::Text {
8130 uri: "file:///open.txt".to_owned(),
8131 text: "preserve me".to_owned(),
8132 mime_type: Some("text/plain".to_owned()),
8133 additional: BTreeMap::from([
8134 (
8135 "_meta".to_owned(),
8136 serde_json::json!({"legacy": "uninterpreted"}),
8137 ),
8138 (
8139 "com.example/legacy".to_owned(),
8140 serde_json::json!({"retained": true}),
8141 ),
8142 ]),
8143 };
8144
8145 let promoted = promote_legacy_resource_content(resource)
8146 .expect("schema-valid legacy resource is promotable");
8147 assert_eq!(
8148 serde_json::to_value(promoted).expect("promoted resource serializes"),
8149 serde_json::json!({
8150 "uri": "file:///open.txt",
8151 "mimeType": "text/plain",
8152 "text": "preserve me",
8153 "_meta": {"legacy": "uninterpreted"},
8154 "com.example/legacy": {"retained": true}
8155 })
8156 );
8157 }
8158
8159 #[test]
8160 fn legacy_content_adapter_rejects_only_audio_without_mutating_baseline() {
8161 let baseline = vec![Content::Image {
8162 data: "aGVsbG8=".to_owned(),
8163 mime_type: "image/png".to_owned(),
8164 }];
8165 let baseline_wire = serde_json::to_value(
8166 legacy_contents_from_handler(baseline.clone())
8167 .expect("baseline content is representable by the legacy adapter"),
8168 )
8169 .expect("baseline legacy content serializes");
8170 let planted = vec![Content::Audio {
8171 data: "aGVsbG8=".to_owned(),
8172 mime_type: "image/png".to_owned(),
8173 }];
8174
8175 assert!(
8176 legacy_contents_from_handler(planted).is_err(),
8177 "changing only the content discriminator to audio rejects the legacy adapter"
8178 );
8179 assert_eq!(
8180 serde_json::to_value(
8181 legacy_contents_from_handler(baseline)
8182 .expect("baseline remains representable after rejection"),
8183 )
8184 .expect("baseline legacy content remains serializable"),
8185 baseline_wire,
8186 "rejected legacy conversion cannot mutate the accepted baseline"
8187 );
8188 }
8189
8190 fn spawn_owned_modern_request(
8191 runtime: &RuntimeHandle,
8192 router: Arc<Router>,
8193 request_context_id: u64,
8194 wire_id: &'static str,
8195 label: &'static str,
8196 control_sender: Option<oneshot::Sender<Cx>>,
8197 ) -> oneshot::Receiver<McpResult<serde_json::Value>> {
8198 let (response_sender, response_receiver) = oneshot::channel();
8199 runtime
8200 .try_spawn_with_cx(move |request_cx| {
8201 if let Some(control_sender) = control_sender {
8202 control_sender
8203 .send_blocking(request_cx.clone())
8204 .expect("the cancellation controller remains available");
8205 }
8206 let request_ctx =
8209 McpContext::with_state(request_cx, request_context_id, SessionState::new());
8210 let request = JsonRpcRequest::new(
8211 "tools/call",
8212 Some(serde_json::json!({
8213 "_meta": {
8214 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
8215 "io.modelcontextprotocol/clientCapabilities": {},
8216 },
8217 "name": "concurrent-modern-tool",
8218 "arguments": {"request": label},
8219 })),
8220 wire_id,
8221 );
8222 async move {
8223 let result = router.dispatch_stateless_owned(request_ctx, request).await;
8224 response_sender
8225 .send_blocking(result)
8226 .expect("the modern dispatch observer remains available");
8227 }
8228 })
8229 .expect("the runtime admits the request owner");
8230 response_receiver
8231 }
8232
8233 struct NamedTool {
8236 name: String,
8237 tags: Vec<String>,
8238 }
8239
8240 impl NamedTool {
8241 fn new(name: &str) -> Self {
8242 Self {
8243 name: name.to_string(),
8244 tags: vec![],
8245 }
8246 }
8247 fn with_tags(name: &str, tags: Vec<String>) -> Self {
8248 Self {
8249 name: name.to_string(),
8250 tags,
8251 }
8252 }
8253 }
8254
8255 impl ToolHandler for NamedTool {
8256 fn definition(&self) -> Tool {
8257 Tool {
8258 name: self.name.clone(),
8259 description: Some(format!("Tool {}", self.name)),
8260 input_schema: serde_json::json!({"type": "object"}),
8261 output_schema: None,
8262 icon: None,
8263 version: None,
8264 tags: self.tags.clone(),
8265 annotations: None,
8266 }
8267 }
8268 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
8269 Ok(vec![Content::text(format!("called {}", self.name))])
8270 }
8271 }
8272
8273 struct RouterProgressTool;
8274
8275 impl ToolHandler for RouterProgressTool {
8276 fn definition(&self) -> Tool {
8277 Tool {
8278 name: "router-progress-tool".to_owned(),
8279 description: None,
8280 input_schema: serde_json::json!({
8281 "type": "object",
8282 "required": ["total"],
8283 "properties": {"total": {"type": "number"}},
8284 "additionalProperties": false,
8285 }),
8286 output_schema: None,
8287 icon: None,
8288 version: None,
8289 tags: Vec::new(),
8290 annotations: None,
8291 }
8292 }
8293
8294 fn call(&self, ctx: &McpContext, args: serde_json::Value) -> McpResult<Vec<Content>> {
8295 let total = args
8296 .get("total")
8297 .and_then(serde_json::Value::as_f64)
8298 .ok_or_else(|| McpError::invalid_params("router progress total is required"))?;
8299 ctx.report_progress_with_total(12_000.0, total, Some("router-final"));
8300 Ok(vec![Content::text("progress emitted")])
8301 }
8302 }
8303
8304 struct DuplicateInvariantTool {
8305 label: &'static str,
8306 schema_property: &'static str,
8307 legacy_calls: Arc<AtomicUsize>,
8308 final_calls: Arc<AtomicUsize>,
8309 }
8310
8311 impl ToolHandler for DuplicateInvariantTool {
8312 fn definition(&self) -> Tool {
8313 Tool {
8314 name: "duplicate-invariant-tool".to_owned(),
8315 description: Some(self.label.to_owned()),
8316 input_schema: serde_json::json!({
8317 "type": "object",
8318 "properties": {
8319 (self.schema_property): {"type": "boolean"}
8320 },
8321 "additionalProperties": false
8322 }),
8323 output_schema: None,
8324 icon: None,
8325 version: None,
8326 tags: vec![self.label.to_owned()],
8327 annotations: None,
8328 }
8329 }
8330
8331 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
8332 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
8333 Ok(vec![Content::text(self.label)])
8334 }
8335
8336 fn call_final_outcome(
8337 &self,
8338 _ctx: &McpContext,
8339 _args: serde_json::Value,
8340 ) -> McpResult<FinalToolOutcome> {
8341 self.final_calls.fetch_add(1, Ordering::SeqCst);
8342 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
8343 FinalCallToolResult {
8344 content: vec![ContentBlock::text(self.label)],
8345 is_error: false,
8346 structured_content: None,
8347 },
8348 )))
8349 }
8350 }
8351
8352 struct InvalidFinalSchemaNamedTool {
8355 name: String,
8356 tags: Vec<String>,
8357 }
8358
8359 impl InvalidFinalSchemaNamedTool {
8360 fn with_tags(name: &str, tags: Vec<String>) -> Self {
8361 Self {
8362 name: name.to_owned(),
8363 tags,
8364 }
8365 }
8366 }
8367
8368 impl ToolHandler for InvalidFinalSchemaNamedTool {
8369 fn definition(&self) -> Tool {
8370 Tool {
8371 name: self.name.clone(),
8372 description: Some(format!("Invalid-schema tool {}", self.name)),
8373 input_schema: serde_json::json!({"type": "object"}),
8374 output_schema: Some(serde_json::json!(false)),
8378 icon: None,
8379 version: None,
8380 tags: self.tags.clone(),
8381 annotations: None,
8382 }
8383 }
8384
8385 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
8386 Ok(vec![Content::text(format!("called {}", self.name))])
8387 }
8388 }
8389
8390 fn final_tools_list_request(
8391 cursor: Option<&str>,
8392 include_tags: Option<Vec<&str>>,
8393 exclude_tags: Option<Vec<&str>>,
8394 id: i64,
8395 ) -> JsonRpcRequest {
8396 let mut params = serde_json::Map::new();
8397 params.insert(
8398 "_meta".to_owned(),
8399 serde_json::json!({
8400 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
8401 "io.modelcontextprotocol/clientCapabilities": {},
8402 }),
8403 );
8404 if let Some(cursor) = cursor {
8405 params.insert("cursor".to_owned(), serde_json::json!(cursor));
8406 }
8407 if let Some(include_tags) = include_tags {
8408 params.insert("includeTags".to_owned(), serde_json::json!(include_tags));
8409 }
8410 if let Some(exclude_tags) = exclude_tags {
8411 params.insert("excludeTags".to_owned(), serde_json::json!(exclude_tags));
8412 }
8413 JsonRpcRequest::new("tools/list", Some(serde_json::Value::Object(params)), id)
8414 }
8415
8416 fn mounted_tool_router() -> Router {
8417 let mut source = Router::new();
8418 source
8419 .add_tool(NamedTool::with_tags("first", vec!["visible".to_owned()]))
8420 .expect("tool registration succeeds");
8421 source
8422 .add_tool(NamedTool::with_tags("second", vec!["visible".to_owned()]))
8423 .expect("tool registration succeeds");
8424 source
8425 .add_tool(NamedTool::with_tags(
8426 "excluded",
8427 vec!["visible".to_owned(), "excluded".to_owned()],
8428 ))
8429 .expect("tool registration succeeds");
8430 source
8431 .add_tool(NamedTool::with_tags("other", vec!["other".to_owned()]))
8432 .expect("tool registration succeeds");
8433
8434 let mut router = Router::new();
8435 let mounted = router.mount_tools(source, Some("peer"));
8436 assert_eq!(mounted.tools, 4);
8437 router
8438 }
8439
8440 static MACRO_DUAL_ERA_TOOL_CALLS: AtomicUsize = AtomicUsize::new(0);
8441 static MACRO_DUAL_ERA_TOOL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
8444
8445 fn final_tool_complete_result(
8446 payload: FinalCallToolResult,
8447 ) -> CompleteResult<FinalCallToolResult> {
8448 let params = serde_json::json!({
8449 "_meta": {
8450 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
8451 "io.modelcontextprotocol/clientCapabilities": {},
8452 },
8453 "name": "macro_dual_era_tool",
8454 });
8455 let request = CoreRequest::decode(ProtocolEra::Modern2026, "tools/call", Some(¶ms))
8456 .expect("test final tools/call request");
8457 let mut wire = serde_json::to_value(payload).expect("final tool payload serializes");
8458 wire.as_object_mut()
8459 .expect("final tool payload is an object")
8460 .insert("resultType".to_owned(), serde_json::json!("complete"));
8461 let encoded = serde_json::to_string(&wire).expect("final tool wire serializes");
8462 let CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) = request
8463 .decode_result(&encoded)
8464 .expect("typed final tools/call result")
8465 else {
8466 panic!("typed final tools/call result is selected");
8467 };
8468 result
8469 }
8470
8471 #[fastmcp_derive::tool]
8472 fn macro_dual_era_tool() -> CompleteResult<FinalCallToolResult> {
8473 MACRO_DUAL_ERA_TOOL_CALLS.fetch_add(1, Ordering::SeqCst);
8474 final_tool_complete_result(FinalCallToolResult {
8475 content: vec![ContentBlock::text("macro final tool result")],
8476 is_error: false,
8477 structured_content: Some(serde_json::json!({"weather":"clear"})),
8478 })
8479 }
8480
8481 struct SchemaBoundaryTool {
8482 final_calls: Arc<AtomicUsize>,
8483 legacy_calls: Arc<AtomicUsize>,
8484 output_matches_schema: bool,
8485 output_is_error: bool,
8486 output_has_unevaluated_property: bool,
8487 invalid_final_input_schema: bool,
8488 missing_final_input_object_type: bool,
8489 invalid_final_output_schema: bool,
8490 }
8491
8492 impl ToolHandler for SchemaBoundaryTool {
8493 fn definition(&self) -> Tool {
8494 Tool {
8495 name: "schema-boundary-tool".to_owned(),
8496 description: None,
8497 input_schema: if self.invalid_final_input_schema {
8498 serde_json::json!(42)
8499 } else if self.missing_final_input_object_type {
8500 serde_json::json!({"properties": {"value": {"type": "string"}}})
8501 } else {
8502 serde_json::json!({
8503 "$schema": "https://json-schema.org/draft/2020-12/schema",
8504 "type": "object",
8505 "required": ["value"],
8506 "properties": {"value": {"type": "string"}},
8507 "unevaluatedProperties": false,
8508 })
8509 },
8510 output_schema: Some(if self.invalid_final_output_schema {
8511 serde_json::json!(42)
8512 } else {
8513 serde_json::json!({
8514 "$schema": "https://json-schema.org/draft/2020-12/schema",
8515 "type": "object",
8516 "properties": {
8517 "accepted": {"type": "boolean"},
8518 "error": {
8519 "enum": ["input-validation", "handler"]
8520 }
8521 },
8522 "anyOf": [
8523 {"required": ["accepted"]},
8524 {"required": ["error"]}
8525 ],
8526 "unevaluatedProperties": false,
8527 })
8528 }),
8529 icon: None,
8530 version: None,
8531 tags: Vec::new(),
8532 annotations: None,
8533 }
8534 }
8535
8536 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
8537 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
8538 Ok(vec![Content::text("legacy schema-boundary result")])
8539 }
8540
8541 fn final_tool_error_structured_content(
8542 &self,
8543 kind: ToolErrorKind,
8544 ) -> Option<serde_json::Value> {
8545 Some(match kind {
8546 ToolErrorKind::InputValidation => {
8547 serde_json::json!({"error": "input-validation"})
8548 }
8549 ToolErrorKind::Handler => serde_json::json!({"error": "handler"}),
8550 })
8551 }
8552
8553 fn call_final_outcome(
8554 &self,
8555 _ctx: &McpContext,
8556 _args: serde_json::Value,
8557 ) -> McpResult<FinalToolOutcome> {
8558 self.final_calls.fetch_add(1, Ordering::SeqCst);
8559 let structured_content = if !self.output_matches_schema {
8560 serde_json::json!({"accepted": "not-a-boolean"})
8561 } else if self.output_has_unevaluated_property {
8562 serde_json::json!({"accepted": true, "unexpected": true})
8563 } else {
8564 serde_json::json!({"accepted": true})
8565 };
8566 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
8567 FinalCallToolResult {
8568 content: vec![ContentBlock::text("final schema-boundary result")],
8569 is_error: self.output_is_error,
8570 structured_content: Some(structured_content),
8571 },
8572 )))
8573 }
8574 }
8575
8576 struct UpstreamScalarSchemaTool {
8577 registered_proxy: bool,
8578 }
8579
8580 impl ToolHandler for UpstreamScalarSchemaTool {
8581 fn definition(&self) -> Tool {
8582 Tool {
8583 name: "upstream-scalar-schema-tool".to_owned(),
8584 description: None,
8585 input_schema: serde_json::json!({"type": "object"}),
8586 output_schema: Some(serde_json::json!(false)),
8587 icon: None,
8588 version: None,
8589 tags: Vec::new(),
8590 annotations: None,
8591 }
8592 }
8593
8594 fn final_tool_schema_authority(&self) -> FinalToolSchemaAuthority {
8595 FinalToolSchemaAuthority::Upstream
8597 }
8598
8599 fn upstream_final_tool_schema_registration(
8600 &self,
8601 ) -> Option<UpstreamFinalToolSchemaRegistration> {
8602 self.registered_proxy
8603 .then(UpstreamFinalToolSchemaRegistration::exact_proxy)
8604 }
8605
8606 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
8607 Ok(vec![Content::text("legacy")])
8608 }
8609
8610 fn call_final_outcome(
8611 &self,
8612 _ctx: &McpContext,
8613 _args: serde_json::Value,
8614 ) -> McpResult<FinalToolOutcome> {
8615 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
8616 FinalCallToolResult {
8617 content: vec![ContentBlock::text("upstream")],
8618 is_error: false,
8619 structured_content: Some(serde_json::json!({"upstream": true})),
8620 },
8621 )))
8622 }
8623 }
8624
8625 struct AdmittedSchemaReplacementTool {
8629 legacy_calls: Arc<AtomicUsize>,
8630 final_calls: Arc<AtomicUsize>,
8631 legacy_label: &'static str,
8632 output_schema: serde_json::Value,
8633 structured_content: Option<serde_json::Value>,
8634 }
8635
8636 impl ToolHandler for AdmittedSchemaReplacementTool {
8637 fn definition(&self) -> Tool {
8638 Tool {
8639 name: "admitted-schema-replacement-tool".to_owned(),
8640 description: Some(self.legacy_label.to_owned()),
8641 input_schema: serde_json::json!({
8642 "$schema": "https://json-schema.org/draft/2020-12/schema",
8643 "type": "object",
8644 "unevaluatedProperties": false,
8645 }),
8646 output_schema: Some(self.output_schema.clone()),
8647 icon: None,
8648 version: None,
8649 tags: Vec::new(),
8650 annotations: None,
8651 }
8652 }
8653
8654 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
8655 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
8656 Ok(vec![Content::text(self.legacy_label)])
8657 }
8658
8659 fn final_tool_error_structured_content(
8660 &self,
8661 kind: ToolErrorKind,
8662 ) -> Option<serde_json::Value> {
8663 match self
8664 .output_schema
8665 .get("type")
8666 .and_then(serde_json::Value::as_str)
8667 {
8668 Some("string") => Some(serde_json::json!(match kind {
8669 ToolErrorKind::InputValidation => "input-validation-error",
8670 ToolErrorKind::Handler => "handler-error",
8671 })),
8672 Some("boolean") => Some(serde_json::json!(matches!(kind, ToolErrorKind::Handler))),
8673 Some("null") => Some(serde_json::Value::Null),
8674 Some("object") => Some(serde_json::json!({
8675 "error": match kind {
8676 ToolErrorKind::InputValidation => "input-validation",
8677 ToolErrorKind::Handler => "handler",
8678 }
8679 })),
8680 _ => None,
8681 }
8682 }
8683
8684 fn call_final_outcome(
8685 &self,
8686 _ctx: &McpContext,
8687 _args: serde_json::Value,
8688 ) -> McpResult<FinalToolOutcome> {
8689 self.final_calls.fetch_add(1, Ordering::SeqCst);
8690 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
8691 FinalCallToolResult {
8692 content: vec![ContentBlock::text(self.legacy_label)],
8693 is_error: false,
8694 structured_content: self.structured_content.clone(),
8695 },
8696 )))
8697 }
8698 }
8699
8700 #[derive(Clone, Copy)]
8701 enum ErrorMapperMode {
8702 Complete,
8703 MissingHandler,
8704 InvalidHandler,
8705 OversizedHandler,
8706 }
8707
8708 struct ErrorMappedTool {
8709 mode: ErrorMapperMode,
8710 calls: Arc<AtomicUsize>,
8711 }
8712
8713 impl ToolHandler for ErrorMappedTool {
8714 fn definition(&self) -> Tool {
8715 Tool {
8716 name: "error-mapped-tool".to_owned(),
8717 description: None,
8718 input_schema: serde_json::json!({
8719 "type": "object",
8720 "required": ["value"],
8721 "properties": {"value": {"type": "string"}},
8722 "additionalProperties": false
8723 }),
8724 output_schema: Some(serde_json::json!({
8725 "type": "object",
8726 "required": ["error"],
8727 "properties": {"error": {"type": "string"}},
8728 "additionalProperties": false
8729 })),
8730 icon: None,
8731 version: None,
8732 tags: Vec::new(),
8733 annotations: None,
8734 }
8735 }
8736
8737 fn final_tool_error_structured_content(
8738 &self,
8739 kind: ToolErrorKind,
8740 ) -> Option<serde_json::Value> {
8741 match (self.mode, kind) {
8742 (ErrorMapperMode::MissingHandler, ToolErrorKind::Handler) => None,
8743 (ErrorMapperMode::InvalidHandler, ToolErrorKind::Handler) => {
8744 Some(serde_json::json!({"error": 7}))
8745 }
8746 (ErrorMapperMode::OversizedHandler, ToolErrorKind::Handler) => Some(
8747 serde_json::json!({"error": "x".repeat(MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES)}),
8748 ),
8749 (_, ToolErrorKind::InputValidation) => {
8750 Some(serde_json::json!({"error": "input-validation"}))
8751 }
8752 (_, ToolErrorKind::Handler) => Some(serde_json::json!({"error": "handler"})),
8753 }
8754 }
8755
8756 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
8757 self.calls.fetch_add(1, Ordering::SeqCst);
8758 Err(McpError::new(
8759 McpErrorCode::ToolExecutionError,
8760 "mapped handler failure",
8761 ))
8762 }
8763 }
8764
8765 fn final_tools_call_request(
8766 name: &str,
8767 arguments: serde_json::Value,
8768 id: i64,
8769 ) -> JsonRpcRequest {
8770 JsonRpcRequest::new(
8771 "tools/call",
8772 Some(serde_json::json!({
8773 "_meta": {
8774 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
8775 "io.modelcontextprotocol/clientCapabilities": {},
8776 },
8777 "name": name,
8778 "arguments": arguments,
8779 })),
8780 id,
8781 )
8782 }
8783
8784 fn admit_http_wire(
8785 method: &str,
8786 target: &str,
8787 body: &[u8],
8788 ) -> (JsonRpcRequest, Option<Arc<str>>) {
8789 let endpoint = HttpEndpointConfig::new(
8790 "/mcp",
8791 HttpAdmissionLimits::new(16, 8_192, 65_536).expect("nonzero HTTP limits"),
8792 )
8793 .expect("HTTP endpoint config");
8794 let headers = vec![
8795 ("Content-Type".to_owned(), "application/json".to_owned()),
8796 ("Accept".to_owned(), "application/json".to_owned()),
8797 ("MCP-Protocol-Version".to_owned(), "2026-07-28".to_owned()),
8798 ("Mcp-Method".to_owned(), method.to_owned()),
8799 ("Mcp-Name".to_owned(), target.to_owned()),
8800 ];
8801 admit_modern_post(&endpoint, "POST", "/mcp", &headers, body)
8802 .expect("wire request admits through HTTP")
8803 .into_request_and_raw_params()
8804 }
8805
8806 #[test]
8807 fn final_router_progress_admits_one_smaller_total_without_replacing_an_outer_runtime() {
8808 let mut router = Router::new();
8809 router
8810 .add_tool(RouterProgressTool)
8811 .expect("router progress tool registers for both eras");
8812 let cx = Cx::for_testing();
8813 let state = SessionState::new();
8814 let request_ctx = request_context(&cx, 177, Budget::INFINITE, &state);
8815 let sent = Arc::new(Mutex::new(Vec::new()));
8816 let sent_clone = Arc::clone(&sent);
8817 let notification_sender: NotificationSender = Arc::new(move |notification| {
8818 sent_clone
8819 .lock()
8820 .expect("notification collection is not poisoned")
8821 .push(notification);
8822 });
8823 let baseline = serde_json::json!({
8824 "_meta": {
8825 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
8826 "io.modelcontextprotocol/clientCapabilities": {},
8827 "progressToken": "router-final-progress",
8828 },
8829 "name": "router-progress-tool",
8830 "arguments": {"total": 12000.0},
8831 });
8832 let mut planted = baseline.clone();
8833 planted["arguments"]["total"] = serde_json::json!(11999.0);
8834 assert_eq!(
8835 baseline["_meta"], planted["_meta"],
8836 "the progress total is the sole planted request dimension"
8837 );
8838 assert_eq!(baseline["name"], planted["name"]);
8839
8840 let baseline: FinalCallToolParams =
8841 serde_json::from_value(baseline).expect("baseline final request is valid");
8842 let outcome = block_on(router.handle_tools_call_final_in_request(
8843 &request_ctx,
8844 request_ctx.cx(),
8845 baseline,
8846 state.clone(),
8847 Some(¬ification_sender),
8848 None,
8849 None,
8850 ))
8851 .expect("ordinary final router dispatch completes");
8852 assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
8853 let notification = sent
8854 .lock()
8855 .expect("notification collection is not poisoned")[0]
8856 .clone();
8857 let wire = serde_json::to_string(
8858 notification
8859 .params
8860 .as_ref()
8861 .expect("ordinary final progress has parameters"),
8862 )
8863 .expect("ordinary final progress parameters serialize");
8864 assert!(wire.contains("\"progress\":12000"));
8865 assert!(wire.contains("\"total\":12000"));
8866
8867 let planted: FinalCallToolParams =
8868 serde_json::from_value(planted).expect("one-variable planted request is valid");
8869 let outcome = block_on(router.handle_tools_call_final_in_request(
8870 &request_ctx,
8871 request_ctx.cx(),
8872 planted,
8873 state,
8874 Some(¬ification_sender),
8875 None,
8876 None,
8877 ))
8878 .expect("the handler result remains valid when only total is smaller");
8879 assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
8880 assert_eq!(
8881 sent.lock()
8882 .expect("notification collection is not poisoned")
8883 .len(),
8884 2,
8885 "a smaller total is not a final-progress violation"
8886 );
8887 let wire = serde_json::to_string(
8888 sent.lock()
8889 .expect("notification collection is not poisoned")[1]
8890 .params
8891 .as_ref()
8892 .expect("second final progress has parameters"),
8893 )
8894 .expect("second final progress parameters serialize");
8895 assert!(wire.contains("\"progress\":12000"));
8896 assert!(wire.contains("\"total\":11999"));
8897 }
8898
8899 #[test]
8900 fn final_router_preserves_an_outer_final_progress_runtime() {
8901 let mut router = Router::new();
8902 router
8903 .add_tool(RouterProgressTool)
8904 .expect("router progress tool registers for both eras");
8905 let cx = Cx::for_testing();
8906 let state = SessionState::new();
8907 let sent = Arc::new(Mutex::new(Vec::new()));
8908 let sent_clone = Arc::clone(&sent);
8909 let outer_runtime = Arc::new(crate::handler::FinalProgressRuntime::new(
8910 ProgressMarker::from("outer-owned-marker"),
8911 move |notification| {
8912 sent_clone
8913 .lock()
8914 .expect("notification collection is not poisoned")
8915 .push(notification);
8916 },
8917 ));
8918 let request_ctx =
8919 McpContext::with_progress(cx, 178, Arc::clone(&outer_runtime).into_reporter());
8920 let params: FinalCallToolParams = serde_json::from_value(serde_json::json!({
8921 "_meta": {
8922 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
8923 "io.modelcontextprotocol/clientCapabilities": {},
8924 "progressToken": "handler-supplied-marker",
8925 },
8926 "name": "router-progress-tool",
8927 "arguments": {"total": 11999.0},
8928 }))
8929 .expect("final tool parameters are valid");
8930 let notification_sender: NotificationSender =
8931 Arc::new(|_| panic!("router must not replace an installed final-progress runtime"));
8932
8933 let outcome = block_on(router.handle_tools_call_final_in_request(
8934 &request_ctx,
8935 request_ctx.cx(),
8936 params,
8937 state,
8938 Some(¬ification_sender),
8939 None,
8940 None,
8941 ))
8942 .expect("outer final progress runtime remains usable");
8943 assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
8944 assert!(outer_runtime.flush_pending());
8945
8946 let sent = sent
8947 .lock()
8948 .expect("notification collection is not poisoned");
8949 assert_eq!(sent.len(), 1);
8950 let wire = serde_json::to_string(
8951 sent[0]
8952 .params
8953 .as_ref()
8954 .expect("outer runtime notification has parameters"),
8955 )
8956 .expect("outer runtime parameters serialize");
8957 assert!(wire.contains("\"progressToken\":\"outer-owned-marker\""));
8958 assert!(wire.contains("\"progress\":12000"));
8959 assert!(wire.contains("\"total\":11999"));
8960 }
8961
8962 #[cfg(feature = "tasks")]
8963 struct TaskCapableRouterTool {
8964 final_calls: Arc<AtomicUsize>,
8965 }
8966
8967 #[cfg(feature = "tasks")]
8968 impl ToolHandler for TaskCapableRouterTool {
8969 fn definition(&self) -> Tool {
8970 Tool {
8971 name: "task-capable-router-tool".to_owned(),
8972 description: None,
8973 input_schema: serde_json::json!({
8974 "$schema": "https://json-schema.org/draft/2020-12/schema",
8975 "type": "object",
8976 "unevaluatedProperties": false,
8977 }),
8978 output_schema: None,
8979 icon: None,
8980 version: None,
8981 tags: Vec::new(),
8982 annotations: None,
8983 }
8984 }
8985
8986 fn declares_final_tasks(&self) -> bool {
8987 true
8988 }
8989
8990 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
8991 Ok(vec![Content::text("legacy task-capable router result")])
8992 }
8993
8994 fn call_final_outcome(
8995 &self,
8996 _ctx: &McpContext,
8997 _args: serde_json::Value,
8998 ) -> McpResult<FinalToolOutcome> {
8999 self.final_calls.fetch_add(1, Ordering::SeqCst);
9000 Ok(FinalToolOutcome::CreateTask {
9001 work_descriptor: FinalTaskWorkDescriptor::new(serde_json::json!({
9002 "operation": "task-capable-router-tool"
9003 }))?,
9004 status_message: Some("router task created".to_owned()),
9005 })
9006 }
9007 }
9008
9009 #[cfg(feature = "tasks")]
9013 struct ConditionalTaskCapableRouterTool {
9014 final_calls: Arc<AtomicUsize>,
9015 }
9016
9017 #[cfg(feature = "tasks")]
9018 impl ToolHandler for ConditionalTaskCapableRouterTool {
9019 fn definition(&self) -> Tool {
9020 Tool {
9021 name: "conditional-task-capable-router-tool".to_owned(),
9022 description: None,
9023 input_schema: serde_json::json!({
9024 "$schema": "https://json-schema.org/draft/2020-12/schema",
9025 "type": "object",
9026 "properties": {"createTask": {"type": "boolean"}},
9027 "required": ["createTask"],
9028 "unevaluatedProperties": false,
9029 }),
9030 output_schema: None,
9031 icon: None,
9032 version: None,
9033 tags: Vec::new(),
9034 annotations: None,
9035 }
9036 }
9037
9038 fn declares_final_tasks(&self) -> bool {
9039 true
9040 }
9041
9042 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9043 Ok(vec![Content::text(
9044 "legacy conditional task-capable router result",
9045 )])
9046 }
9047
9048 fn call_final_outcome(
9049 &self,
9050 _ctx: &McpContext,
9051 args: serde_json::Value,
9052 ) -> McpResult<FinalToolOutcome> {
9053 self.final_calls.fetch_add(1, Ordering::SeqCst);
9054 if args
9055 .get("createTask")
9056 .and_then(serde_json::Value::as_bool)
9057 .unwrap_or(false)
9058 {
9059 return Ok(FinalToolOutcome::CreateTask {
9060 work_descriptor: FinalTaskWorkDescriptor::new(serde_json::json!({
9061 "operation": "conditional-task-capable-router-tool"
9062 }))?,
9063 status_message: None,
9064 });
9065 }
9066 Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9067 FinalCallToolResult {
9068 content: vec![ContentBlock::text("ordinary final result")],
9069 is_error: false,
9070 structured_content: None,
9071 },
9072 )))
9073 }
9074 }
9075
9076 #[cfg(feature = "tasks")]
9080 struct UndeclaredTaskOutcomeRouterTool {
9081 final_calls: Arc<AtomicUsize>,
9082 }
9083
9084 #[cfg(feature = "tasks")]
9085 impl ToolHandler for UndeclaredTaskOutcomeRouterTool {
9086 fn definition(&self) -> Tool {
9087 Tool {
9088 name: "undeclared-task-outcome-router-tool".to_owned(),
9089 description: None,
9090 input_schema: serde_json::json!({
9091 "$schema": "https://json-schema.org/draft/2020-12/schema",
9092 "type": "object",
9093 "unevaluatedProperties": false,
9094 }),
9095 output_schema: None,
9096 icon: None,
9097 version: None,
9098 tags: Vec::new(),
9099 annotations: None,
9100 }
9101 }
9102
9103 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9104 Ok(vec![Content::text("legacy undeclared-task outcome")])
9105 }
9106
9107 fn call_final_outcome_async_in_request<'a>(
9108 &'a self,
9109 _ctx: &'a McpContext,
9110 _request_cx: &'a Cx,
9111 _args: serde_json::Value,
9112 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
9113 self.final_calls.fetch_add(1, Ordering::SeqCst);
9114 let work_descriptor = FinalTaskWorkDescriptor::new(serde_json::json!({
9115 "operation": "undeclared-task-outcome-router-tool"
9116 }));
9117 Box::pin(async move {
9118 match work_descriptor {
9119 Ok(work_descriptor) => Outcome::Ok(FinalToolOutcome::CreateTask {
9120 work_descriptor,
9121 status_message: None,
9122 }),
9123 Err(error) => Outcome::Err(error),
9124 }
9125 })
9126 }
9127 }
9128
9129 #[cfg(feature = "tasks")]
9130 struct NoopFinalTaskSupervisor;
9131
9132 #[cfg(feature = "tasks")]
9133 impl ApplicationTaskSupervisor for NoopFinalTaskSupervisor {
9134 fn resume<'a>(
9135 &'a self,
9136 _cx: &'a Cx,
9137 _handoff: FinalTaskSupervisorHandoff,
9138 ) -> FinalTaskSupervisorFuture<'a> {
9139 Box::pin(async { Ok(()) })
9140 }
9141 }
9142
9143 #[cfg(feature = "tasks")]
9144 fn task_runtime_for_router(store: Arc<InMemoryFinalTaskStore>) -> FinalTaskRuntime {
9145 let store: Arc<dyn FinalTaskStore> = store;
9146 FinalTaskRuntime::new(
9147 store,
9148 FinalTaskRuntimeConfig::new(60_000, Some(5_000))
9149 .expect("a finite final Task policy is valid"),
9150 Arc::new(|_notification| {}),
9151 )
9152 }
9153
9154 #[cfg(feature = "tasks")]
9155 fn final_task_capable_tool_request(id: i64) -> JsonRpcRequest {
9156 JsonRpcRequest::new(
9157 "tools/call",
9158 Some(serde_json::json!({
9159 "_meta": {
9160 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9161 "io.modelcontextprotocol/clientCapabilities": {
9162 "extensions": {
9163 "io.modelcontextprotocol/tasks": {}
9164 }
9165 },
9166 },
9167 "name": "task-capable-router-tool",
9168 "arguments": {},
9169 })),
9170 id,
9171 )
9172 }
9173
9174 struct FinalCatalogTool {
9175 metadata: OpenMetadata,
9176 icons: Vec<RawIcon>,
9177 }
9178
9179 impl ToolHandler for FinalCatalogTool {
9180 fn definition(&self) -> Tool {
9181 Tool {
9182 name: "final-catalog-tool".to_owned(),
9183 description: Some("final catalog description".to_owned()),
9184 input_schema: serde_json::json!({"type": "object"}),
9185 output_schema: Some(serde_json::json!({"type": "object"})),
9186 icon: Some(fastmcp_protocol::Icon::new("https://legacy.test/icon.png")),
9187 version: Some("legacy-version".to_owned()),
9188 tags: vec!["legacy-tag".to_owned()],
9189 annotations: None,
9190 }
9191 }
9192
9193 fn final_title(&self) -> Option<&str> {
9194 Some("Final Catalog Tool")
9195 }
9196
9197 fn final_icons(&self) -> Option<&[RawIcon]> {
9198 Some(&self.icons)
9199 }
9200
9201 fn final_metadata(&self) -> Option<&OpenMetadata> {
9202 Some(&self.metadata)
9203 }
9204
9205 fn final_definition(&self) -> Option<FinalTool> {
9206 Some(FinalTool {
9207 name: "final-catalog-tool".to_owned(),
9208 title: Some("Exact Final Catalog Tool".to_owned()),
9209 description: Some("exact final catalog description".to_owned()),
9210 input_schema: serde_json::json!({"type": "object"}),
9211 output_schema: Some(serde_json::json!({"type": "object"})),
9212 annotations: Some(FinalToolAnnotations {
9213 title: Some("Exact annotation title".to_owned()),
9214 destructive: Some(false),
9215 idempotent: Some(true),
9216 read_only: Some(true),
9217 open_world_hint: Some(false),
9218 }),
9219 icons: Some(self.icons.clone()),
9220 meta: Some(self.metadata.clone()),
9221 })
9222 }
9223
9224 fn final_tool_error_structured_content(
9225 &self,
9226 kind: ToolErrorKind,
9227 ) -> Option<serde_json::Value> {
9228 Some(serde_json::json!({
9229 "error": match kind {
9230 ToolErrorKind::InputValidation => "input-validation",
9231 ToolErrorKind::Handler => "handler",
9232 }
9233 }))
9234 }
9235
9236 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9237 Ok(vec![Content::text("legacy final catalog result")])
9238 }
9239 }
9240
9241 struct FinalCatalogResource {
9242 metadata: OpenMetadata,
9243 icons: Vec<RawIcon>,
9244 annotations: Annotations,
9245 }
9246
9247 impl ResourceHandler for FinalCatalogResource {
9248 fn definition(&self) -> Resource {
9249 Resource {
9250 uri: "file:///final-catalog-resource".to_owned(),
9251 name: "final-catalog-resource".to_owned(),
9252 description: Some("final resource description".to_owned()),
9253 mime_type: Some("text/plain".to_owned()),
9254 icon: None,
9255 version: None,
9256 tags: Vec::new(),
9257 }
9258 }
9259
9260 fn final_title(&self) -> Option<&str> {
9261 Some("Final Catalog Resource")
9262 }
9263
9264 fn final_icons(&self) -> Option<&[RawIcon]> {
9265 Some(&self.icons)
9266 }
9267
9268 fn final_annotations(&self) -> Option<&Annotations> {
9269 Some(&self.annotations)
9270 }
9271
9272 fn final_metadata(&self) -> Option<&OpenMetadata> {
9273 Some(&self.metadata)
9274 }
9275
9276 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
9277 Ok(Vec::new())
9278 }
9279 }
9280
9281 struct FinalCatalogResourceTemplate {
9282 metadata: OpenMetadata,
9283 icons: Vec<RawIcon>,
9284 annotations: Annotations,
9285 }
9286
9287 impl ResourceHandler for FinalCatalogResourceTemplate {
9288 fn definition(&self) -> Resource {
9289 Resource {
9290 uri: "template://placeholder".to_owned(),
9291 name: "final-catalog-template".to_owned(),
9292 description: None,
9293 mime_type: None,
9294 icon: None,
9295 version: None,
9296 tags: Vec::new(),
9297 }
9298 }
9299
9300 fn template(&self) -> Option<ResourceTemplate> {
9301 Some(ResourceTemplate {
9302 uri_template: "template://{id}".to_owned(),
9303 name: "final-catalog-template".to_owned(),
9304 description: Some("final template description".to_owned()),
9305 mime_type: Some("application/json".to_owned()),
9306 icon: None,
9307 version: None,
9308 tags: Vec::new(),
9309 })
9310 }
9311
9312 fn final_template_title(&self) -> Option<&str> {
9313 Some("Final Catalog Template")
9314 }
9315
9316 fn final_template_icons(&self) -> Option<&[RawIcon]> {
9317 Some(&self.icons)
9318 }
9319
9320 fn final_template_annotations(&self) -> Option<&Annotations> {
9321 Some(&self.annotations)
9322 }
9323
9324 fn final_template_metadata(&self) -> Option<&OpenMetadata> {
9325 Some(&self.metadata)
9326 }
9327
9328 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
9329 Ok(Vec::new())
9330 }
9331 }
9332
9333 struct FinalCatalogPrompt {
9334 metadata: OpenMetadata,
9335 icons: Vec<RawIcon>,
9336 }
9337
9338 impl PromptHandler for FinalCatalogPrompt {
9339 fn definition(&self) -> Prompt {
9340 Prompt {
9341 name: "final-catalog-prompt".to_owned(),
9342 description: Some("final prompt description".to_owned()),
9343 arguments: vec![PromptArgument {
9344 name: "optional-argument".to_owned(),
9345 description: Some("must remain explicitly false".to_owned()),
9346 required: false,
9347 }],
9348 icon: None,
9349 version: None,
9350 tags: Vec::new(),
9351 }
9352 }
9353
9354 fn final_title(&self) -> Option<&str> {
9355 Some("Final Catalog Prompt")
9356 }
9357
9358 fn final_icons(&self) -> Option<&[RawIcon]> {
9359 Some(&self.icons)
9360 }
9361
9362 fn final_metadata(&self) -> Option<&OpenMetadata> {
9363 Some(&self.metadata)
9364 }
9365
9366 fn get(
9367 &self,
9368 _ctx: &McpContext,
9369 _args: std::collections::HashMap<String, String>,
9370 ) -> McpResult<Vec<PromptMessage>> {
9371 Ok(Vec::new())
9372 }
9373 }
9374
9375 struct DirectFinalPrompt {
9376 final_calls: Arc<AtomicUsize>,
9377 }
9378
9379 impl PromptHandler for DirectFinalPrompt {
9380 fn definition(&self) -> Prompt {
9381 Prompt {
9382 name: "direct-final-prompt".to_owned(),
9383 description: Some("legacy prompt definition".to_owned()),
9384 arguments: vec![],
9385 icon: None,
9386 version: None,
9387 tags: Vec::new(),
9388 }
9389 }
9390
9391 fn get(
9392 &self,
9393 _ctx: &McpContext,
9394 _args: std::collections::HashMap<String, String>,
9395 ) -> McpResult<Vec<PromptMessage>> {
9396 Err(McpError::internal_error(
9397 "legacy prompt projection must not service a final request",
9398 ))
9399 }
9400
9401 fn get_final(
9402 &self,
9403 _ctx: &McpContext,
9404 _args: std::collections::HashMap<String, String>,
9405 ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
9406 self.final_calls.fetch_add(1, Ordering::SeqCst);
9407 let content_meta = OpenMetadata::try_from_entries([(
9408 "com.example/direct-prompt".to_owned(),
9409 serde_json::json!({"source": "final-handler"}),
9410 )])
9411 .expect("direct prompt content metadata is valid");
9412 Ok(CompleteResult::new(
9413 FinalGetPromptResult {
9414 description: Some("direct final prompt description".to_owned()),
9415 messages: vec![FinalPromptMessage {
9416 role: fastmcp_protocol::Role::Assistant,
9417 content: ContentBlock::Audio {
9418 data: "aGVsbG8=".to_owned(),
9419 mime_type: "audio/mpeg".to_owned(),
9420 annotations: None,
9421 meta: Some(content_meta),
9422 additional: BTreeMap::from([(
9423 "com.example/direct-field".to_owned(),
9424 serde_json::json!(true),
9425 )]),
9426 },
9427 }],
9428 },
9429 empty_final_result_meta()?,
9430 ))
9431 }
9432 }
9433
9434 struct PromptArgumentBoundary {
9435 final_calls: Arc<AtomicUsize>,
9436 legacy_calls: Arc<AtomicUsize>,
9437 }
9438
9439 impl PromptHandler for PromptArgumentBoundary {
9440 fn definition(&self) -> Prompt {
9441 Prompt {
9442 name: "prompt-argument-boundary".to_owned(),
9443 description: None,
9444 arguments: vec![PromptArgument {
9445 name: "topic".to_owned(),
9446 description: Some("required modern prompt topic".to_owned()),
9447 required: true,
9448 }],
9449 icon: None,
9450 version: None,
9451 tags: Vec::new(),
9452 }
9453 }
9454
9455 fn get(
9456 &self,
9457 _ctx: &McpContext,
9458 _args: std::collections::HashMap<String, String>,
9459 ) -> McpResult<Vec<PromptMessage>> {
9460 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9461 Ok(vec![PromptMessage {
9462 role: fastmcp_protocol::Role::Assistant,
9463 content: Content::text("legacy prompt-argument-boundary result"),
9464 }])
9465 }
9466
9467 fn get_final(
9468 &self,
9469 _ctx: &McpContext,
9470 _args: std::collections::HashMap<String, String>,
9471 ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
9472 self.final_calls.fetch_add(1, Ordering::SeqCst);
9473 Ok(CompleteResult::new(
9474 FinalGetPromptResult {
9475 description: None,
9476 messages: vec![FinalPromptMessage {
9477 role: fastmcp_protocol::Role::Assistant,
9478 content: ContentBlock::text("final prompt-argument-boundary result"),
9479 }],
9480 },
9481 empty_final_result_meta()?,
9482 ))
9483 }
9484 }
9485
9486 struct MutablePromptDefinition {
9489 expose_admitted_argument: Arc<AtomicBool>,
9490 final_calls: Arc<AtomicUsize>,
9491 }
9492
9493 impl PromptHandler for MutablePromptDefinition {
9494 fn definition(&self) -> Prompt {
9495 let argument = if self.expose_admitted_argument.load(Ordering::SeqCst) {
9496 PromptArgument {
9497 name: "topic".to_owned(),
9498 description: Some("admitted required argument".to_owned()),
9499 required: true,
9500 }
9501 } else {
9502 PromptArgument {
9503 name: "mutated".to_owned(),
9504 description: Some("must not affect final validation".to_owned()),
9505 required: false,
9506 }
9507 };
9508 Prompt {
9509 name: "mutable-prompt-definition".to_owned(),
9510 description: None,
9511 arguments: vec![argument],
9512 icon: None,
9513 version: None,
9514 tags: Vec::new(),
9515 }
9516 }
9517
9518 fn get(
9519 &self,
9520 _ctx: &McpContext,
9521 _args: std::collections::HashMap<String, String>,
9522 ) -> McpResult<Vec<PromptMessage>> {
9523 Ok(Vec::new())
9524 }
9525
9526 fn get_final(
9527 &self,
9528 _ctx: &McpContext,
9529 _args: std::collections::HashMap<String, String>,
9530 ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
9531 self.final_calls.fetch_add(1, Ordering::SeqCst);
9532 Ok(CompleteResult::new(
9533 FinalGetPromptResult {
9534 description: None,
9535 messages: Vec::new(),
9536 },
9537 empty_final_result_meta()?,
9538 ))
9539 }
9540 }
9541
9542 fn input_required_result(forged_request_state: &str) -> InputRequiredResult {
9543 let encoded = serde_json::json!({
9544 "resultType": "input_required",
9545 "inputRequests": {"roots": {"method": "roots/list"}},
9546 "requestState": forged_request_state,
9547 })
9548 .to_string();
9549 let (decoded, diagnostic) = decode_peer_result(
9550 &encoded,
9551 ResultPeerEra::Modern,
9552 &CoreResultDiscriminatorPolicy,
9553 )
9554 .expect("test input-required result decodes");
9555 assert!(diagnostic.is_none());
9556 let DecodedResult::InputRequired(result) = decoded else {
9557 panic!("test result is input_required");
9558 };
9559 result
9560 }
9561
9562 fn sampling_input_required_result(forged_request_state: &str) -> InputRequiredResult {
9563 let encoded = serde_json::json!({
9564 "resultType": "input_required",
9565 "inputRequests": {
9566 "sample": {
9567 "method": "sampling/createMessage",
9568 "params": {
9569 "messages": [{
9570 "role": "assistant",
9571 "content": {
9572 "type": "tool_use",
9573 "id": "weather-1",
9574 "name": "weather",
9575 "input": {"city": "Boston"},
9576 },
9577 }],
9578 "maxTokens": 16,
9579 "tools": [{"name": "weather", "inputSchema": {"type": "object"}}],
9580 "toolChoice": {"mode": "required"},
9581 },
9582 },
9583 },
9584 "requestState": forged_request_state,
9585 })
9586 .to_string();
9587 let (decoded, diagnostic) = decode_peer_result(
9588 &encoded,
9589 ResultPeerEra::Modern,
9590 &CoreResultDiscriminatorPolicy,
9591 )
9592 .expect("final sampling input-required result decodes");
9593 assert!(diagnostic.is_none());
9594 let DecodedResult::InputRequired(result) = decoded else {
9595 panic!("test result is sampling input_required");
9596 };
9597 result
9598 }
9599
9600 fn state_only_input_required_result(forged_request_state: &str) -> InputRequiredResult {
9601 let encoded = serde_json::json!({
9602 "resultType": "input_required",
9603 "requestState": forged_request_state,
9604 })
9605 .to_string();
9606 let (decoded, diagnostic) = decode_peer_result(
9607 &encoded,
9608 ResultPeerEra::Modern,
9609 &CoreResultDiscriminatorPolicy,
9610 )
9611 .expect("test state-only input-required result decodes");
9612 assert!(diagnostic.is_none());
9613 let DecodedResult::InputRequired(result) = decoded else {
9614 panic!("test result is state-only input_required");
9615 };
9616 assert!(result.input_requests().is_none());
9617 result
9618 }
9619
9620 fn router_roots_response_wire() -> serde_json::Value {
9621 serde_json::to_value(
9622 MrtrInputResponse::roots(fastmcp_protocol::ListRootsResult::empty())
9623 .expect("roots response serializes"),
9624 )
9625 .expect("roots response converts to a wire value")
9626 }
9627
9628 struct InputRequiredTool {
9629 legacy_calls: Arc<AtomicUsize>,
9630 final_calls: Arc<AtomicUsize>,
9631 }
9632
9633 struct StateOnlyInputRequiredTool {
9634 initial_calls: Arc<AtomicUsize>,
9635 resumed_calls: Arc<AtomicUsize>,
9636 }
9637
9638 impl ToolHandler for StateOnlyInputRequiredTool {
9639 fn definition(&self) -> Tool {
9640 Tool {
9641 name: "state-only-input-required-tool".to_owned(),
9642 description: None,
9643 input_schema: serde_json::json!({"type": "object"}),
9644 output_schema: None,
9645 icon: None,
9646 version: None,
9647 tags: Vec::new(),
9648 annotations: None,
9649 }
9650 }
9651
9652 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9653 Ok(vec![Content::text(
9654 "legacy state-only input-required result",
9655 )])
9656 }
9657
9658 fn declares_final_mrtr(&self) -> bool {
9659 true
9660 }
9661
9662 fn call_final_outcome(
9663 &self,
9664 _ctx: &McpContext,
9665 _args: serde_json::Value,
9666 ) -> McpResult<FinalToolOutcome> {
9667 self.initial_calls.fetch_add(1, Ordering::SeqCst);
9668 Ok(FinalToolOutcome::InputRequired(
9669 state_only_input_required_result("handler-forged-state"),
9670 ))
9671 }
9672
9673 fn call_final_outcome_async_resuming_in_request<'a>(
9674 &'a self,
9675 ctx: &'a McpContext,
9676 _request_cx: &'a Cx,
9677 arguments: serde_json::Value,
9678 resume_inputs: Option<&'a MrtrCompletedInputs>,
9679 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
9680 Box::pin(async move {
9681 let Some(resume_inputs) = resume_inputs else {
9684 return match self.call_final_outcome(ctx, arguments) {
9685 Ok(result) => Outcome::Ok(result),
9686 Err(error) => Outcome::Err(error),
9687 };
9688 };
9689 if !resume_inputs.responses().is_empty() {
9690 return Outcome::Err(McpError::internal_error(
9691 "state-only MRTR resume unexpectedly carried inputs",
9692 ));
9693 }
9694 self.resumed_calls.fetch_add(1, Ordering::SeqCst);
9695 Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9696 FinalCallToolResult {
9697 content: vec![ContentBlock::text("state-only resumed")],
9698 is_error: false,
9699 structured_content: None,
9700 },
9701 )))
9702 })
9703 }
9704 }
9705
9706 impl ToolHandler for InputRequiredTool {
9707 fn definition(&self) -> Tool {
9708 Tool {
9709 name: "input-required-tool".to_owned(),
9710 description: None,
9711 input_schema: serde_json::json!({"type": "object"}),
9712 output_schema: None,
9713 icon: None,
9714 version: None,
9715 tags: Vec::new(),
9716 annotations: None,
9717 }
9718 }
9719
9720 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9721 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9722 Ok(vec![Content::text("legacy tool result")])
9723 }
9724
9725 fn declares_final_mrtr(&self) -> bool {
9726 true
9727 }
9728
9729 fn call_final_outcome(
9730 &self,
9731 _ctx: &McpContext,
9732 _args: serde_json::Value,
9733 ) -> McpResult<FinalToolOutcome> {
9734 self.final_calls.fetch_add(1, Ordering::SeqCst);
9735 Ok(FinalToolOutcome::InputRequired(input_required_result(
9736 "tool-retry-state",
9737 )))
9738 }
9739
9740 fn call_final_outcome_async_resuming_in_request<'a>(
9741 &'a self,
9742 ctx: &'a McpContext,
9743 _request_cx: &'a Cx,
9744 arguments: serde_json::Value,
9745 resume_inputs: Option<&'a MrtrCompletedInputs>,
9746 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
9747 Box::pin(async move {
9748 let Some(resume_inputs) = resume_inputs else {
9751 return match self.call_final_outcome(ctx, arguments) {
9752 Ok(result) => Outcome::Ok(result),
9753 Err(error) => Outcome::Err(error),
9754 };
9755 };
9756 match resume_inputs.roots("roots") {
9757 Ok(Some(_)) => match self.call_final_outcome(ctx, arguments) {
9758 Ok(result) => Outcome::Ok(result),
9759 Err(error) => Outcome::Err(error),
9760 },
9761 Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
9762 Err(error) => Outcome::Err(error),
9763 }
9764 })
9765 }
9766 }
9767
9768 struct OneShotMrtrTool {
9769 calls: Arc<AtomicUsize>,
9770 }
9771
9772 struct ContextElicitationTool {
9773 initial_calls: Arc<AtomicUsize>,
9774 resumed_calls: Arc<AtomicUsize>,
9775 }
9776
9777 struct ContextSamplingTool {
9778 calls: Arc<AtomicUsize>,
9779 }
9780
9781 impl ToolHandler for ContextElicitationTool {
9782 fn definition(&self) -> Tool {
9783 Tool {
9784 name: "context-elicitation-tool".to_owned(),
9785 description: None,
9786 input_schema: serde_json::json!({"type": "object"}),
9787 output_schema: None,
9788 icon: None,
9789 version: None,
9790 tags: Vec::new(),
9791 annotations: None,
9792 }
9793 }
9794
9795 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9796 Ok(vec![Content::text("legacy result")])
9797 }
9798
9799 fn declares_final_mrtr(&self) -> bool {
9800 true
9801 }
9802
9803 fn call_final_outcome(
9804 &self,
9805 ctx: &McpContext,
9806 _args: serde_json::Value,
9807 ) -> McpResult<FinalToolOutcome> {
9808 self.initial_calls.fetch_add(1, Ordering::SeqCst);
9809 let elicitation = ctx.final_elicitation_form(
9810 "approval",
9811 "Approve this operation",
9812 serde_json::json!({
9813 "type": "object",
9814 "properties": {"approved": {"type": "boolean"}},
9815 "required": ["approved"],
9816 }),
9817 )?;
9818 Ok(FinalToolOutcome::InputRequired(
9819 elicitation.into_input_required()?,
9820 ))
9821 }
9822
9823 fn call_final_outcome_async_resuming_in_request<'a>(
9824 &'a self,
9825 ctx: &'a McpContext,
9826 _request_cx: &'a Cx,
9827 arguments: serde_json::Value,
9828 resume_inputs: Option<&'a MrtrCompletedInputs>,
9829 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
9830 Box::pin(async move {
9831 let Some(resume_inputs) = resume_inputs else {
9832 return match self.call_final_outcome(ctx, arguments) {
9833 Ok(result) => Outcome::Ok(result),
9834 Err(error) => Outcome::Err(error),
9835 };
9836 };
9837 let response = match resume_inputs.elicitation("approval") {
9838 Ok(Some(response)) => response,
9839 Ok(None) => {
9840 return Outcome::Err(McpError::internal_error(
9841 "MRTR elicitation response was lost",
9842 ));
9843 }
9844 Err(error) => return Outcome::Err(error),
9845 };
9846 if response.action != fastmcp_protocol::ElicitAction::Accept
9847 || response
9848 .content
9849 .as_ref()
9850 .and_then(|content| content.get("approved"))
9851 != Some(&fastmcp_protocol::ElicitContentValue::Bool(true))
9852 {
9853 return Outcome::Err(McpError::invalid_params(
9854 "MRTR elicitation response did not approve the operation",
9855 ));
9856 }
9857 self.resumed_calls.fetch_add(1, Ordering::SeqCst);
9858 Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9859 FinalCallToolResult {
9860 content: vec![ContentBlock::text("approved")],
9861 is_error: false,
9862 structured_content: None,
9863 },
9864 )))
9865 })
9866 }
9867 }
9868
9869 impl ToolHandler for ContextSamplingTool {
9870 fn definition(&self) -> Tool {
9871 Tool {
9872 name: "context-sampling-tool".to_owned(),
9873 description: None,
9874 input_schema: serde_json::json!({"type": "object"}),
9875 output_schema: None,
9876 icon: None,
9877 version: None,
9878 tags: Vec::new(),
9879 annotations: None,
9880 }
9881 }
9882
9883 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9884 Ok(vec![Content::text("legacy result")])
9885 }
9886
9887 fn declares_final_mrtr(&self) -> bool {
9888 true
9889 }
9890
9891 fn call_final_outcome(
9892 &self,
9893 _ctx: &McpContext,
9894 _args: serde_json::Value,
9895 ) -> McpResult<FinalToolOutcome> {
9896 self.calls.fetch_add(1, Ordering::SeqCst);
9897 Ok(FinalToolOutcome::InputRequired(
9898 sampling_input_required_result("handler-forged-state"),
9899 ))
9900 }
9901 }
9902
9903 impl ToolHandler for OneShotMrtrTool {
9904 fn definition(&self) -> Tool {
9905 Tool {
9906 name: "one-shot-mrtr-tool".to_owned(),
9907 description: None,
9908 input_schema: serde_json::json!({"type": "object"}),
9909 output_schema: None,
9910 icon: None,
9911 version: None,
9912 tags: Vec::new(),
9913 annotations: None,
9914 }
9915 }
9916
9917 fn declares_final_mrtr(&self) -> bool {
9918 true
9919 }
9920
9921 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9922 Ok(vec![Content::text("one-shot legacy result")])
9923 }
9924
9925 fn call_final_outcome(
9926 &self,
9927 _ctx: &McpContext,
9928 _args: serde_json::Value,
9929 ) -> McpResult<FinalToolOutcome> {
9930 self.calls.fetch_add(1, Ordering::SeqCst);
9931 Ok(FinalToolOutcome::InputRequired(input_required_result(
9932 "one-shot-handler-state",
9933 )))
9934 }
9935
9936 fn call_final_outcome_async_resuming_in_request<'a>(
9937 &'a self,
9938 ctx: &'a McpContext,
9939 _request_cx: &'a Cx,
9940 arguments: serde_json::Value,
9941 resume_inputs: Option<&'a MrtrCompletedInputs>,
9942 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
9943 Box::pin(async move {
9944 let Some(resume_inputs) = resume_inputs else {
9945 return match self.call_final_outcome(ctx, arguments) {
9946 Ok(result) => Outcome::Ok(result),
9947 Err(error) => Outcome::Err(error),
9948 };
9949 };
9950 match resume_inputs.roots("roots") {
9951 Ok(Some(_)) => {
9952 self.calls.fetch_add(1, Ordering::SeqCst);
9953 Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9954 FinalCallToolResult {
9955 content: vec![ContentBlock::text("one-shot resumed")],
9956 is_error: false,
9957 structured_content: None,
9958 },
9959 )))
9960 }
9961 Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
9962 Err(error) => Outcome::Err(error),
9963 }
9964 })
9965 }
9966 }
9967
9968 #[cfg(feature = "tasks")]
9969 struct TaskCapableInputRequiredTool {
9970 final_calls: Arc<AtomicUsize>,
9971 }
9972
9973 #[cfg(feature = "tasks")]
9974 impl ToolHandler for TaskCapableInputRequiredTool {
9975 fn definition(&self) -> Tool {
9976 Tool {
9977 name: "task-capable-input-required-tool".to_owned(),
9978 description: None,
9979 input_schema: serde_json::json!({"type": "object"}),
9980 output_schema: None,
9981 icon: None,
9982 version: None,
9983 tags: Vec::new(),
9984 annotations: None,
9985 }
9986 }
9987
9988 fn declares_final_tasks(&self) -> bool {
9989 true
9990 }
9991
9992 fn declares_final_mrtr(&self) -> bool {
9993 true
9994 }
9995
9996 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9997 Ok(vec![Content::text(
9998 "legacy task-capable input-required result",
9999 )])
10000 }
10001
10002 fn call_final_outcome(
10003 &self,
10004 _ctx: &McpContext,
10005 _args: serde_json::Value,
10006 ) -> McpResult<FinalToolOutcome> {
10007 self.final_calls.fetch_add(1, Ordering::SeqCst);
10008 Ok(FinalToolOutcome::InputRequired(input_required_result(
10009 "task-capable-tool-retry-state",
10010 )))
10011 }
10012 }
10013
10014 struct InputRequiredResource {
10015 legacy_calls: Arc<AtomicUsize>,
10016 final_calls: Arc<AtomicUsize>,
10017 }
10018
10019 impl ResourceHandler for InputRequiredResource {
10020 fn definition(&self) -> Resource {
10021 Resource {
10022 uri: "file:///input-required-resource".to_owned(),
10023 name: "input-required-resource".to_owned(),
10024 description: None,
10025 mime_type: Some("text/plain".to_owned()),
10026 icon: None,
10027 version: None,
10028 tags: Vec::new(),
10029 }
10030 }
10031
10032 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10033 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10034 Ok(vec![ResourceContent {
10035 uri: "file:///input-required-resource".to_owned(),
10036 mime_type: Some("text/plain".to_owned()),
10037 text: Some("legacy resource result".to_owned()),
10038 blob: None,
10039 }])
10040 }
10041
10042 fn declares_final_mrtr(&self) -> bool {
10043 true
10044 }
10045
10046 fn read_final_outcome(
10047 &self,
10048 _ctx: &McpContext,
10049 ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
10050 self.final_calls.fetch_add(1, Ordering::SeqCst);
10051 Ok(FinalMethodOutcome::InputRequired(input_required_result(
10052 "resource-retry-state",
10053 )))
10054 }
10055
10056 fn read_final_outcome_async_with_uri_resuming_in_request<'a>(
10057 &'a self,
10058 ctx: &'a McpContext,
10059 _request_cx: &'a Cx,
10060 _uri: &'a str,
10061 _params: &'a UriParams,
10062 resume_inputs: Option<&'a MrtrCompletedInputs>,
10063 ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
10064 Box::pin(async move {
10065 let Some(resume_inputs) = resume_inputs else {
10066 return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
10067 };
10068 match resume_inputs.roots("roots") {
10069 Ok(Some(_)) => match self.read_final_outcome(ctx) {
10070 Ok(result) => Outcome::Ok(result),
10071 Err(error) => Outcome::Err(error),
10072 },
10073 Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
10074 Err(error) => Outcome::Err(error),
10075 }
10076 })
10077 }
10078 }
10079
10080 struct InputRequiredPrompt {
10081 legacy_calls: Arc<AtomicUsize>,
10082 final_calls: Arc<AtomicUsize>,
10083 }
10084
10085 impl PromptHandler for InputRequiredPrompt {
10086 fn definition(&self) -> Prompt {
10087 Prompt {
10088 name: "input-required-prompt".to_owned(),
10089 description: None,
10090 arguments: Vec::new(),
10091 icon: None,
10092 version: None,
10093 tags: Vec::new(),
10094 }
10095 }
10096
10097 fn get(
10098 &self,
10099 _ctx: &McpContext,
10100 _args: std::collections::HashMap<String, String>,
10101 ) -> McpResult<Vec<PromptMessage>> {
10102 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10103 Ok(Vec::new())
10104 }
10105
10106 fn declares_final_mrtr(&self) -> bool {
10107 true
10108 }
10109
10110 fn get_final_outcome(
10111 &self,
10112 _ctx: &McpContext,
10113 _args: std::collections::HashMap<String, String>,
10114 ) -> McpResult<FinalMethodOutcome<FinalGetPromptResult>> {
10115 self.final_calls.fetch_add(1, Ordering::SeqCst);
10116 Ok(FinalMethodOutcome::InputRequired(input_required_result(
10117 "prompt-retry-state",
10118 )))
10119 }
10120
10121 fn get_final_outcome_async_resuming_in_request<'a>(
10122 &'a self,
10123 ctx: &'a McpContext,
10124 _request_cx: &'a Cx,
10125 arguments: std::collections::HashMap<String, String>,
10126 resume_inputs: Option<&'a MrtrCompletedInputs>,
10127 ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalGetPromptResult>>> {
10128 Box::pin(async move {
10129 let Some(resume_inputs) = resume_inputs else {
10130 return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
10131 };
10132 match resume_inputs.roots("roots") {
10133 Ok(Some(_)) => match self.get_final_outcome(ctx, arguments) {
10134 Ok(result) => Outcome::Ok(result),
10135 Err(error) => Outcome::Err(error),
10136 },
10137 Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
10138 Err(error) => Outcome::Err(error),
10139 }
10140 })
10141 }
10142 }
10143
10144 fn direct_final_prompt_request(id: i64) -> JsonRpcRequest {
10145 JsonRpcRequest::new(
10146 "prompts/get",
10147 Some(serde_json::json!({
10148 "_meta": {
10149 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
10150 "io.modelcontextprotocol/clientCapabilities": {},
10151 },
10152 "name": "direct-final-prompt",
10153 })),
10154 id,
10155 )
10156 }
10157
10158 fn final_prompt_get_request(
10159 name: &str,
10160 arguments: serde_json::Value,
10161 id: i64,
10162 ) -> JsonRpcRequest {
10163 JsonRpcRequest::new(
10164 "prompts/get",
10165 Some(serde_json::json!({
10166 "_meta": {
10167 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
10168 "io.modelcontextprotocol/clientCapabilities": {},
10169 },
10170 "name": name,
10171 "arguments": arguments,
10172 })),
10173 id,
10174 )
10175 }
10176
10177 struct DirectFinalResource {
10178 legacy_calls: Arc<AtomicUsize>,
10179 final_calls: Arc<AtomicUsize>,
10180 }
10181
10182 impl ResourceHandler for DirectFinalResource {
10183 fn definition(&self) -> Resource {
10184 Resource {
10185 uri: "file:///direct-final-resource".to_owned(),
10186 name: "direct-final-resource".to_owned(),
10187 description: Some("legacy resource definition".to_owned()),
10188 mime_type: Some("text/plain".to_owned()),
10189 icon: None,
10190 version: None,
10191 tags: Vec::new(),
10192 }
10193 }
10194
10195 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10196 self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10197 Ok(vec![ResourceContent {
10198 uri: "file:///direct-final-resource".to_owned(),
10199 mime_type: Some("text/plain".to_owned()),
10200 text: Some("legacy resource result".to_owned()),
10201 blob: None,
10202 }])
10203 }
10204
10205 fn read_final(
10206 &self,
10207 _ctx: &McpContext,
10208 ) -> McpResult<CompleteResult<FinalReadResourceResult>> {
10209 self.final_calls.fetch_add(1, Ordering::SeqCst);
10210 let content_meta = OpenMetadata::try_from_entries([(
10211 "com.example/direct-resource".to_owned(),
10212 serde_json::json!({"source": "final-handler"}),
10213 )])
10214 .expect("direct resource content metadata is valid");
10215 Ok(CompleteResult::new(
10216 FinalReadResourceResult {
10217 contents: vec![EmbeddedResourceContents::Text {
10218 uri: AbsoluteUri::parse("file:///direct-final-resource")
10219 .expect("routed direct resource URI is valid"),
10220 text: "direct final resource result".to_owned(),
10221 mime_type: Some("text/markdown".to_owned()),
10222 meta: Some(content_meta),
10223 additional: BTreeMap::from([(
10224 "com.example/direct-field".to_owned(),
10225 serde_json::json!(true),
10226 )]),
10227 }],
10228 ttl_ms: CacheTtl::milliseconds(321),
10229 cache_scope: CacheScope::Public,
10230 },
10231 empty_final_result_meta()?,
10232 ))
10233 }
10234
10235 fn final_resource_read_cache_hint_provenance(
10236 &self,
10237 ) -> FinalResourceReadCacheHintProvenance {
10238 FinalResourceReadCacheHintProvenance::Explicit
10239 }
10240 }
10241
10242 struct HttpsCatalogResource {
10243 client_direct_https: bool,
10244 }
10245
10246 impl ResourceHandler for HttpsCatalogResource {
10247 fn definition(&self) -> Resource {
10248 Resource {
10249 uri: "https://client.example.test/catalog.txt".to_owned(),
10250 name: "client-direct-catalog".to_owned(),
10251 description: None,
10252 mime_type: Some("text/plain".to_owned()),
10253 icon: None,
10254 version: None,
10255 tags: Vec::new(),
10256 }
10257 }
10258
10259 fn final_client_direct_https(&self) -> bool {
10260 self.client_direct_https
10261 }
10262
10263 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10264 Ok(Vec::new())
10265 }
10266 }
10267
10268 struct ClientDirectHttpsPrompt;
10269
10270 impl PromptHandler for ClientDirectHttpsPrompt {
10271 fn definition(&self) -> Prompt {
10272 Prompt {
10273 name: "client-direct-https-prompt".to_owned(),
10274 description: None,
10275 arguments: Vec::new(),
10276 icon: None,
10277 version: None,
10278 tags: Vec::new(),
10279 }
10280 }
10281
10282 fn final_client_direct_https(&self) -> bool {
10283 true
10284 }
10285
10286 fn get(
10287 &self,
10288 _ctx: &McpContext,
10289 _args: std::collections::HashMap<String, String>,
10290 ) -> McpResult<Vec<PromptMessage>> {
10291 Ok(Vec::new())
10292 }
10293
10294 fn get_final(
10295 &self,
10296 _ctx: &McpContext,
10297 _args: std::collections::HashMap<String, String>,
10298 ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
10299 Ok(CompleteResult::new(
10300 FinalGetPromptResult {
10301 description: None,
10302 messages: vec![FinalPromptMessage {
10303 role: fastmcp_protocol::Role::Assistant,
10304 content: ContentBlock::ResourceLink {
10305 icons: None,
10306 name: "client-direct-link".to_owned(),
10307 title: None,
10308 uri: AbsoluteUri::parse("https://client.example.test/prompt-link")
10309 .expect("test HTTPS URI is valid"),
10310 description: None,
10311 mime_type: Some("text/plain".to_owned()),
10312 annotations: None,
10313 size: None,
10314 meta: None,
10315 additional: BTreeMap::new(),
10316 },
10317 }],
10318 },
10319 empty_final_result_meta()?,
10320 ))
10321 }
10322 }
10323
10324 struct MrtrHttpsEmbeddedResource {
10325 initial_calls: Arc<AtomicUsize>,
10326 resumed_calls: Arc<AtomicUsize>,
10327 }
10328
10329 impl ResourceHandler for MrtrHttpsEmbeddedResource {
10330 fn definition(&self) -> Resource {
10331 Resource {
10332 uri: "mcp://uri-policy/mrtr".to_owned(),
10333 name: "uri-policy-mrtr".to_owned(),
10334 description: None,
10335 mime_type: Some("text/plain".to_owned()),
10336 icon: None,
10337 version: None,
10338 tags: Vec::new(),
10339 }
10340 }
10341
10342 fn final_client_direct_https(&self) -> bool {
10343 true
10344 }
10345
10346 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10347 Ok(Vec::new())
10348 }
10349
10350 fn read_final_outcome(
10351 &self,
10352 _ctx: &McpContext,
10353 ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
10354 self.initial_calls.fetch_add(1, Ordering::SeqCst);
10355 Ok(FinalMethodOutcome::InputRequired(input_required_result(
10356 "uri-policy-mrtr-state",
10357 )))
10358 }
10359
10360 fn read_final_outcome_async_with_uri_resuming_in_request<'a>(
10361 &'a self,
10362 _ctx: &'a McpContext,
10363 _request_cx: &'a Cx,
10364 _uri: &'a str,
10365 _params: &'a UriParams,
10366 resume_inputs: Option<&'a MrtrCompletedInputs>,
10367 ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
10368 Box::pin(async move {
10369 let Some(resume_inputs) = resume_inputs else {
10370 return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
10371 };
10372 if !matches!(resume_inputs.roots("roots"), Ok(Some(_))) {
10373 return Outcome::Err(McpError::internal_error(
10374 "MRTR roots input was not preserved",
10375 ));
10376 }
10377 self.resumed_calls.fetch_add(1, Ordering::SeqCst);
10378 Outcome::Ok(FinalMethodOutcome::Complete(CompleteResult::new(
10379 FinalReadResourceResult {
10380 contents: vec![EmbeddedResourceContents::Text {
10381 uri: AbsoluteUri::parse("https://client.example.test/mrtr-content")
10382 .expect("test HTTPS URI is valid"),
10383 text: "must not be emitted as embedded content".to_owned(),
10384 mime_type: Some("text/plain".to_owned()),
10385 meta: None,
10386 additional: BTreeMap::new(),
10387 }],
10388 ttl_ms: CacheTtl::milliseconds(1),
10389 cache_scope: CacheScope::Private,
10390 },
10391 empty_final_result_meta().expect("empty final metadata is valid"),
10392 )))
10393 })
10394 }
10395 }
10396
10397 struct SentinelHintResource {
10398 provenance: FinalResourceReadCacheHintProvenance,
10399 }
10400
10401 impl ResourceHandler for SentinelHintResource {
10402 fn definition(&self) -> Resource {
10403 Resource {
10404 uri: "file:///sentinel-hint-resource".to_owned(),
10405 name: "sentinel-hint-resource".to_owned(),
10406 description: None,
10407 mime_type: Some("text/plain".to_owned()),
10408 icon: None,
10409 version: None,
10410 tags: Vec::new(),
10411 }
10412 }
10413
10414 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10415 Ok(Vec::new())
10416 }
10417
10418 fn read_final(
10419 &self,
10420 _ctx: &McpContext,
10421 ) -> McpResult<CompleteResult<FinalReadResourceResult>> {
10422 Ok(CompleteResult::new(
10423 FinalReadResourceResult {
10424 contents: Vec::new(),
10425 ttl_ms: CacheTtl::milliseconds(DEFAULT_FINAL_RESOURCE_TTL_MS),
10428 cache_scope: CacheScope::Private,
10429 },
10430 empty_final_result_meta()?,
10431 ))
10432 }
10433
10434 fn final_resource_read_cache_hint_provenance(
10435 &self,
10436 ) -> FinalResourceReadCacheHintProvenance {
10437 self.provenance
10438 }
10439 }
10440
10441 fn direct_final_resource_request(id: i64) -> JsonRpcRequest {
10442 JsonRpcRequest::new(
10443 "resources/read",
10444 Some(serde_json::json!({
10445 "_meta": {
10446 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
10447 "io.modelcontextprotocol/clientCapabilities": {},
10448 },
10449 "uri": "file:///direct-final-resource",
10450 })),
10451 id,
10452 )
10453 }
10454
10455 struct EchoCompletion;
10456
10457 impl CompletionHandler for EchoCompletion {
10458 fn complete_legacy(
10459 &self,
10460 _ctx: &McpContext,
10461 params: LegacyCompletionParams,
10462 ) -> McpResult<CompletionValues> {
10463 Ok(CompletionValues {
10464 values: vec![format!("{}ging", params.argument.value)],
10465 total: Some(1),
10466 has_more: Some(false),
10467 })
10468 }
10469
10470 fn complete_final(
10471 &self,
10472 _ctx: &McpContext,
10473 params: FinalCompletionParams,
10474 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
10475 Ok(fastmcp_protocol::FinalCompletionValues {
10476 values: vec![format!("{}ging", params.argument.value)],
10477 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
10478 has_more: Some(false),
10479 })
10480 }
10481 }
10482
10483 struct CountingCompletion {
10484 final_calls: Arc<AtomicUsize>,
10485 }
10486
10487 impl CompletionHandler for CountingCompletion {
10488 fn complete_legacy(
10489 &self,
10490 _ctx: &McpContext,
10491 _params: LegacyCompletionParams,
10492 ) -> McpResult<CompletionValues> {
10493 Ok(CompletionValues {
10494 values: vec!["legacy".to_owned()],
10495 total: Some(1),
10496 has_more: Some(false),
10497 })
10498 }
10499
10500 fn complete_final(
10501 &self,
10502 _ctx: &McpContext,
10503 params: FinalCompletionParams,
10504 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
10505 self.final_calls.fetch_add(1, Ordering::SeqCst);
10506 Ok(fastmcp_protocol::FinalCompletionValues {
10507 values: vec![format!("{}ging", params.argument.value)],
10508 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
10509 has_more: Some(false),
10510 })
10511 }
10512 }
10513
10514 struct ProviderCompletion {
10515 value: &'static str,
10516 final_calls: Arc<AtomicUsize>,
10517 }
10518
10519 impl CompletionHandler for ProviderCompletion {
10520 fn complete_legacy(
10521 &self,
10522 _ctx: &McpContext,
10523 _params: LegacyCompletionParams,
10524 ) -> McpResult<CompletionValues> {
10525 Ok(CompletionValues {
10526 values: vec![self.value.to_owned()],
10527 total: Some(1),
10528 has_more: Some(false),
10529 })
10530 }
10531
10532 fn complete_final(
10533 &self,
10534 _ctx: &McpContext,
10535 _params: FinalCompletionParams,
10536 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
10537 self.final_calls.fetch_add(1, Ordering::SeqCst);
10538 Ok(fastmcp_protocol::FinalCompletionValues {
10539 values: vec![self.value.to_owned()],
10540 total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
10541 has_more: Some(false),
10542 })
10543 }
10544 }
10545
10546 struct ReversibleLevelFourTemplateResource {
10547 read_calls: Arc<AtomicUsize>,
10548 }
10549
10550 impl ResourceHandler for ReversibleLevelFourTemplateResource {
10551 fn definition(&self) -> Resource {
10552 Resource {
10553 uri: "mcp://resource/template".to_owned(),
10554 name: "level-four-template".to_owned(),
10555 description: None,
10556 mime_type: Some("text/plain".to_owned()),
10557 icon: None,
10558 version: None,
10559 tags: Vec::new(),
10560 }
10561 }
10562
10563 fn template(&self) -> Option<ResourceTemplate> {
10564 Some(marked_template(
10565 "mcp://resource{/collection*}/manifest{?revision*}",
10566 "level-four-template",
10567 ))
10568 }
10569
10570 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10571 unreachable!("templated reads receive their URI parameters")
10572 }
10573
10574 fn read_with_uri(
10575 &self,
10576 _ctx: &McpContext,
10577 uri: &str,
10578 params: &UriParams,
10579 ) -> McpResult<Vec<ResourceContent>> {
10580 self.read_calls.fetch_add(1, Ordering::SeqCst);
10581 Ok(vec![ResourceContent {
10582 uri: uri.to_owned(),
10583 mime_type: Some("text/plain".to_owned()),
10584 text: Some(format!(
10585 "{}:{}",
10586 params.get("collection").expect("collection is captured"),
10587 params.get("revision").expect("revision is captured")
10588 )),
10589 blob: None,
10590 }])
10591 }
10592 }
10593
10594 struct LegacyTemplateResource {
10595 read_calls: Arc<AtomicUsize>,
10596 }
10597
10598 impl ResourceHandler for LegacyTemplateResource {
10599 fn definition(&self) -> Resource {
10600 Resource {
10601 uri: "mcp://resource/legacy-template".to_owned(),
10602 name: "legacy-template".to_owned(),
10603 description: None,
10604 mime_type: Some("text/plain".to_owned()),
10605 icon: None,
10606 version: None,
10607 tags: Vec::new(),
10608 }
10609 }
10610
10611 fn template(&self) -> Option<ResourceTemplate> {
10612 Some(marked_template(
10613 "mcp://resource/{collection}/manifest?revision={revision}",
10614 "legacy-template",
10615 ))
10616 }
10617
10618 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10619 unreachable!("templated reads receive their URI parameters")
10620 }
10621
10622 fn read_with_uri(
10623 &self,
10624 _ctx: &McpContext,
10625 uri: &str,
10626 params: &UriParams,
10627 ) -> McpResult<Vec<ResourceContent>> {
10628 self.read_calls.fetch_add(1, Ordering::SeqCst);
10629 Ok(vec![ResourceContent {
10630 uri: uri.to_owned(),
10631 mime_type: Some("text/plain".to_owned()),
10632 text: Some(format!(
10633 "{}:{}",
10634 params.get("collection").expect("collection is captured"),
10635 params.get("revision").expect("revision is captured")
10636 )),
10637 blob: None,
10638 }])
10639 }
10640 }
10641
10642 struct CompletionValueBoundary {
10643 final_calls: Arc<AtomicUsize>,
10644 }
10645
10646 impl CompletionHandler for CompletionValueBoundary {
10647 fn complete_legacy(
10648 &self,
10649 _ctx: &McpContext,
10650 _params: LegacyCompletionParams,
10651 ) -> McpResult<CompletionValues> {
10652 Ok(CompletionValues {
10653 values: Vec::new(),
10654 total: None,
10655 has_more: None,
10656 })
10657 }
10658
10659 fn complete_final(
10660 &self,
10661 _ctx: &McpContext,
10662 params: FinalCompletionParams,
10663 ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
10664 self.final_calls.fetch_add(1, Ordering::SeqCst);
10665 let value_count = if params.argument.value == "one-over" {
10666 fastmcp_protocol::MAX_COMPLETION_VALUES + 1
10667 } else {
10668 fastmcp_protocol::MAX_COMPLETION_VALUES
10669 };
10670 Ok(fastmcp_protocol::FinalCompletionValues {
10671 values: (0..value_count)
10672 .map(|index| format!("completion-{index}"))
10673 .collect(),
10674 total: (params.argument.value == "negative-total")
10675 .then(|| fastmcp_protocol::JsonInteger::from(-1_i64)),
10676 has_more: None,
10677 })
10678 }
10679 }
10680
10681 struct ConcurrentModernTool {
10682 started: Arc<AtomicUsize>,
10683 completed: Arc<Mutex<Vec<String>>>,
10684 }
10685
10686 impl ConcurrentModernTool {
10687 fn new(started: Arc<AtomicUsize>, completed: Arc<Mutex<Vec<String>>>) -> Self {
10688 Self { started, completed }
10689 }
10690 }
10691
10692 impl ToolHandler for ConcurrentModernTool {
10693 fn definition(&self) -> Tool {
10694 Tool {
10695 name: "concurrent-modern-tool".to_string(),
10696 description: Some("deterministic modern dispatch probe".to_string()),
10697 input_schema: serde_json::json!({
10698 "type": "object",
10699 "required": ["request"],
10700 "properties": {"request": {"type": "string"}},
10701 }),
10702 output_schema: None,
10703 icon: None,
10704 version: None,
10705 tags: vec![],
10706 annotations: None,
10707 }
10708 }
10709
10710 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10711 Err(McpError::internal_error(
10712 "concurrent modern dispatch requires the async request hook",
10713 ))
10714 }
10715
10716 fn call_final_outcome_async_in_request<'a>(
10720 &'a self,
10721 ctx: &'a McpContext,
10722 request_cx: &'a Cx,
10723 arguments: serde_json::Value,
10724 ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10725 Box::pin(async move {
10726 let label = arguments
10727 .get("request")
10728 .and_then(serde_json::Value::as_str)
10729 .unwrap_or("missing")
10730 .to_string();
10731 self.started.fetch_add(1, Ordering::SeqCst);
10732
10733 while self.started.load(Ordering::SeqCst) < 2 {
10734 if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
10735 return Outcome::Cancelled(asupersync::CancelReason::user(
10736 "request cancelled before concurrent admission",
10737 ));
10738 }
10739 yield_once().await;
10740 }
10741
10742 if label == "cancelled" {
10743 loop {
10744 if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
10745 return Outcome::Cancelled(asupersync::CancelReason::user(
10746 "request cancellation observed by child Cx",
10747 ));
10748 }
10749 yield_once().await;
10750 }
10751 }
10752
10753 if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
10754 return Outcome::Cancelled(asupersync::CancelReason::user(
10755 "request cancelled before completion",
10756 ));
10757 }
10758 self.completed
10759 .lock()
10760 .expect("completion probe lock is not poisoned")
10761 .push(label.clone());
10762 match crate::handler::promote_legacy_tool_content(vec![Content::text(label)]) {
10763 Ok(result) => Outcome::Ok(FinalToolOutcome::Complete(result)),
10764 Err(error) => Outcome::Err(error),
10765 }
10766 })
10767 }
10768 }
10769
10770 struct ErrorTool {
10771 name: &'static str,
10772 code: McpErrorCode,
10773 }
10774
10775 impl ToolHandler for ErrorTool {
10776 fn definition(&self) -> Tool {
10777 Tool {
10778 name: self.name.to_string(),
10779 description: None,
10780 input_schema: serde_json::json!({"type": "object"}),
10781 output_schema: None,
10782 icon: None,
10783 version: None,
10784 tags: vec![],
10785 annotations: None,
10786 }
10787 }
10788
10789 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10790 Err(McpError::new(self.code, "nested tool error"))
10791 }
10792 }
10793
10794 struct AlternatingTool {
10795 calls: Arc<AtomicU64>,
10796 }
10797
10798 impl ToolHandler for AlternatingTool {
10799 fn definition(&self) -> Tool {
10800 Tool {
10801 name: "alternating_tool".to_string(),
10802 description: None,
10803 input_schema: serde_json::json!({"type": "object"}),
10804 output_schema: None,
10805 icon: None,
10806 version: None,
10807 tags: vec![],
10808 annotations: None,
10809 }
10810 }
10811
10812 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10813 Err(McpError::internal_error("async alternating tool only"))
10814 }
10815
10816 fn call_async<'a>(
10817 &'a self,
10818 ctx: &'a McpContext,
10819 _arguments: serde_json::Value,
10820 ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
10821 Box::pin(async move {
10822 self.calls.fetch_add(1, Ordering::Relaxed);
10823 match ctx.read_resource("loop://resource").await {
10824 Ok(_) => Outcome::Ok(vec![Content::text("unexpected completion")]),
10825 Err(error) => Outcome::Err(error),
10826 }
10827 })
10828 }
10829 }
10830
10831 struct NamedResource {
10832 uri: String,
10833 tags: Vec<String>,
10834 }
10835
10836 impl NamedResource {
10837 fn new(uri: &str) -> Self {
10838 Self {
10839 uri: uri.to_string(),
10840 tags: vec![],
10841 }
10842 }
10843 fn with_tags(uri: &str, tags: Vec<String>) -> Self {
10844 Self {
10845 uri: uri.to_string(),
10846 tags,
10847 }
10848 }
10849 }
10850
10851 impl ResourceHandler for NamedResource {
10852 fn definition(&self) -> Resource {
10853 Resource {
10854 uri: self.uri.clone(),
10855 name: self.uri.clone(),
10856 description: None,
10857 mime_type: Some("text/plain".to_string()),
10858 icon: None,
10859 version: None,
10860 tags: self.tags.clone(),
10861 }
10862 }
10863 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10864 Ok(vec![ResourceContent {
10865 uri: self.uri.clone(),
10866 mime_type: Some("text/plain".to_string()),
10867 text: Some("content".to_string()),
10868 blob: None,
10869 }])
10870 }
10871 }
10872
10873 struct AlternatingResource {
10874 calls: Arc<AtomicU64>,
10875 }
10876
10877 impl ResourceHandler for AlternatingResource {
10878 fn definition(&self) -> Resource {
10879 Resource {
10880 uri: "loop://resource".to_string(),
10881 name: "alternating-resource".to_string(),
10882 description: None,
10883 mime_type: Some("text/plain".to_string()),
10884 icon: None,
10885 version: None,
10886 tags: vec![],
10887 }
10888 }
10889
10890 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10891 Err(McpError::internal_error("async alternating resource only"))
10892 }
10893
10894 fn read_async<'a>(
10895 &'a self,
10896 ctx: &'a McpContext,
10897 ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
10898 Box::pin(async move {
10899 self.calls.fetch_add(1, Ordering::Relaxed);
10900 match ctx
10901 .call_tool("alternating_tool", serde_json::json!({}))
10902 .await
10903 {
10904 Ok(_) => Outcome::Ok(vec![ResourceContent {
10905 uri: "loop://resource".to_string(),
10906 mime_type: Some("text/plain".to_string()),
10907 text: Some("unexpected completion".to_string()),
10908 blob: None,
10909 }]),
10910 Err(error) => Outcome::Err(error),
10911 }
10912 })
10913 }
10914 }
10915
10916 struct CostLedgerTool {
10917 remaining_after_parent_debit: Arc<AtomicU64>,
10918 remaining_after_nested_read: Arc<AtomicU64>,
10919 }
10920
10921 impl ToolHandler for CostLedgerTool {
10922 fn definition(&self) -> Tool {
10923 Tool {
10924 name: "cost_ledger_tool".to_string(),
10925 description: None,
10926 input_schema: serde_json::json!({"type": "object"}),
10927 output_schema: None,
10928 icon: None,
10929 version: None,
10930 tags: vec![],
10931 annotations: None,
10932 }
10933 }
10934
10935 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10936 Err(McpError::internal_error("async cost-ledger tool only"))
10937 }
10938
10939 fn call_async<'a>(
10940 &'a self,
10941 ctx: &'a McpContext,
10942 _arguments: serde_json::Value,
10943 ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
10944 Box::pin(async move {
10945 if ctx.consume_cost(1).is_err() {
10946 return Outcome::Err(McpError::request_cancelled());
10947 }
10948 self.remaining_after_parent_debit.store(
10949 ctx.budget()
10950 .cost_quota
10951 .expect("test request has finite cost quota"),
10952 Ordering::Relaxed,
10953 );
10954
10955 if let Err(error) = ctx.read_resource("cost://nested").await {
10956 return Outcome::Err(error);
10957 }
10958 self.remaining_after_nested_read.store(
10959 ctx.budget()
10960 .cost_quota
10961 .expect("test request has finite cost quota"),
10962 Ordering::Relaxed,
10963 );
10964 Outcome::Ok(vec![Content::text("shared ledger")])
10965 })
10966 }
10967 }
10968
10969 struct CostLedgerResource {
10970 remaining_after_nested_debit: Arc<AtomicU64>,
10971 }
10972
10973 impl ResourceHandler for CostLedgerResource {
10974 fn definition(&self) -> Resource {
10975 Resource {
10976 uri: "cost://nested".to_string(),
10977 name: "cost-ledger-resource".to_string(),
10978 description: None,
10979 mime_type: Some("text/plain".to_string()),
10980 icon: None,
10981 version: None,
10982 tags: vec![],
10983 }
10984 }
10985
10986 fn read(&self, ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10987 ctx.consume_cost(1)
10988 .map_err(|_| McpError::request_cancelled())?;
10989 self.remaining_after_nested_debit.store(
10990 ctx.budget()
10991 .cost_quota
10992 .expect("test request has finite cost quota"),
10993 Ordering::Relaxed,
10994 );
10995 Ok(vec![ResourceContent {
10996 uri: "cost://nested".to_string(),
10997 mime_type: Some("text/plain".to_string()),
10998 text: Some("nested debit".to_string()),
10999 blob: None,
11000 }])
11001 }
11002 }
11003
11004 struct NamedPrompt {
11005 name: String,
11006 tags: Vec<String>,
11007 }
11008
11009 impl NamedPrompt {
11010 fn new(name: &str) -> Self {
11011 Self {
11012 name: name.to_string(),
11013 tags: vec![],
11014 }
11015 }
11016 fn with_tags(name: &str, tags: Vec<String>) -> Self {
11017 Self {
11018 name: name.to_string(),
11019 tags,
11020 }
11021 }
11022 }
11023
11024 impl PromptHandler for NamedPrompt {
11025 fn definition(&self) -> Prompt {
11026 Prompt {
11027 name: self.name.clone(),
11028 description: Some(format!("Prompt {}", self.name)),
11029 arguments: vec![],
11030 icon: None,
11031 version: None,
11032 tags: self.tags.clone(),
11033 }
11034 }
11035 fn get(
11036 &self,
11037 _ctx: &McpContext,
11038 _args: std::collections::HashMap<String, String>,
11039 ) -> McpResult<Vec<PromptMessage>> {
11040 Ok(vec![])
11041 }
11042 }
11043
11044 fn marked_template(uri_template: &str, marker: &str) -> ResourceTemplate {
11045 ResourceTemplate {
11046 uri_template: uri_template.to_string(),
11047 name: marker.to_string(),
11048 description: Some(marker.to_string()),
11049 mime_type: None,
11050 icon: None,
11051 version: None,
11052 tags: vec![marker.to_string()],
11053 }
11054 }
11055
11056 fn marked_router(marker: &str) -> Router {
11057 let mut router = Router::new();
11058 router
11059 .add_tool(NamedTool::with_tags(
11060 "duplicate_tool",
11061 vec![marker.to_string()],
11062 ))
11063 .expect("tool registration succeeds");
11064 router.add_resource(NamedResource::with_tags(
11065 "duplicate://resource",
11066 vec![marker.to_string()],
11067 ));
11068 router.add_resource_template(marked_template("duplicate://{item}", marker));
11069 router.add_prompt(NamedPrompt::with_tags(
11070 "duplicate_prompt",
11071 vec![marker.to_string()],
11072 ));
11073 router
11074 }
11075
11076 fn assert_router_marker(router: &Router, marker: &str) {
11077 assert_eq!(
11078 router
11079 .get_tool("duplicate_tool")
11080 .expect("tool exists")
11081 .definition()
11082 .tags,
11083 vec![marker.to_string()]
11084 );
11085 assert_eq!(
11086 router
11087 .get_resource("duplicate://resource")
11088 .expect("resource exists")
11089 .definition()
11090 .tags,
11091 vec![marker.to_string()]
11092 );
11093 assert_eq!(
11094 router
11095 .get_resource_template("duplicate://{item}")
11096 .expect("resource template exists")
11097 .tags,
11098 vec![marker.to_string()]
11099 );
11100 assert_eq!(
11101 router
11102 .get_prompt("duplicate_prompt")
11103 .expect("prompt exists")
11104 .definition()
11105 .tags,
11106 vec![marker.to_string()]
11107 );
11108 }
11109
11110 struct BudgetProbeTool {
11111 timeout: Option<Duration>,
11112 delay: Duration,
11113 observed_deadline: Arc<Mutex<Option<Time>>>,
11114 timeout_read: Arc<std::sync::atomic::AtomicBool>,
11115 }
11116
11117 impl ToolHandler for BudgetProbeTool {
11118 fn definition(&self) -> Tool {
11119 Tool {
11120 name: "budget_probe".to_string(),
11121 description: None,
11122 input_schema: serde_json::json!({"type": "object"}),
11123 output_schema: None,
11124 icon: None,
11125 version: None,
11126 tags: vec![],
11127 annotations: None,
11128 }
11129 }
11130
11131 fn timeout(&self) -> Option<Duration> {
11132 self.timeout_read.store(true, Ordering::Relaxed);
11133 self.timeout
11134 }
11135
11136 fn call(&self, ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11137 eprintln!(
11138 "WM-BUDGET call entered deadline={:?}",
11139 ctx.budget().deadline
11140 );
11141 *self
11142 .observed_deadline
11143 .lock()
11144 .unwrap_or_else(std::sync::PoisonError::into_inner) = ctx.budget().deadline;
11145 if !self.delay.is_zero() {
11146 std::thread::sleep(self.delay);
11147 }
11148 Ok(vec![Content::text("completed")])
11149 }
11150 }
11151
11152 struct SlowDefinitionTool {
11153 definition_reads: Arc<AtomicU64>,
11154 called: Arc<std::sync::atomic::AtomicBool>,
11155 }
11156
11157 impl ToolHandler for SlowDefinitionTool {
11158 fn definition(&self) -> Tool {
11159 if self.definition_reads.fetch_add(1, Ordering::Relaxed) > 0 {
11160 eprintln!(
11161 "WM-PROBE requery backtrace:\n{}",
11162 std::backtrace::Backtrace::force_capture()
11163 );
11164 std::thread::sleep(Duration::from_millis(15));
11165 }
11166 Tool {
11167 name: "slow_definition".to_string(),
11168 description: None,
11169 input_schema: serde_json::json!({"type": "object"}),
11170 output_schema: None,
11171 icon: None,
11172 version: None,
11173 tags: vec![],
11174 annotations: None,
11175 }
11176 }
11177
11178 fn timeout(&self) -> Option<Duration> {
11179 Some(Duration::from_millis(1))
11180 }
11181
11182 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11183 self.called.store(true, Ordering::Relaxed);
11184 Ok(vec![Content::text("must not run")])
11185 }
11186 }
11187
11188 const PANIC_CANARY: &str = "Bearer peer-secret\n\u{001b}[31mred\u{001b}[0m\u{001b}]8;;https://invalid\u{0007}link\u{202e}";
11189
11190 struct PanickingDisplay(String);
11191
11192 impl fmt::Display for PanickingDisplay {
11193 fn fmt(&self, _formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
11194 panic!("display-must-not-run: {}", self.0);
11195 }
11196 }
11197
11198 struct UnwindingPanicTool {
11199 payload: String,
11200 non_string: bool,
11201 }
11202
11203 impl ToolHandler for UnwindingPanicTool {
11204 fn definition(&self) -> Tool {
11205 Tool {
11206 name: "panic_tool".to_string(),
11207 description: None,
11208 input_schema: serde_json::json!({"type": "object"}),
11209 output_schema: None,
11210 icon: None,
11211 version: None,
11212 tags: vec![],
11213 annotations: None,
11214 }
11215 }
11216
11217 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11218 unreachable!("the async override is the handler boundary under test")
11219 }
11220
11221 fn call_async<'a>(
11222 &'a self,
11223 _ctx: &'a McpContext,
11224 _arguments: serde_json::Value,
11225 ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
11226 Box::pin(async move {
11227 if self.non_string {
11228 std::panic::panic_any(PanickingDisplay(self.payload.clone()));
11229 }
11230 panic!("{}", self.payload);
11231 })
11232 }
11233 }
11234
11235 struct OutcomePanicTool(String);
11236
11237 impl ToolHandler for OutcomePanicTool {
11238 fn definition(&self) -> Tool {
11239 Tool {
11240 name: "outcome_panic_tool".to_string(),
11241 description: None,
11242 input_schema: serde_json::json!({"type": "object"}),
11243 output_schema: None,
11244 icon: None,
11245 version: None,
11246 tags: vec![],
11247 annotations: None,
11248 }
11249 }
11250
11251 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11252 unreachable!("the async override is the handler boundary under test")
11253 }
11254
11255 fn call_async<'a>(
11256 &'a self,
11257 _ctx: &'a McpContext,
11258 _arguments: serde_json::Value,
11259 ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
11260 Box::pin(async move {
11261 Outcome::Panicked(asupersync::types::PanicPayload::new(self.0.clone()))
11262 })
11263 }
11264 }
11265
11266 struct OpaqueInternalTool;
11267
11268 impl ToolHandler for OpaqueInternalTool {
11269 fn definition(&self) -> Tool {
11270 Tool {
11271 name: "opaque_internal_tool".to_string(),
11272 description: None,
11273 input_schema: serde_json::json!({"type": "object"}),
11274 output_schema: None,
11275 icon: None,
11276 version: None,
11277 tags: vec![],
11278 annotations: None,
11279 }
11280 }
11281
11282 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11283 Err(McpError::with_data(
11284 McpErrorCode::InternalError,
11285 PANIC_CANARY,
11286 serde_json::json!({"secret": PANIC_CANARY}),
11287 ))
11288 }
11289 }
11290
11291 struct OpaqueInternalResource;
11292
11293 impl ResourceHandler for OpaqueInternalResource {
11294 fn definition(&self) -> Resource {
11295 Resource {
11296 uri: "opaque://internal".to_string(),
11297 name: "opaque-internal-resource".to_string(),
11298 description: None,
11299 mime_type: None,
11300 icon: None,
11301 version: None,
11302 tags: vec![],
11303 }
11304 }
11305
11306 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11307 Err(McpError::with_data(
11308 McpErrorCode::InternalError,
11309 PANIC_CANARY,
11310 serde_json::json!({"secret": PANIC_CANARY}),
11311 ))
11312 }
11313 }
11314
11315 struct OpaqueInternalPrompt;
11316
11317 impl PromptHandler for OpaqueInternalPrompt {
11318 fn definition(&self) -> Prompt {
11319 Prompt {
11320 name: "opaque_internal_prompt".to_string(),
11321 description: None,
11322 arguments: vec![],
11323 icon: None,
11324 version: None,
11325 tags: vec![],
11326 }
11327 }
11328
11329 fn get(
11330 &self,
11331 _ctx: &McpContext,
11332 _args: std::collections::HashMap<String, String>,
11333 ) -> McpResult<Vec<PromptMessage>> {
11334 Err(McpError::with_data(
11335 McpErrorCode::InternalError,
11336 PANIC_CANARY,
11337 serde_json::json!({"secret": PANIC_CANARY}),
11338 ))
11339 }
11340 }
11341
11342 struct PanicResource;
11343
11344 impl ResourceHandler for PanicResource {
11345 fn definition(&self) -> Resource {
11346 Resource {
11347 uri: "panic://resource".to_string(),
11348 name: "panic-resource".to_string(),
11349 description: None,
11350 mime_type: None,
11351 icon: None,
11352 version: None,
11353 tags: vec![],
11354 }
11355 }
11356
11357 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11358 panic!("{PANIC_CANARY}")
11359 }
11360 }
11361
11362 struct PanicPrompt;
11363
11364 impl PromptHandler for PanicPrompt {
11365 fn definition(&self) -> Prompt {
11366 Prompt {
11367 name: "panic_prompt".to_string(),
11368 description: None,
11369 arguments: vec![],
11370 icon: None,
11371 version: None,
11372 tags: vec![],
11373 }
11374 }
11375
11376 fn get(
11377 &self,
11378 _ctx: &McpContext,
11379 _args: std::collections::HashMap<String, String>,
11380 ) -> McpResult<Vec<PromptMessage>> {
11381 panic!("{PANIC_CANARY}")
11382 }
11383 }
11384
11385 struct DefinitionPanicTool(std::sync::atomic::AtomicBool);
11386
11387 impl ToolHandler for DefinitionPanicTool {
11388 fn definition(&self) -> Tool {
11389 if self.0.swap(true, Ordering::Relaxed) {
11390 panic!("{PANIC_CANARY}");
11391 }
11392 Tool {
11393 name: "definition_panic_tool".to_string(),
11394 description: None,
11395 input_schema: serde_json::json!({"type": "object"}),
11396 output_schema: None,
11397 icon: None,
11398 version: None,
11399 tags: vec![],
11400 annotations: None,
11401 }
11402 }
11403
11404 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11405 Ok(vec![])
11406 }
11407 }
11408
11409 struct DefinitionPanicResource(std::sync::atomic::AtomicBool);
11410
11411 impl ResourceHandler for DefinitionPanicResource {
11412 fn definition(&self) -> Resource {
11413 if self.0.swap(true, Ordering::Relaxed) {
11414 panic!("{PANIC_CANARY}");
11415 }
11416 Resource {
11417 uri: "panic://definition".to_string(),
11418 name: "definition-panic-resource".to_string(),
11419 description: None,
11420 mime_type: None,
11421 icon: None,
11422 version: None,
11423 tags: vec![],
11424 }
11425 }
11426
11427 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11428 Ok(vec![])
11429 }
11430 }
11431
11432 struct TemplatePanicResource(std::sync::atomic::AtomicBool);
11433
11434 impl ResourceHandler for TemplatePanicResource {
11435 fn definition(&self) -> Resource {
11436 Resource {
11437 uri: "panic-template://placeholder".to_string(),
11438 name: "template-panic-resource".to_string(),
11439 description: None,
11440 mime_type: None,
11441 icon: None,
11442 version: None,
11443 tags: vec![],
11444 }
11445 }
11446
11447 fn template(&self) -> Option<ResourceTemplate> {
11448 if self.0.swap(true, Ordering::Relaxed) {
11449 panic!("{PANIC_CANARY}");
11450 }
11451 Some(ResourceTemplate {
11452 uri_template: "panic-template://{id}".to_string(),
11453 name: "template-panic-resource".to_string(),
11454 description: None,
11455 mime_type: None,
11456 icon: None,
11457 version: None,
11458 tags: vec![],
11459 })
11460 }
11461
11462 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11463 unreachable!("templated reads use read_with_uri")
11464 }
11465
11466 fn read_with_uri(
11467 &self,
11468 _ctx: &McpContext,
11469 uri: &str,
11470 _params: &UriParams,
11471 ) -> McpResult<Vec<ResourceContent>> {
11472 Ok(vec![ResourceContent {
11473 uri: uri.to_string(),
11474 mime_type: None,
11475 text: Some("template-content".to_string()),
11476 blob: None,
11477 }])
11478 }
11479 }
11480
11481 struct DefinitionPanicPrompt(std::sync::atomic::AtomicBool);
11482
11483 impl PromptHandler for DefinitionPanicPrompt {
11484 fn definition(&self) -> Prompt {
11485 if self.0.swap(true, Ordering::Relaxed) {
11486 panic!("{PANIC_CANARY}");
11487 }
11488 Prompt {
11489 name: "definition_panic_prompt".to_string(),
11490 description: None,
11491 arguments: vec![],
11492 icon: None,
11493 version: None,
11494 tags: vec![],
11495 }
11496 }
11497
11498 fn get(
11499 &self,
11500 _ctx: &McpContext,
11501 _args: std::collections::HashMap<String, String>,
11502 ) -> McpResult<Vec<PromptMessage>> {
11503 Ok(vec![])
11504 }
11505 }
11506
11507 #[test]
11510 fn new_router_is_empty() {
11511 let r = Router::new();
11512 assert_eq!(r.tools_count(), 0);
11513 assert_eq!(r.resources_count(), 0);
11514 assert_eq!(r.resource_templates_count(), 0);
11515 assert_eq!(r.prompts_count(), 0);
11516 assert!(r.tools().is_empty());
11517 assert!(r.resources().is_empty());
11518 assert!(r.resource_templates().is_empty());
11519 assert!(r.prompts().is_empty());
11520 }
11521
11522 #[test]
11523 fn default_router_is_empty() {
11524 let r = Router::default();
11525 assert_eq!(r.tools_count(), 0);
11526 }
11527
11528 #[test]
11531 fn add_and_get_tool() {
11532 let mut r = Router::new();
11533 r.add_tool(NamedTool::new("my_tool"))
11534 .expect("tool registration succeeds");
11535 assert_eq!(r.tools_count(), 1);
11536 assert!(r.get_tool("my_tool").is_some());
11537 assert!(r.get_tool("other").is_none());
11538 }
11539
11540 #[test]
11541 fn add_tool_replace_on_duplicate() {
11542 let mut r = Router::new();
11543 r.add_tool(NamedTool::new("t"))
11544 .expect("initial registration succeeds");
11545 r.add_tool(NamedTool::new("t"))
11546 .expect("valid replacement succeeds");
11547 assert_eq!(r.tools_count(), 1);
11548 assert_eq!(r.tools().len(), 1);
11550 }
11551
11552 #[test]
11553 fn tools_returns_definitions_in_order() {
11554 let mut r = Router::new();
11555 r.add_tool(NamedTool::new("b"))
11556 .expect("tool registration succeeds");
11557 r.add_tool(NamedTool::new("a"))
11558 .expect("tool registration succeeds");
11559 let names: Vec<_> = r.tools().iter().map(|t| t.name.clone()).collect();
11560 assert_eq!(names, vec!["b", "a"]); }
11562
11563 #[test]
11564 fn explicit_legacy_tool_is_callable_only_on_exact_2024_routes() {
11565 let mut router = Router::new();
11566 router
11567 .add_legacy_tool(InvalidFinalSchemaNamedTool::with_tags(
11568 "legacy-only-tool",
11569 vec!["legacy".to_owned()],
11570 ))
11571 .expect("explicit legacy registration does not claim final admission");
11572 assert_eq!(
11573 router
11574 .tools()
11575 .into_iter()
11576 .map(|tool| tool.name)
11577 .collect::<Vec<_>>(),
11578 vec!["legacy-only-tool"]
11579 );
11580 assert!(
11581 !router
11582 .server_discovery_behavior_registry()
11583 .contains(ServerBehavior::ToolsList),
11584 "a legacy-only catalog cannot produce a modern discovery claim"
11585 );
11586
11587 let cx = Cx::for_testing();
11588 let state = SessionState::new();
11589 let request_ctx = request_context(&cx, 78, Budget::INFINITE, &state);
11590 let legacy = router
11591 .handle_tools_call(
11592 &request_ctx,
11593 CallToolParams {
11594 name: "legacy-only-tool".to_owned(),
11595 arguments: Some(serde_json::json!({})),
11596 meta: None,
11597 },
11598 state,
11599 None,
11600 None,
11601 )
11602 .expect("exact 2024 dispatch reaches the explicit legacy handler");
11603 assert_eq!(
11604 serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
11605 "called legacy-only-tool"
11606 );
11607
11608 let modern_catalog = router
11609 .dispatch_stateless(
11610 &request_ctx,
11611 &final_tools_list_request(None, None, None, 79_i64),
11612 )
11613 .expect("modern tools/list remains a valid empty catalog");
11614 assert_eq!(modern_catalog["tools"], serde_json::json!([]));
11615 let modern_error = router
11616 .dispatch_stateless(
11617 &request_ctx,
11618 &final_tools_call_request("legacy-only-tool", serde_json::json!({}), 80_i64),
11619 )
11620 .expect_err("modern dispatch cannot resolve an explicit legacy-only tool");
11621 assert_eq!(modern_error.code, McpErrorCode::InvalidParams);
11622 assert_eq!(router.tools_count(), 1);
11623 }
11624
11625 #[test]
11626 fn explicit_legacy_resource_and_prompt_are_inert_on_final_routes() {
11627 let mut router = Router::new();
11628 router.add_legacy_resource(NamedResource::new("file:///legacy-only"));
11629 router.add_legacy_resource_template(marked_template("file:///{name}", "legacy-only"));
11630 router.add_legacy_prompt(NamedPrompt::new("legacy-only-prompt"));
11631 let cx = Cx::for_testing();
11632 let state = SessionState::new();
11633 let request_ctx = request_context(&cx, 181, Budget::INFINITE, &state);
11634
11635 let legacy_resource = router
11636 .handle_resources_read(
11637 &request_ctx,
11638 &ReadResourceParams {
11639 uri: "file:///legacy-only".to_owned(),
11640 meta: None,
11641 },
11642 state.clone(),
11643 None,
11644 None,
11645 )
11646 .expect("the exact legacy resource route retains its explicit registration");
11647 assert_eq!(legacy_resource.contents.len(), 1);
11648 let legacy_prompt = router
11649 .handle_prompts_get(
11650 &request_ctx,
11651 GetPromptParams {
11652 name: "legacy-only-prompt".to_owned(),
11653 arguments: None,
11654 meta: None,
11655 },
11656 state,
11657 None,
11658 None,
11659 )
11660 .expect("the exact legacy prompt route retains its explicit registration");
11661 assert!(legacy_prompt.messages.is_empty());
11662
11663 let modern_resources = router
11664 .dispatch_stateless(
11665 &request_ctx,
11666 &JsonRpcRequest::new(
11667 "resources/list",
11668 Some(serde_json::json!({
11669 "_meta": {
11670 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11671 "io.modelcontextprotocol/clientCapabilities": {},
11672 },
11673 })),
11674 182_i64,
11675 ),
11676 )
11677 .expect("final discovery remains valid with only legacy resources");
11678 assert_eq!(modern_resources["resources"], serde_json::json!([]));
11679 let modern_templates = router
11680 .dispatch_stateless(
11681 &request_ctx,
11682 &JsonRpcRequest::new(
11683 "resources/templates/list",
11684 Some(serde_json::json!({
11685 "_meta": {
11686 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11687 "io.modelcontextprotocol/clientCapabilities": {},
11688 },
11689 })),
11690 183_i64,
11691 ),
11692 )
11693 .expect("final template discovery remains valid with only legacy templates");
11694 assert_eq!(modern_templates["resourceTemplates"], serde_json::json!([]));
11695
11696 let modern_prompt = router
11697 .dispatch_stateless(
11698 &request_ctx,
11699 &JsonRpcRequest::new(
11700 "prompts/get",
11701 Some(serde_json::json!({
11702 "_meta": {
11703 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11704 "io.modelcontextprotocol/clientCapabilities": {},
11705 },
11706 "name": "legacy-only-prompt",
11707 })),
11708 184_i64,
11709 ),
11710 )
11711 .expect_err("changing only the dispatch era refuses the legacy-only prompt");
11712 assert_eq!(modern_prompt.code, McpErrorCode::InvalidParams);
11713 assert!(
11714 !router
11715 .server_discovery_behavior_registry()
11716 .contains(ServerBehavior::ResourcesList)
11717 );
11718 assert!(
11719 !router
11720 .server_discovery_behavior_registry()
11721 .contains(ServerBehavior::PromptsList)
11722 );
11723 }
11724
11725 #[test]
11728 fn duplicate_error_warn_and_ignore_preserve_handler_schema_and_order() {
11729 for behavior in [
11730 crate::DuplicateBehavior::Error,
11731 crate::DuplicateBehavior::Warn,
11732 crate::DuplicateBehavior::Ignore,
11733 ] {
11734 let original_legacy_calls = Arc::new(AtomicUsize::new(0));
11735 let original_final_calls = Arc::new(AtomicUsize::new(0));
11736 let candidate_legacy_calls = Arc::new(AtomicUsize::new(0));
11737 let candidate_final_calls = Arc::new(AtomicUsize::new(0));
11738 let mut router = Router::new();
11739 router
11740 .add_tool(NamedTool::new("before"))
11741 .expect("baseline tool admission succeeds");
11742 router
11743 .add_tool(DuplicateInvariantTool {
11744 label: "original",
11745 schema_property: "original_property",
11746 legacy_calls: Arc::clone(&original_legacy_calls),
11747 final_calls: Arc::clone(&original_final_calls),
11748 })
11749 .expect("original admission succeeds");
11750 router
11751 .add_tool(NamedTool::new("after"))
11752 .expect("trailing tool admission succeeds");
11753
11754 let cx = Cx::for_testing();
11755 let state = SessionState::new();
11756 let request_ctx = request_context(&cx, 81, Budget::INFINITE, &state);
11757 let legacy_before =
11758 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
11759 let modern_before = router
11760 .dispatch_stateless(
11761 &request_ctx,
11762 &final_tools_list_request(None, None, None, 81_i64),
11763 )
11764 .expect("modern catalog is available");
11765
11766 let registration = router.add_tool_with_behavior(
11767 DuplicateInvariantTool {
11768 label: "candidate",
11769 schema_property: "candidate_property",
11770 legacy_calls: Arc::clone(&candidate_legacy_calls),
11771 final_calls: Arc::clone(&candidate_final_calls),
11772 },
11773 behavior,
11774 );
11775 if behavior == crate::DuplicateBehavior::Error {
11776 let error = registration.expect_err("Error rejects the duplicate");
11777 assert!(error.message.contains("already exists"));
11778 } else {
11779 registration.expect("Warn and Ignore retain the original");
11780 }
11781
11782 assert_eq!(
11783 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
11784 legacy_before
11785 );
11786 assert_eq!(
11787 router
11788 .dispatch_stateless(
11789 &request_ctx,
11790 &final_tools_list_request(None, None, None, 82_i64),
11791 )
11792 .expect("modern catalog remains available"),
11793 modern_before
11794 );
11795 assert_eq!(
11796 router
11797 .tools()
11798 .into_iter()
11799 .map(|tool| tool.name)
11800 .collect::<Vec<_>>(),
11801 vec!["before", "duplicate-invariant-tool", "after"]
11802 );
11803
11804 let legacy = router
11805 .handle_tools_call(
11806 &request_ctx,
11807 CallToolParams {
11808 name: "duplicate-invariant-tool".to_owned(),
11809 arguments: Some(serde_json::json!({})),
11810 meta: None,
11811 },
11812 state.clone(),
11813 None,
11814 None,
11815 )
11816 .expect("legacy dispatch retains the original");
11817 assert_eq!(
11818 serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
11819 "original"
11820 );
11821 let modern = router
11822 .dispatch_stateless(
11823 &request_ctx,
11824 &final_tools_call_request(
11825 "duplicate-invariant-tool",
11826 serde_json::json!({}),
11827 83_i64,
11828 ),
11829 )
11830 .expect("modern dispatch retains the original");
11831 assert_eq!(modern["content"][0]["text"], "original");
11832 assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 1);
11833 assert_eq!(original_final_calls.load(Ordering::SeqCst), 1);
11834 assert_eq!(candidate_legacy_calls.load(Ordering::SeqCst), 0);
11835 assert_eq!(candidate_final_calls.load(Ordering::SeqCst), 0);
11836 }
11837 }
11838
11839 #[test]
11840 fn duplicate_replace_atomically_updates_handler_schema_and_retains_order() {
11841 let replacement_legacy_calls = Arc::new(AtomicUsize::new(0));
11842 let replacement_final_calls = Arc::new(AtomicUsize::new(0));
11843 let mut router = Router::new();
11844 router
11845 .add_tool(NamedTool::new("before"))
11846 .expect("baseline admission succeeds");
11847 router
11848 .add_tool(DuplicateInvariantTool {
11849 label: "original",
11850 schema_property: "original_property",
11851 legacy_calls: Arc::new(AtomicUsize::new(0)),
11852 final_calls: Arc::new(AtomicUsize::new(0)),
11853 })
11854 .expect("original admission succeeds");
11855 router
11856 .add_tool(NamedTool::new("after"))
11857 .expect("trailing admission succeeds");
11858 router
11859 .add_tool_with_behavior(
11860 DuplicateInvariantTool {
11861 label: "replacement",
11862 schema_property: "replacement_property",
11863 legacy_calls: Arc::clone(&replacement_legacy_calls),
11864 final_calls: Arc::clone(&replacement_final_calls),
11865 },
11866 crate::DuplicateBehavior::Replace,
11867 )
11868 .expect("Replace admits before committing the candidate");
11869
11870 assert_eq!(
11871 router
11872 .tools()
11873 .into_iter()
11874 .map(|tool| tool.name)
11875 .collect::<Vec<_>>(),
11876 vec!["before", "duplicate-invariant-tool", "after"]
11877 );
11878 let cx = Cx::for_testing();
11879 let state = SessionState::new();
11880 let request_ctx = request_context(&cx, 84, Budget::INFINITE, &state);
11881 let modern_catalog = router
11882 .dispatch_stateless(
11883 &request_ctx,
11884 &final_tools_list_request(None, None, None, 84_i64),
11885 )
11886 .expect("modern replacement catalog is available");
11887 assert_eq!(
11888 modern_catalog["tools"][1]["inputSchema"]["properties"],
11889 serde_json::json!({"replacement_property": {"type": "boolean"}})
11890 );
11891 assert_eq!(
11892 router.tools()[1].description.as_deref(),
11893 Some("replacement")
11894 );
11895
11896 let legacy = router
11897 .handle_tools_call(
11898 &request_ctx,
11899 CallToolParams {
11900 name: "duplicate-invariant-tool".to_owned(),
11901 arguments: Some(serde_json::json!({})),
11902 meta: None,
11903 },
11904 state,
11905 None,
11906 None,
11907 )
11908 .expect("legacy dispatch uses replacement");
11909 assert_eq!(
11910 serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
11911 "replacement"
11912 );
11913 let modern = router
11914 .dispatch_stateless(
11915 &request_ctx,
11916 &final_tools_call_request(
11917 "duplicate-invariant-tool",
11918 serde_json::json!({}),
11919 85_i64,
11920 ),
11921 )
11922 .expect("modern dispatch uses replacement");
11923 assert_eq!(modern["content"][0]["text"], "replacement");
11924 assert_eq!(replacement_legacy_calls.load(Ordering::SeqCst), 1);
11925 assert_eq!(replacement_final_calls.load(Ordering::SeqCst), 1);
11926 }
11927
11928 #[test]
11929 fn add_tool_behavior_new_tool_ok() {
11930 let mut r = Router::new();
11931 r.add_tool_with_behavior(NamedTool::new("t"), crate::DuplicateBehavior::Error)
11932 .unwrap();
11933 assert_eq!(r.tools_count(), 1);
11934 }
11935
11936 #[test]
11939 fn add_and_get_resource() {
11940 let mut r = Router::new();
11941 r.add_resource(NamedResource::new("file:///a.txt"));
11942 assert_eq!(r.resources_count(), 1);
11943 assert!(r.get_resource("file:///a.txt").is_some());
11944 assert!(r.get_resource("file:///b.txt").is_none());
11945 }
11946
11947 #[test]
11948 fn resources_returns_definitions_in_order() {
11949 let mut r = Router::new();
11950 r.add_resource(NamedResource::new("file:///b"));
11951 r.add_resource(NamedResource::new("file:///a"));
11952 let uris: Vec<_> = r.resources().iter().map(|res| res.uri.clone()).collect();
11953 assert_eq!(uris, vec!["file:///b", "file:///a"]);
11954 }
11955
11956 #[test]
11959 fn add_resource_behavior_error_on_duplicate() {
11960 let mut r = Router::new();
11961 r.add_resource(NamedResource::new("file:///a"));
11962 let err = r
11963 .add_resource_with_behavior(
11964 NamedResource::new("file:///a"),
11965 crate::DuplicateBehavior::Error,
11966 )
11967 .unwrap_err();
11968 assert!(err.message.contains("already exists"));
11969 }
11970
11971 #[test]
11972 fn add_resource_behavior_ignore() {
11973 let mut r = Router::new();
11974 r.add_resource(NamedResource::new("file:///a"));
11975 r.add_resource_with_behavior(
11976 NamedResource::new("file:///a"),
11977 crate::DuplicateBehavior::Ignore,
11978 )
11979 .unwrap();
11980 assert_eq!(r.resources_count(), 1);
11981 }
11982
11983 #[test]
11986 fn add_and_get_prompt() {
11987 let mut r = Router::new();
11988 r.add_prompt(NamedPrompt::new("greet"));
11989 assert_eq!(r.prompts_count(), 1);
11990 assert!(r.get_prompt("greet").is_some());
11991 assert!(r.get_prompt("other").is_none());
11992 }
11993
11994 #[test]
11995 fn prompts_returns_definitions_in_order() {
11996 let mut r = Router::new();
11997 r.add_prompt(NamedPrompt::new("z"));
11998 r.add_prompt(NamedPrompt::new("a"));
11999 let names: Vec<_> = r.prompts().iter().map(|p| p.name.clone()).collect();
12000 assert_eq!(names, vec!["z", "a"]);
12001 }
12002
12003 #[test]
12006 fn add_prompt_behavior_error_on_duplicate() {
12007 let mut r = Router::new();
12008 r.add_prompt(NamedPrompt::new("p"));
12009 let err = r
12010 .add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Error)
12011 .unwrap_err();
12012 assert!(err.message.contains("already exists"));
12013 }
12014
12015 #[test]
12016 fn add_prompt_behavior_warn_keeps_original() {
12017 let mut r = Router::new();
12018 r.add_prompt(NamedPrompt::new("p"));
12019 r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Warn)
12020 .unwrap();
12021 assert_eq!(r.prompts_count(), 1);
12022 }
12023
12024 #[test]
12025 fn duplicate_registration_errors_do_not_echo_peer_identifiers() {
12026 let canary = "raw-peer-identifier-canary";
12027
12028 let mut tools = Router::new();
12029 tools
12030 .add_tool(NamedTool::new(canary))
12031 .expect("tool registration succeeds");
12032 let tool_error = tools
12033 .add_tool_with_behavior(NamedTool::new(canary), crate::DuplicateBehavior::Error)
12034 .unwrap_err();
12035
12036 let canary_uri = format!("resource://{canary}");
12039 let mut resources = Router::new();
12040 resources.add_resource(NamedResource::new(&canary_uri));
12041 let resource_error = resources
12042 .add_resource_with_behavior(
12043 NamedResource::new(&canary_uri),
12044 crate::DuplicateBehavior::Error,
12045 )
12046 .unwrap_err();
12047
12048 let mut templates = Router::new();
12049 templates.add_resource_template(marked_template(&canary_uri, "original"));
12050 let template_error = templates
12051 .add_resource_template_with_behavior(
12052 marked_template(&canary_uri, "incoming"),
12053 crate::DuplicateBehavior::Error,
12054 )
12055 .unwrap_err();
12056
12057 let mut prompts = Router::new();
12058 prompts.add_prompt(NamedPrompt::new(canary));
12059 let prompt_error = prompts
12060 .add_prompt_with_behavior(NamedPrompt::new(canary), crate::DuplicateBehavior::Error)
12061 .unwrap_err();
12062
12063 for error in [tool_error, resource_error, template_error, prompt_error] {
12064 assert!(error.message.contains("already exists"));
12065 assert!(!error.message.contains(canary));
12066 }
12067 }
12068
12069 #[test]
12072 fn add_resource_template_and_list() {
12073 let mut r = Router::new();
12074 let tmpl = ResourceTemplate {
12075 uri_template: "db://{table}".to_string(),
12076 name: "db".to_string(),
12077 description: None,
12078 mime_type: None,
12079 icon: None,
12080 version: None,
12081 tags: vec![],
12082 };
12083 r.add_resource_template(tmpl);
12084 assert_eq!(r.resource_templates_count(), 1);
12085 assert!(r.get_resource_template("db://{table}").is_some());
12086 assert!(r.get_resource_template("db://{other}").is_none());
12087 }
12088
12089 #[test]
12090 fn add_resource_template_replaces_existing() {
12091 let mut r = Router::new();
12092 let tmpl1 = ResourceTemplate {
12093 uri_template: "db://{table}".to_string(),
12094 name: "db1".to_string(),
12095 description: None,
12096 mime_type: None,
12097 icon: None,
12098 version: None,
12099 tags: vec![],
12100 };
12101 let tmpl2 = ResourceTemplate {
12102 uri_template: "db://{table}".to_string(),
12103 name: "db2".to_string(),
12104 description: None,
12105 mime_type: None,
12106 icon: None,
12107 version: None,
12108 tags: vec![],
12109 };
12110 r.add_resource_template(tmpl1);
12111 r.add_resource_template(tmpl2);
12112 assert_eq!(r.resource_templates_count(), 1);
12113 let tmpl = r.get_resource_template("db://{table}").unwrap();
12114 assert_eq!(tmpl.name, "db2");
12115 }
12116
12117 #[test]
12118 fn add_resource_template_with_behavior_preserves_or_replaces_identity() {
12119 for behavior in [
12120 crate::DuplicateBehavior::Warn,
12121 crate::DuplicateBehavior::Ignore,
12122 crate::DuplicateBehavior::Error,
12123 ] {
12124 let mut router = Router::new();
12125 router.add_resource_template(marked_template("peer://{secret}", "original"));
12126 let result = router.add_resource_template_with_behavior(
12127 marked_template("peer://{secret}", "incoming"),
12128 behavior,
12129 );
12130
12131 if behavior == crate::DuplicateBehavior::Error {
12132 let error = result.expect_err("Error policy rejects the duplicate");
12133 assert!(error.message.contains("already exists"));
12134 assert!(!error.message.contains("peer://{secret}"));
12135 } else {
12136 result.expect("Warn and Ignore keep the original");
12137 }
12138 assert_eq!(router.resource_templates_count(), 1);
12139 assert_eq!(
12140 router
12141 .get_resource_template("peer://{secret}")
12142 .expect("original template remains")
12143 .name,
12144 "original"
12145 );
12146 }
12147
12148 let mut router = Router::new();
12149 router.add_resource_template(marked_template("peer://{secret}", "original"));
12150 router
12151 .add_resource_template_with_behavior(
12152 marked_template("peer://{secret}", "incoming"),
12153 crate::DuplicateBehavior::Replace,
12154 )
12155 .expect("Replace accepts the duplicate");
12156 assert_eq!(router.resource_templates_count(), 1);
12157 assert_eq!(
12158 router
12159 .get_resource_template("peer://{secret}")
12160 .expect("replacement template exists")
12161 .name,
12162 "incoming"
12163 );
12164 }
12165
12166 #[test]
12169 fn resource_exists_for_static_resource() {
12170 let mut r = Router::new();
12171 r.add_resource(NamedResource::new("file:///a.txt"));
12172 assert!(r.resource_exists("file:///a.txt"));
12173 assert!(!r.resource_exists("file:///b.txt"));
12174 }
12175
12176 #[test]
12179 fn strict_input_validation_default_off() {
12180 let r = Router::new();
12181 assert!(!r.strict_input_validation());
12182 }
12183
12184 #[test]
12185 fn set_strict_input_validation() {
12186 let mut r = Router::new();
12187 r.set_strict_input_validation(true);
12188 assert!(r.strict_input_validation());
12189 r.set_strict_input_validation(false);
12190 assert!(!r.strict_input_validation());
12191 }
12192
12193 #[test]
12196 fn set_list_page_size_zero_treated_as_none() {
12197 let mut r = Router::new();
12198 r.set_list_page_size(Some(0));
12199 assert!(r.list_page_size.is_none());
12201 }
12202
12203 #[test]
12204 fn set_list_page_size_positive() {
12205 let mut r = Router::new();
12206 r.set_list_page_size(Some(10));
12207 assert_eq!(r.list_page_size, Some(10));
12208 }
12209
12210 #[test]
12211 fn set_list_page_size_none() {
12212 let mut r = Router::new();
12213 r.set_list_page_size(Some(10));
12214 r.set_list_page_size(None);
12215 assert!(r.list_page_size.is_none());
12216 }
12217
12218 #[test]
12221 fn tools_filtered_no_filters_returns_all() {
12222 let mut r = Router::new();
12223 r.add_tool(NamedTool::new("a"))
12224 .expect("tool registration succeeds");
12225 r.add_tool(NamedTool::new("b"))
12226 .expect("tool registration succeeds");
12227 let tools = r.tools_filtered(None, None);
12228 assert_eq!(tools.len(), 2);
12229 }
12230
12231 #[test]
12232 fn tools_filtered_by_session_state_disables() {
12233 let mut r = Router::new();
12234 r.add_tool(NamedTool::new("a"))
12235 .expect("tool registration succeeds");
12236 r.add_tool(NamedTool::new("b"))
12237 .expect("tool registration succeeds");
12238 let state = SessionState::new();
12239 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
12240 state.set("fastmcp.disabled_tools", &disabled);
12241 let tools = r.tools_filtered(Some(&state), None);
12242 assert_eq!(tools.len(), 1);
12243 assert_eq!(tools[0].name, "b");
12244 }
12245
12246 #[test]
12247 fn tools_filtered_by_tags() {
12248 let mut r = Router::new();
12249 r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
12250 .expect("tool registration succeeds");
12251 r.add_tool(NamedTool::with_tags("b", vec!["web".to_string()]))
12252 .expect("tool registration succeeds");
12253 let include = vec!["db".to_string()];
12254 let filters = TagFilters::new(Some(&include), None);
12255 let tools = r.tools_filtered(None, Some(&filters));
12256 assert_eq!(tools.len(), 1);
12257 assert_eq!(tools[0].name, "a");
12258 }
12259
12260 #[test]
12263 fn resources_filtered_by_session_state() {
12264 let mut r = Router::new();
12265 r.add_resource(NamedResource::new("file:///a"));
12266 r.add_resource(NamedResource::new("file:///b"));
12267 let state = SessionState::new();
12268 let disabled: std::collections::HashSet<String> =
12269 ["file:///a".to_string()].into_iter().collect();
12270 state.set("fastmcp.disabled_resources", &disabled);
12271 let res = r.resources_filtered(Some(&state), None);
12272 assert_eq!(res.len(), 1);
12273 assert_eq!(res[0].uri, "file:///b");
12274 }
12275
12276 #[test]
12279 fn prompts_filtered_by_session_state() {
12280 let mut r = Router::new();
12281 r.add_prompt(NamedPrompt::new("a"));
12282 r.add_prompt(NamedPrompt::new("b"));
12283 let state = SessionState::new();
12284 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
12285 state.set("fastmcp.disabled_prompts", &disabled);
12286 let prompts = r.prompts_filtered(Some(&state), None);
12287 assert_eq!(prompts.len(), 1);
12288 assert_eq!(prompts[0].name, "b");
12289 }
12290
12291 #[test]
12292 fn prompts_filtered_by_tags() {
12293 let mut r = Router::new();
12294 r.add_prompt(NamedPrompt::with_tags("a", vec!["internal".to_string()]));
12295 r.add_prompt(NamedPrompt::with_tags("b", vec!["public".to_string()]));
12296 let exclude = vec!["internal".to_string()];
12297 let filters = TagFilters::new(None, Some(&exclude));
12298 let prompts = r.prompts_filtered(None, Some(&filters));
12299 assert_eq!(prompts.len(), 1);
12300 assert_eq!(prompts[0].name, "b");
12301 }
12302
12303 #[test]
12306 fn resource_templates_filtered_by_session_state() {
12307 let mut r = Router::new();
12308 r.add_resource_template(ResourceTemplate {
12309 uri_template: "db://{table}".to_string(),
12310 name: "db".to_string(),
12311 description: None,
12312 mime_type: None,
12313 icon: None,
12314 version: None,
12315 tags: vec!["admin".to_string()],
12316 });
12317 r.add_resource_template(ResourceTemplate {
12318 uri_template: "cache://{key}".to_string(),
12319 name: "cache".to_string(),
12320 description: None,
12321 mime_type: None,
12322 icon: None,
12323 version: None,
12324 tags: vec![],
12325 });
12326 let state = SessionState::new();
12327 let disabled: std::collections::HashSet<String> =
12328 ["db://{table}".to_string()].into_iter().collect();
12329 state.set("fastmcp.disabled_resources", &disabled);
12330 let tmpls = r.resource_templates_filtered(Some(&state), None);
12331 assert_eq!(tmpls.len(), 1);
12332 assert_eq!(tmpls[0].name, "cache");
12333 }
12334
12335 #[test]
12338 fn apply_prefix_with_prefix() {
12339 assert_eq!(Router::apply_prefix("tool", Some("ns")), "ns/tool");
12340 }
12341
12342 #[test]
12343 fn apply_prefix_no_prefix() {
12344 assert_eq!(Router::apply_prefix("tool", None), "tool");
12345 }
12346
12347 #[test]
12348 fn apply_prefix_empty_prefix() {
12349 assert_eq!(Router::apply_prefix("tool", Some("")), "tool");
12350 }
12351
12352 #[test]
12353 fn validate_prefix_valid() {
12354 assert!(Router::validate_prefix("my-prefix_1").is_ok());
12355 }
12356
12357 #[test]
12358 fn validate_prefix_empty_is_ok() {
12359 assert!(Router::validate_prefix("").is_ok());
12360 }
12361
12362 #[test]
12363 fn validate_prefix_rejects_slashes() {
12364 let err = Router::validate_prefix("a/b").unwrap_err();
12365 assert!(err.contains("slashes"));
12366 }
12367
12368 #[test]
12369 fn validate_prefix_rejects_special_chars() {
12370 let err = Router::validate_prefix("a@b").unwrap_err();
12371 assert!(err.contains("invalid character"));
12372 }
12373
12374 #[test]
12377 fn mount_result_default_has_no_components() {
12378 let r = MountResult::default();
12379 assert!(!r.has_components());
12380 assert!(r.is_success());
12381 }
12382
12383 #[test]
12384 fn mount_result_with_tools_has_components() {
12385 let mut r = MountResult::default();
12386 r.tools = 1;
12387 assert!(r.has_components());
12388 }
12389
12390 #[test]
12391 fn mount_result_debug() {
12392 let r = MountResult::default();
12393 let debug = format!("{:?}", r);
12394 assert!(debug.contains("MountResult"));
12395 }
12396
12397 #[test]
12400 fn mount_tools_with_prefix() {
12401 let mut main = Router::new();
12402 let mut sub = Router::new();
12403 sub.add_tool(NamedTool::new("query"))
12404 .expect("tool registration succeeds");
12405 let result = main.mount(sub, Some("db"));
12406 assert_eq!(result.tools, 1);
12407 assert!(main.get_tool("db/query").is_some());
12408 assert!(main.get_tool("query").is_none());
12409 }
12410
12411 #[test]
12412 fn mount_without_prefix() {
12413 let mut main = Router::new();
12414 let mut sub = Router::new();
12415 sub.add_tool(NamedTool::new("query"))
12416 .expect("tool registration succeeds");
12417 let result = main.mount(sub, None);
12418 assert_eq!(result.tools, 1);
12419 assert!(main.get_tool("query").is_some());
12420 }
12421
12422 #[test]
12423 fn mount_resources_with_prefix() {
12424 let mut main = Router::new();
12425 let mut sub = Router::new();
12426 sub.add_resource(NamedResource::new("file:///a"));
12427 let result = main.mount(sub, Some("ns"));
12428 assert_eq!(result.resources, 1);
12429 assert!(main.get_resource("ns/file:///a").is_some());
12430
12431 let cx = Cx::for_testing();
12432 let state = SessionState::new();
12433 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
12434 let read = main
12435 .handle_resources_read(
12436 &request_ctx,
12437 &ReadResourceParams {
12438 uri: "ns/file:///a".to_string(),
12439 meta: None,
12440 },
12441 state,
12442 None,
12443 None,
12444 )
12445 .expect("mounted resource is readable through its public URI");
12446 let [
12447 LegacyResourceContent::Text {
12448 uri,
12449 text,
12450 additional,
12451 ..
12452 },
12453 ] = read.contents.as_slice()
12454 else {
12455 panic!("mounted resource must retain the exact legacy text shape");
12456 };
12457 assert_eq!(uri, "ns/file:///a");
12458 assert_eq!(text, "content");
12459 assert!(additional.is_empty());
12460 }
12461
12462 #[test]
12463 fn mount_namespaced_prefixes_tools_and_keeps_resource_uris() {
12464 let mut main = Router::new();
12465 let mut sub = Router::new();
12466 sub.add_tool(NamedTool::new("query"));
12467 sub.add_resource(NamedResource::new("file:///a"));
12468 let result =
12469 main.mount_namespaced_with_behavior(sub, Some("ns"), crate::DuplicateBehavior::Replace);
12470 assert!(result.is_success());
12471 assert_eq!(result.tools, 1);
12472 assert_eq!(result.resources, 1);
12473 assert!(main.get_tool("ns/query").is_some());
12474 assert!(main.get_tool("query").is_none());
12475 assert!(main.get_resource("file:///a").is_some());
12476 assert!(main.get_resource("ns/file:///a").is_none());
12477 assert!(main.final_resources.contains_key("file:///a"));
12478 }
12479
12480 #[test]
12481 fn mounting_resource_does_not_requery_one_shot_definition() {
12482 let mut source = Router::new();
12483 source.add_resource(DefinitionPanicResource(std::sync::atomic::AtomicBool::new(
12484 false,
12485 )));
12486 let mut destination = Router::new();
12487
12488 let result = destination.mount_resources(source, Some("ns"));
12489
12490 assert!(result.is_success());
12491 assert_eq!(result.resources, 1);
12492 assert!(destination.get_resource("ns/panic://definition").is_some());
12493 }
12494
12495 #[test]
12496 fn mounting_resource_template_does_not_requery_one_shot_template() {
12497 let mut source = Router::new();
12498 source.add_resource(TemplatePanicResource(std::sync::atomic::AtomicBool::new(
12499 false,
12500 )));
12501 let mut destination = Router::new();
12502
12503 let result = destination.mount_resources(source, Some("ns"));
12504
12505 assert!(result.is_success());
12506 assert_eq!(result.resource_templates, 1);
12507 assert!(
12508 destination
12509 .get_resource_template("ns/panic-template://{id}")
12510 .is_some()
12511 );
12512 }
12513
12514 #[test]
12515 fn prefixed_resource_template_mount_is_legacy_only_after_final_source_dispatch() {
12516 struct FinalTemplateResource;
12517
12518 impl ResourceHandler for FinalTemplateResource {
12519 fn definition(&self) -> Resource {
12520 Resource {
12521 uri: "mcp://mounted/template".to_owned(),
12522 name: "mounted-template".to_owned(),
12523 description: None,
12524 mime_type: Some("text/plain".to_owned()),
12525 icon: None,
12526 version: None,
12527 tags: Vec::new(),
12528 }
12529 }
12530
12531 fn template(&self) -> Option<ResourceTemplate> {
12532 Some(marked_template("mcp://mounted/{id}", "mounted-template"))
12533 }
12534
12535 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12536 unreachable!("templated reads receive their URI parameters")
12537 }
12538
12539 fn read_with_uri(
12540 &self,
12541 _ctx: &McpContext,
12542 uri: &str,
12543 _params: &UriParams,
12544 ) -> McpResult<Vec<ResourceContent>> {
12545 Ok(vec![ResourceContent {
12546 uri: uri.to_owned(),
12547 mime_type: Some("text/plain".to_owned()),
12548 text: Some("source-template".to_owned()),
12549 blob: None,
12550 }])
12551 }
12552 }
12553
12554 let cx = Cx::for_testing();
12555 let state = SessionState::new();
12556 let request_ctx = request_context(&cx, 191, Budget::INFINITE, &state);
12557 let final_metadata = serde_json::json!({
12558 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
12559 "io.modelcontextprotocol/clientCapabilities": {},
12560 });
12561 let mut source = Router::new();
12562 source.add_resource(FinalTemplateResource);
12563
12564 let source_final_read = source
12565 .dispatch_stateless(
12566 &request_ctx,
12567 &JsonRpcRequest::new(
12568 "resources/read",
12569 Some(serde_json::json!({
12570 "_meta": final_metadata.clone(),
12571 "uri": "mcp://mounted/item",
12572 })),
12573 191_i64,
12574 ),
12575 )
12576 .expect("the unprefixed final template dispatches");
12577 assert_eq!(source_final_read["contents"][0]["text"], "source-template");
12578
12579 let mut destination = Router::new();
12580 let mounted = destination.mount_resources(source, Some("peer"));
12581 assert!(mounted.is_success());
12582 assert_eq!(mounted.resource_templates, 1);
12583
12584 let legacy_templates = destination
12585 .handle_resource_templates_list(
12586 &request_ctx,
12587 ListResourceTemplatesParams::default(),
12588 None,
12589 )
12590 .expect("the prefixed template remains in legacy discovery");
12591 assert_eq!(
12592 legacy_templates.resource_templates[0].uri_template,
12593 "peer/mcp://mounted/{id}"
12594 );
12595 let legacy_read = destination
12596 .handle_resources_read(
12597 &request_ctx,
12598 &ReadResourceParams {
12599 uri: "peer/mcp://mounted/item".to_owned(),
12600 meta: None,
12601 },
12602 state.clone(),
12603 None,
12604 None,
12605 )
12606 .expect("the prefixed template remains readable on the legacy surface");
12607 assert_eq!(
12608 serde_json::to_value(legacy_read).expect("legacy result serializes")["contents"][0]["text"],
12609 "source-template"
12610 );
12611
12612 let final_templates = destination
12613 .dispatch_stateless(
12614 &request_ctx,
12615 &JsonRpcRequest::new(
12616 "resources/templates/list",
12617 Some(serde_json::json!({"_meta": final_metadata.clone()})),
12618 192_i64,
12619 ),
12620 )
12621 .expect("prefixed legacy-only templates do not break final discovery");
12622 assert_eq!(final_templates["resourceTemplates"], serde_json::json!([]));
12623
12624 let final_error = destination
12625 .dispatch_stateless(
12626 &request_ctx,
12627 &JsonRpcRequest::new(
12628 "resources/read",
12629 Some(serde_json::json!({
12630 "_meta": final_metadata,
12631 "uri": "peer/mcp://mounted/item",
12632 })),
12633 193_i64,
12634 ),
12635 )
12636 .expect_err("the relative mounted namespace is not exposed to final dispatch");
12637 assert_eq!(final_error.code, McpErrorCode::InvalidParams);
12638 }
12639
12640 #[test]
12641 fn nested_resource_mounts_translate_both_namespace_layers() {
12642 let mut leaf = Router::new();
12643 leaf.add_resource(NamedResource::new("file:///a"));
12644
12645 let mut middle = Router::new();
12646 assert!(middle.mount(leaf, Some("ns")).is_success());
12647 let mut outer = Router::new();
12648 assert!(outer.mount(middle, Some("ns")).is_success());
12649
12650 let cx = Cx::for_testing();
12651 let state = SessionState::new();
12652 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
12653 let read = outer
12654 .handle_resources_read(
12655 &request_ctx,
12656 &ReadResourceParams {
12657 uri: "ns/ns/file:///a".to_string(),
12658 meta: None,
12659 },
12660 state,
12661 None,
12662 None,
12663 )
12664 .expect("nested mounted resource is readable");
12665 let [
12666 LegacyResourceContent::Text {
12667 uri,
12668 text,
12669 additional,
12670 ..
12671 },
12672 ] = read.contents.as_slice()
12673 else {
12674 panic!("nested mounted resource must retain the exact legacy text shape");
12675 };
12676 assert_eq!(uri, "ns/ns/file:///a");
12677 assert_eq!(text, "content");
12678 assert!(additional.is_empty());
12679 }
12680
12681 #[test]
12682 fn nested_resource_template_mounts_translate_every_namespace_layer() {
12683 struct TemplatedResource;
12684
12685 impl ResourceHandler for TemplatedResource {
12686 fn definition(&self) -> Resource {
12687 Resource {
12688 uri: "db://placeholder".to_string(),
12689 name: "database".to_string(),
12690 description: None,
12691 mime_type: Some("text/plain".to_string()),
12692 icon: None,
12693 version: None,
12694 tags: vec![],
12695 }
12696 }
12697
12698 fn template(&self) -> Option<ResourceTemplate> {
12699 Some(ResourceTemplate {
12700 uri_template: "db://{table}".to_string(),
12701 name: "database".to_string(),
12702 description: None,
12703 mime_type: Some("text/plain".to_string()),
12704 icon: None,
12705 version: None,
12706 tags: vec![],
12707 })
12708 }
12709
12710 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12711 unreachable!("templated reads use read_with_uri")
12712 }
12713
12714 fn read_with_uri(
12715 &self,
12716 _ctx: &McpContext,
12717 uri: &str,
12718 params: &UriParams,
12719 ) -> McpResult<Vec<ResourceContent>> {
12720 Ok(vec![ResourceContent {
12721 uri: uri.to_string(),
12722 mime_type: Some("text/plain".to_string()),
12723 text: params.get("table").cloned(),
12724 blob: None,
12725 }])
12726 }
12727 }
12728
12729 let mut source = Router::new();
12730 source.add_resource(TemplatedResource);
12731 let mut middle = Router::new();
12732 assert!(middle.mount_resources(source, Some("peer")).is_success());
12733 let mut mounted = Router::new();
12734 assert!(mounted.mount_resources(middle, Some("peer")).is_success());
12735
12736 let cx = Cx::for_testing();
12737 let state = SessionState::new();
12738 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
12739 let read = mounted
12740 .handle_resources_read(
12741 &request_ctx,
12742 &ReadResourceParams {
12743 uri: "peer/peer/db://users".to_string(),
12744 meta: None,
12745 },
12746 state,
12747 None,
12748 None,
12749 )
12750 .expect("mounted template is readable through its public URI");
12751 let [
12752 LegacyResourceContent::Text {
12753 uri,
12754 text,
12755 additional,
12756 ..
12757 },
12758 ] = read.contents.as_slice()
12759 else {
12760 panic!("mounted template must retain the exact legacy text shape");
12761 };
12762 assert_eq!(uri, "peer/peer/db://users");
12763 assert_eq!(text, "users");
12764 assert!(additional.is_empty());
12765 }
12766
12767 #[test]
12768 fn handle_resources_read_resolves_true_async_mounted_template_and_translates_uri() {
12769 struct AsyncTemplatedResource {
12770 observed: Arc<Mutex<Option<(String, String)>>>,
12771 }
12772
12773 impl ResourceHandler for AsyncTemplatedResource {
12774 fn definition(&self) -> Resource {
12775 Resource {
12776 uri: "async-db://placeholder".to_string(),
12777 name: "async-database".to_string(),
12778 description: None,
12779 mime_type: Some("text/plain".to_string()),
12780 icon: None,
12781 version: None,
12782 tags: vec![],
12783 }
12784 }
12785
12786 fn template(&self) -> Option<ResourceTemplate> {
12787 Some(ResourceTemplate {
12788 uri_template: "async-db://{table}".to_string(),
12789 name: "async-database".to_string(),
12790 description: None,
12791 mime_type: Some("text/plain".to_string()),
12792 icon: None,
12793 version: None,
12794 tags: vec![],
12795 })
12796 }
12797
12798 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12799 unreachable!("router must dispatch templated resources through the async override")
12800 }
12801
12802 fn read_with_uri(
12803 &self,
12804 _ctx: &McpContext,
12805 _uri: &str,
12806 _params: &UriParams,
12807 ) -> McpResult<Vec<ResourceContent>> {
12808 unreachable!("router must not fall back to the synchronous templated read")
12809 }
12810
12811 fn read_async_with_uri<'a>(
12812 &'a self,
12813 _ctx: &'a McpContext,
12814 uri: &'a str,
12815 params: &'a UriParams,
12816 ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
12817 Box::pin(async move {
12818 let mut first_poll = true;
12819 std::future::poll_fn(move |waker| {
12820 if std::mem::take(&mut first_poll) {
12821 waker.waker().wake_by_ref();
12822 std::task::Poll::Pending
12823 } else {
12824 std::task::Poll::Ready(())
12825 }
12826 })
12827 .await;
12828 let Some(table) = params.get("table").cloned() else {
12829 return Outcome::Err(McpError::invalid_params(
12830 "mounted template did not resolve its table parameter",
12831 ));
12832 };
12833 *self.observed.lock().expect("observation mutex poisoned") =
12834 Some((uri.to_string(), table.clone()));
12835 Outcome::Ok(vec![ResourceContent {
12836 uri: uri.to_string(),
12837 mime_type: Some("text/plain".to_string()),
12838 text: Some(format!("async table {table}")),
12839 blob: None,
12840 }])
12841 })
12842 }
12843 }
12844
12845 let observed = Arc::new(Mutex::new(None));
12846 let mut source = Router::new();
12847 source.add_resource(AsyncTemplatedResource {
12848 observed: Arc::clone(&observed),
12849 });
12850 let mut mounted = Router::new();
12851 let mount_result = mounted.mount_resources(source, Some("peer"));
12852 assert!(mount_result.is_success());
12853 assert_eq!(mount_result.resource_templates, 1);
12854
12855 let cx = Cx::for_testing();
12856 let state = SessionState::new();
12857 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
12858 let read = mounted
12859 .handle_resources_read(
12860 &request_ctx,
12861 &ReadResourceParams {
12862 uri: "peer/async-db://users".to_string(),
12863 meta: None,
12864 },
12865 state,
12866 None,
12867 None,
12868 )
12869 .expect("true-async mounted template is readable through its public URI");
12870
12871 assert_eq!(read.contents.len(), 1);
12872 let [
12873 LegacyResourceContent::Text {
12874 uri,
12875 text,
12876 additional,
12877 ..
12878 },
12879 ] = read.contents.as_slice()
12880 else {
12881 panic!("async mounted template must retain the exact legacy text shape");
12882 };
12883 assert_eq!(uri, "peer/async-db://users");
12884 assert_eq!(text, "async table users");
12885 assert!(additional.is_empty());
12886 assert_eq!(
12887 *observed.lock().expect("observation mutex poisoned"),
12888 Some(("async-db://users".to_string(), "users".to_string()))
12889 );
12890 }
12891
12892 #[test]
12893 fn mount_prompts_with_prefix() {
12894 let mut main = Router::new();
12895 let mut sub = Router::new();
12896 sub.add_prompt(NamedPrompt::new("greet"));
12897 let result = main.mount(sub, Some("ns"));
12898 assert_eq!(result.prompts, 1);
12899 assert!(main.get_prompt("ns/greet").is_some());
12900 }
12901
12902 #[test]
12903 fn mount_warns_on_conflict() {
12904 let mut main = Router::new();
12905 main.add_tool(NamedTool::new("t"))
12906 .expect("tool registration succeeds");
12907 let mut sub = Router::new();
12908 sub.add_tool(NamedTool::new("t"))
12909 .expect("tool registration succeeds");
12910 let result = main.mount(sub, None);
12911 assert_eq!(result.tools, 1);
12912 assert!(!result.warnings.is_empty());
12913 assert!(result.warnings[0].contains("already exists"));
12914 }
12915
12916 #[test]
12917 fn mount_rejects_invalid_prefix_without_mutating() {
12918 let mut main = Router::new();
12919 main.add_tool(NamedTool::new("original"))
12920 .expect("tool registration succeeds");
12921 let mut sub = Router::new();
12922 sub.add_tool(NamedTool::new("incoming"))
12923 .expect("tool registration succeeds");
12924 let result = main.mount(sub, Some("bad/prefix"));
12925 assert!(!result.is_success());
12926 assert_eq!(result.tools, 0);
12927 assert!(!result.warnings.is_empty());
12928 assert!(result.warnings[0].contains("slashes"));
12929 assert!(!result.errors[0].contains("bad/prefix"));
12930 assert_eq!(main.tools_count(), 1);
12931 assert!(main.get_tool("original").is_some());
12932 assert!(main.get_tool("bad/prefix/incoming").is_none());
12933 }
12934
12935 #[test]
12936 fn mount_with_behavior_honors_policy_for_every_component_kind() {
12937 for behavior in [
12938 crate::DuplicateBehavior::Warn,
12939 crate::DuplicateBehavior::Ignore,
12940 crate::DuplicateBehavior::Replace,
12941 crate::DuplicateBehavior::Error,
12942 ] {
12943 let mut main = marked_router("original");
12944 let mut sub = marked_router("incoming");
12945 if behavior == crate::DuplicateBehavior::Error {
12946 sub.add_tool(NamedTool::new("unique_tool"))
12947 .expect("tool registration succeeds");
12948 }
12949
12950 let result = main.mount_with_behavior(sub, None, behavior);
12951 let replaced = behavior == crate::DuplicateBehavior::Replace;
12952 let rejected = behavior == crate::DuplicateBehavior::Error;
12953 let expected_mounted = usize::from(replaced);
12954
12955 assert_eq!(result.is_success(), !rejected);
12956 assert_eq!(result.tools, expected_mounted);
12957 assert_eq!(result.resources, expected_mounted);
12958 assert_eq!(result.resource_templates, expected_mounted);
12959 assert_eq!(result.prompts, expected_mounted);
12960 assert_eq!(result.errors.len(), if rejected { 4 } else { 0 });
12961 assert_eq!(
12962 result.warnings.len(),
12963 if matches!(
12964 behavior,
12965 crate::DuplicateBehavior::Warn | crate::DuplicateBehavior::Replace
12966 ) {
12967 4
12968 } else {
12969 0
12970 }
12971 );
12972 assert_router_marker(&main, if replaced { "incoming" } else { "original" });
12973 assert!(main.get_tool("unique_tool").is_none());
12974
12975 for message in result.warnings.iter().chain(&result.errors) {
12976 assert!(!message.contains("duplicate_tool"));
12977 assert!(!message.contains("duplicate://resource"));
12978 assert!(!message.contains("duplicate://{item}"));
12979 assert!(!message.contains("duplicate_prompt"));
12980 }
12981 }
12982 }
12983
12984 #[test]
12985 fn behavior_aware_partial_mounts_preflight_error_atomically() {
12986 let mut tools = Router::new();
12987 tools
12988 .add_tool(NamedTool::with_tags("same", vec!["original".to_string()]))
12989 .expect("tool registration succeeds");
12990 let mut tool_source = Router::new();
12991 tool_source
12992 .add_tool(NamedTool::with_tags("same", vec!["incoming".to_string()]))
12993 .expect("tool registration succeeds");
12994 tool_source
12995 .add_tool(NamedTool::new("unique"))
12996 .expect("tool registration succeeds");
12997 let tool_result =
12998 tools.mount_tools_with_behavior(tool_source, None, crate::DuplicateBehavior::Error);
12999 assert!(!tool_result.is_success());
13000 assert_eq!(tool_result.tools, 0);
13001 assert_eq!(tools.tools_count(), 1);
13002 assert_eq!(
13003 tools.get_tool("same").unwrap().definition().tags,
13004 vec!["original".to_string()]
13005 );
13006 assert!(tools.get_tool("unique").is_none());
13007
13008 let mut resources = Router::new();
13009 resources.add_resource(NamedResource::with_tags(
13010 "same://resource",
13011 vec!["original".to_string()],
13012 ));
13013 let mut resource_source = Router::new();
13014 resource_source.add_resource(NamedResource::with_tags(
13015 "same://resource",
13016 vec!["incoming".to_string()],
13017 ));
13018 resource_source.add_resource_template(marked_template("unique://{item}", "incoming"));
13019 let resource_result = resources.mount_resources_with_behavior(
13020 resource_source,
13021 None,
13022 crate::DuplicateBehavior::Error,
13023 );
13024 assert!(!resource_result.is_success());
13025 assert_eq!(resource_result.resources, 0);
13026 assert_eq!(resource_result.resource_templates, 0);
13027 assert_eq!(resources.resources_count(), 1);
13028 assert_eq!(resources.resource_templates_count(), 0);
13029 assert_eq!(
13030 resources
13031 .get_resource("same://resource")
13032 .unwrap()
13033 .definition()
13034 .tags,
13035 vec!["original".to_string()]
13036 );
13037
13038 let mut prompts = Router::new();
13039 prompts.add_prompt(NamedPrompt::with_tags("same", vec!["original".to_string()]));
13040 let mut prompt_source = Router::new();
13041 prompt_source.add_prompt(NamedPrompt::with_tags("same", vec!["incoming".to_string()]));
13042 prompt_source.add_prompt(NamedPrompt::new("unique"));
13043 let prompt_result = prompts.mount_prompts_with_behavior(
13044 prompt_source,
13045 None,
13046 crate::DuplicateBehavior::Error,
13047 );
13048 assert!(!prompt_result.is_success());
13049 assert_eq!(prompt_result.prompts, 0);
13050 assert_eq!(prompts.prompts_count(), 1);
13051 assert_eq!(
13052 prompts.get_prompt("same").unwrap().definition().tags,
13053 vec!["original".to_string()]
13054 );
13055 assert!(prompts.get_prompt("unique").is_none());
13056 }
13057
13058 #[test]
13059 fn full_error_mount_is_atomic_across_component_kinds() {
13060 let mut main = Router::new();
13061 main.add_tool(NamedTool::with_tags(
13062 "conflict",
13063 vec!["original".to_string()],
13064 ))
13065 .expect("tool registration succeeds");
13066
13067 let mut sub = Router::new();
13068 sub.add_tool(NamedTool::with_tags(
13069 "conflict",
13070 vec!["incoming".to_string()],
13071 ))
13072 .expect("tool registration succeeds");
13073 sub.add_resource(NamedResource::new("unique://resource"));
13074 sub.add_resource_template(marked_template("unique://{item}", "incoming"));
13075 sub.add_prompt(NamedPrompt::new("unique_prompt"));
13076
13077 let result = main.mount_with_behavior(sub, None, crate::DuplicateBehavior::Error);
13078 assert!(!result.is_success());
13079 assert_eq!(result.errors.len(), 1);
13080 assert!(!result.has_components());
13081 assert_eq!(main.tools_count(), 1);
13082 assert_eq!(main.resources_count(), 0);
13083 assert_eq!(main.resource_templates_count(), 0);
13084 assert_eq!(main.prompts_count(), 0);
13085 assert_eq!(
13086 main.get_tool("conflict").unwrap().definition().tags,
13087 vec!["original".to_string()]
13088 );
13089 }
13090
13091 #[test]
13092 fn invalid_prefix_rejects_every_partial_mount_without_mutation() {
13093 let mut tools = Router::new();
13094 let mut tool_source = Router::new();
13095 tool_source
13096 .add_tool(NamedTool::new("tool"))
13097 .expect("tool registration succeeds");
13098 let tool_result = tools.mount_tools_with_behavior(
13099 tool_source,
13100 Some("peer/secret"),
13101 crate::DuplicateBehavior::Replace,
13102 );
13103 assert!(!tool_result.is_success());
13104 assert_eq!(tools.tools_count(), 0);
13105
13106 let mut resources = Router::new();
13107 let mut resource_source = Router::new();
13108 resource_source.add_resource(NamedResource::new("resource://value"));
13109 resource_source.add_resource_template(marked_template("template://{value}", "incoming"));
13110 let resource_result = resources.mount_resources_with_behavior(
13111 resource_source,
13112 Some("peer/secret"),
13113 crate::DuplicateBehavior::Replace,
13114 );
13115 assert!(!resource_result.is_success());
13116 assert_eq!(resources.resources_count(), 0);
13117 assert_eq!(resources.resource_templates_count(), 0);
13118
13119 let mut prompts = Router::new();
13120 let mut prompt_source = Router::new();
13121 prompt_source.add_prompt(NamedPrompt::new("prompt"));
13122 let prompt_result = prompts.mount_prompts_with_behavior(
13123 prompt_source,
13124 Some("peer/secret"),
13125 crate::DuplicateBehavior::Replace,
13126 );
13127 assert!(!prompt_result.is_success());
13128 assert_eq!(prompts.prompts_count(), 0);
13129
13130 for result in [tool_result, resource_result, prompt_result] {
13131 assert_eq!(result.errors.len(), 1);
13132 assert!(!result.errors[0].contains("peer/secret"));
13133 assert!(!result.has_components());
13134 }
13135 }
13136
13137 #[test]
13140 fn mount_tools_only() {
13141 let mut main = Router::new();
13142 let mut sub = Router::new();
13143 sub.add_tool(NamedTool::new("t1"))
13144 .expect("tool registration succeeds");
13145 sub.add_prompt(NamedPrompt::new("p1"));
13146 let result = main.mount_tools(sub, Some("ns"));
13147 assert_eq!(result.tools, 1);
13148 assert!(main.get_tool("ns/t1").is_some());
13149 assert_eq!(main.prompts_count(), 0); }
13151
13152 #[test]
13153 fn mount_prompts_only() {
13154 let mut main = Router::new();
13155 let mut sub = Router::new();
13156 sub.add_tool(NamedTool::new("t1"))
13157 .expect("tool registration succeeds");
13158 sub.add_prompt(NamedPrompt::new("p1"));
13159 let result = main.mount_prompts(sub, Some("ns"));
13160 assert_eq!(result.prompts, 1);
13161 assert!(main.get_prompt("ns/p1").is_some());
13162 assert_eq!(main.tools_count(), 0); }
13164
13165 #[test]
13168 fn handle_tools_list_no_pagination() {
13169 let mut r = Router::new();
13170 r.add_tool(NamedTool::new("a"))
13171 .expect("tool registration succeeds");
13172 r.add_tool(NamedTool::new("b"))
13173 .expect("tool registration succeeds");
13174 let cx = Cx::for_testing();
13175 let params = ListToolsParams {
13176 cursor: None,
13177 include_tags: None,
13178 exclude_tags: None,
13179 };
13180 let request_ctx = McpContext::new(cx, 1);
13181 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
13182 assert_eq!(result.tools.len(), 2);
13183 assert!(result.next_cursor.is_none());
13184 }
13185
13186 #[test]
13187 fn handle_tools_list_with_pagination() {
13188 let mut r = Router::new();
13189 r.set_list_page_size(Some(1));
13190 r.add_tool(NamedTool::new("a"))
13191 .expect("tool registration succeeds");
13192 r.add_tool(NamedTool::new("b"))
13193 .expect("tool registration succeeds");
13194 let cx = Cx::for_testing();
13195 let request_ctx = McpContext::new(cx, 1);
13196
13197 let params = ListToolsParams {
13199 cursor: None,
13200 include_tags: None,
13201 exclude_tags: None,
13202 };
13203 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
13204 assert_eq!(result.tools.len(), 1);
13205 assert_eq!(result.tools[0].name, "a");
13206 assert!(result.next_cursor.is_some());
13207
13208 let params = ListToolsParams {
13210 cursor: result.next_cursor,
13211 include_tags: None,
13212 exclude_tags: None,
13213 };
13214 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
13215 assert_eq!(result.tools.len(), 1);
13216 assert_eq!(result.tools[0].name, "b");
13217 assert!(result.next_cursor.is_none());
13218 }
13219
13220 #[test]
13221 fn handle_tools_list_with_tag_filter() {
13222 let mut r = Router::new();
13223 r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
13224 .expect("tool registration succeeds");
13225 r.add_tool(NamedTool::with_tags("b", vec!["web".to_string()]))
13226 .expect("tool registration succeeds");
13227 let cx = Cx::for_testing();
13228 let params = ListToolsParams {
13229 cursor: None,
13230 include_tags: Some(vec!["db".to_string()]),
13231 exclude_tags: None,
13232 };
13233 let request_ctx = McpContext::new(cx, 1);
13234 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
13235 assert_eq!(result.tools.len(), 1);
13236 assert_eq!(result.tools[0].name, "a");
13237 }
13238
13239 #[test]
13242 fn handle_resources_list_no_pagination() {
13243 let mut r = Router::new();
13244 r.add_resource(NamedResource::new("file:///a"));
13245 let cx = Cx::for_testing();
13246 let params = ListResourcesParams {
13247 cursor: None,
13248 include_tags: None,
13249 exclude_tags: None,
13250 };
13251 let request_ctx = McpContext::new(cx, 1);
13252 let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
13253 assert_eq!(result.resources.len(), 1);
13254 assert!(result.next_cursor.is_none());
13255 }
13256
13257 #[test]
13258 fn handle_resources_list_with_pagination() {
13259 let mut r = Router::new();
13260 r.set_list_page_size(Some(1));
13261 r.add_resource(NamedResource::new("file:///a"));
13262 r.add_resource(NamedResource::new("file:///b"));
13263 let cx = Cx::for_testing();
13264 let params = ListResourcesParams {
13265 cursor: None,
13266 include_tags: None,
13267 exclude_tags: None,
13268 };
13269 let request_ctx = McpContext::new(cx, 1);
13270 let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
13271 assert_eq!(result.resources.len(), 1);
13272 assert!(result.next_cursor.is_some());
13273 }
13274
13275 #[test]
13278 fn handle_prompts_list_no_pagination() {
13279 let mut r = Router::new();
13280 r.add_prompt(NamedPrompt::new("greet"));
13281 let cx = Cx::for_testing();
13282 let params = ListPromptsParams {
13283 cursor: None,
13284 include_tags: None,
13285 exclude_tags: None,
13286 };
13287 let request_ctx = McpContext::new(cx, 1);
13288 let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
13289 assert_eq!(result.prompts.len(), 1);
13290 assert!(result.next_cursor.is_none());
13291 }
13292
13293 #[test]
13296 fn handle_resource_templates_list_no_pagination() {
13297 let mut r = Router::new();
13298 r.add_resource_template(ResourceTemplate {
13299 uri_template: "db://{table}".to_string(),
13300 name: "db".to_string(),
13301 description: None,
13302 mime_type: None,
13303 icon: None,
13304 version: None,
13305 tags: vec![],
13306 });
13307 let cx = Cx::for_testing();
13308 let params = ListResourceTemplatesParams {
13309 cursor: None,
13310 include_tags: None,
13311 exclude_tags: None,
13312 };
13313 let request_ctx = McpContext::new(cx, 1);
13314 let result = r
13315 .handle_resource_templates_list(&request_ctx, params, None)
13316 .unwrap();
13317 assert_eq!(result.resource_templates.len(), 1);
13318 assert!(result.next_cursor.is_none());
13319 }
13320
13321 #[test]
13324 fn handle_initialize_returns_protocol_version() {
13325 let r = Router::new();
13326 let cx = Cx::for_testing();
13327 let mut session = Session::new(
13328 fastmcp_protocol::ServerInfo {
13329 name: "test".to_string(),
13330 version: "1.0".to_string(),
13331 },
13332 fastmcp_protocol::ServerCapabilities::default(),
13333 );
13334 let params = InitializeParams {
13335 protocol_version: PROTOCOL_VERSION.to_string(),
13336 capabilities: fastmcp_protocol::ClientCapabilities::default(),
13337 client_info: fastmcp_protocol::ClientInfo {
13338 name: "test-client".to_string(),
13339 version: "1.0".to_string(),
13340 },
13341 };
13342 let request_ctx = McpContext::new(cx, 1);
13343 let result = r
13344 .handle_initialize(
13345 &request_ctx,
13346 &mut session,
13347 params,
13348 Some("test instructions"),
13349 )
13350 .unwrap();
13351 assert_eq!(result.protocol_version, PROTOCOL_VERSION);
13352 assert_eq!(result.server_info.name, "test");
13353 assert_eq!(result.instructions.as_deref(), Some("test instructions"));
13354 }
13355
13356 #[test]
13357 fn handle_initialize_no_instructions() {
13358 let r = Router::new();
13359 let cx = Cx::for_testing();
13360 let mut session = Session::new(
13361 fastmcp_protocol::ServerInfo {
13362 name: "srv".to_string(),
13363 version: "0.1".to_string(),
13364 },
13365 fastmcp_protocol::ServerCapabilities::default(),
13366 );
13367 let params = InitializeParams {
13368 protocol_version: PROTOCOL_VERSION.to_string(),
13369 capabilities: fastmcp_protocol::ClientCapabilities::default(),
13370 client_info: fastmcp_protocol::ClientInfo {
13371 name: "c".to_string(),
13372 version: "0.1".to_string(),
13373 },
13374 };
13375 let request_ctx = McpContext::new(cx, 1);
13376 let result = r
13377 .handle_initialize(&request_ctx, &mut session, params, None)
13378 .unwrap();
13379 assert!(result.instructions.is_none());
13380 }
13381
13382 #[test]
13385 fn add_resource_behavior_warn_keeps_original() {
13386 let mut r = Router::new();
13387 r.add_resource(NamedResource::new("file:///a"));
13388 r.add_resource_with_behavior(
13389 NamedResource::new("file:///a"),
13390 crate::DuplicateBehavior::Warn,
13391 )
13392 .unwrap();
13393 assert_eq!(r.resources_count(), 1);
13394 }
13395
13396 #[test]
13397 fn add_resource_behavior_replace() {
13398 let mut r = Router::new();
13399 r.add_resource(NamedResource::new("file:///a"));
13400 r.add_resource_with_behavior(
13401 NamedResource::new("file:///a"),
13402 crate::DuplicateBehavior::Replace,
13403 )
13404 .unwrap();
13405 assert_eq!(r.resources_count(), 1);
13406 }
13407
13408 #[test]
13409 fn add_resource_behavior_new_resource_ok() {
13410 let mut r = Router::new();
13411 r.add_resource_with_behavior(
13412 NamedResource::new("file:///a"),
13413 crate::DuplicateBehavior::Error,
13414 )
13415 .unwrap();
13416 assert_eq!(r.resources_count(), 1);
13417 }
13418
13419 #[test]
13422 fn add_prompt_behavior_replace() {
13423 let mut r = Router::new();
13424 r.add_prompt(NamedPrompt::new("p"));
13425 r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Replace)
13426 .unwrap();
13427 assert_eq!(r.prompts_count(), 1);
13428 }
13429
13430 #[test]
13431 fn add_prompt_behavior_ignore() {
13432 let mut r = Router::new();
13433 r.add_prompt(NamedPrompt::new("p"));
13434 r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Ignore)
13435 .unwrap();
13436 assert_eq!(r.prompts_count(), 1);
13437 }
13438
13439 #[test]
13440 fn add_prompt_behavior_new_prompt_ok() {
13441 let mut r = Router::new();
13442 r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Error)
13443 .unwrap();
13444 assert_eq!(r.prompts_count(), 1);
13445 }
13446
13447 #[test]
13450 fn add_resource_replaces_on_duplicate() {
13451 let mut r = Router::new();
13452 r.add_resource(NamedResource::new("file:///a"));
13453 r.add_resource(NamedResource::new("file:///a"));
13454 assert_eq!(r.resources_count(), 1);
13455 assert_eq!(r.resources().len(), 1);
13456 }
13457
13458 #[test]
13459 fn add_prompt_replaces_on_duplicate() {
13460 let mut r = Router::new();
13461 r.add_prompt(NamedPrompt::new("p"));
13462 r.add_prompt(NamedPrompt::new("p"));
13463 assert_eq!(r.prompts_count(), 1);
13464 assert_eq!(r.prompts().len(), 1);
13465 }
13466
13467 #[test]
13470 fn resource_exists_for_template_match() {
13471 struct DbResource;
13472 impl ResourceHandler for DbResource {
13473 fn definition(&self) -> Resource {
13474 Resource {
13475 uri: "db://placeholder".to_string(),
13476 name: "db".to_string(),
13477 description: None,
13478 mime_type: Some("text/plain".to_string()),
13479 icon: None,
13480 version: None,
13481 tags: vec![],
13482 }
13483 }
13484 fn template(&self) -> Option<ResourceTemplate> {
13485 Some(ResourceTemplate {
13486 uri_template: "db://{table}".to_string(),
13487 name: "db".to_string(),
13488 description: None,
13489 mime_type: None,
13490 icon: None,
13491 version: None,
13492 tags: vec![],
13493 })
13494 }
13495 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<fastmcp_protocol::ResourceContent>> {
13496 Ok(vec![])
13497 }
13498 }
13499 let mut r = Router::new();
13500 r.add_resource(DbResource);
13501 assert!(r.resource_exists("db://users"));
13502 assert!(!r.resource_exists("file://other"));
13503 }
13504
13505 #[test]
13508 fn resources_filtered_by_tags() {
13509 let mut r = Router::new();
13510 r.add_resource(NamedResource::with_tags(
13511 "file:///a",
13512 vec!["internal".to_string()],
13513 ));
13514 r.add_resource(NamedResource::with_tags(
13515 "file:///b",
13516 vec!["public".to_string()],
13517 ));
13518 let include = vec!["public".to_string()];
13519 let filters = TagFilters::new(Some(&include), None);
13520 let res = r.resources_filtered(None, Some(&filters));
13521 assert_eq!(res.len(), 1);
13522 assert_eq!(res[0].uri, "file:///b");
13523 }
13524
13525 #[test]
13528 fn resource_templates_filtered_by_tags() {
13529 let mut r = Router::new();
13530 r.add_resource_template(ResourceTemplate {
13531 uri_template: "db://{table}".to_string(),
13532 name: "db".to_string(),
13533 description: None,
13534 mime_type: None,
13535 icon: None,
13536 version: None,
13537 tags: vec!["admin".to_string()],
13538 });
13539 r.add_resource_template(ResourceTemplate {
13540 uri_template: "cache://{key}".to_string(),
13541 name: "cache".to_string(),
13542 description: None,
13543 mime_type: None,
13544 icon: None,
13545 version: None,
13546 tags: vec!["public".to_string()],
13547 });
13548 let exclude = vec!["admin".to_string()];
13549 let filters = TagFilters::new(None, Some(&exclude));
13550 let tmpls = r.resource_templates_filtered(None, Some(&filters));
13551 assert_eq!(tmpls.len(), 1);
13552 assert_eq!(tmpls[0].name, "cache");
13553 }
13554
13555 #[test]
13558 fn handle_tools_list_with_session_state_filter() {
13559 let mut r = Router::new();
13560 r.add_tool(NamedTool::new("a"))
13561 .expect("tool registration succeeds");
13562 r.add_tool(NamedTool::new("b"))
13563 .expect("tool registration succeeds");
13564 let cx = Cx::for_testing();
13565 let state = SessionState::new();
13566 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
13567 state.set("fastmcp.disabled_tools", &disabled);
13568 let params = ListToolsParams {
13569 cursor: None,
13570 include_tags: None,
13571 exclude_tags: None,
13572 };
13573 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
13574 let result = r
13575 .handle_tools_list(&request_ctx, params, Some(&state))
13576 .unwrap();
13577 assert_eq!(result.tools.len(), 1);
13578 assert_eq!(result.tools[0].name, "b");
13579 }
13580
13581 #[test]
13584 fn handle_resources_list_with_tag_filter() {
13585 let mut r = Router::new();
13586 r.add_resource(NamedResource::with_tags(
13587 "file:///a",
13588 vec!["db".to_string()],
13589 ));
13590 r.add_resource(NamedResource::with_tags(
13591 "file:///b",
13592 vec!["web".to_string()],
13593 ));
13594 let cx = Cx::for_testing();
13595 let params = ListResourcesParams {
13596 cursor: None,
13597 include_tags: Some(vec!["web".to_string()]),
13598 exclude_tags: None,
13599 };
13600 let request_ctx = McpContext::new(cx, 1);
13601 let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
13602 assert_eq!(result.resources.len(), 1);
13603 assert_eq!(result.resources[0].uri, "file:///b");
13604 }
13605
13606 #[test]
13609 fn handle_prompts_list_with_pagination() {
13610 let mut r = Router::new();
13611 r.set_list_page_size(Some(1));
13612 r.add_prompt(NamedPrompt::new("a"));
13613 r.add_prompt(NamedPrompt::new("b"));
13614 let cx = Cx::for_testing();
13615 let params = ListPromptsParams {
13616 cursor: None,
13617 include_tags: None,
13618 exclude_tags: None,
13619 };
13620 let request_ctx = McpContext::new(cx, 1);
13621 let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
13622 assert_eq!(result.prompts.len(), 1);
13623 assert_eq!(result.prompts[0].name, "a");
13624 assert!(result.next_cursor.is_some());
13625
13626 let params = ListPromptsParams {
13627 cursor: result.next_cursor,
13628 include_tags: None,
13629 exclude_tags: None,
13630 };
13631 let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
13632 assert_eq!(result.prompts.len(), 1);
13633 assert_eq!(result.prompts[0].name, "b");
13634 assert!(result.next_cursor.is_none());
13635 }
13636
13637 #[test]
13640 fn handle_prompts_list_with_tag_filter() {
13641 let mut r = Router::new();
13642 r.add_prompt(NamedPrompt::with_tags("a", vec!["internal".to_string()]));
13643 r.add_prompt(NamedPrompt::with_tags("b", vec!["public".to_string()]));
13644 let cx = Cx::for_testing();
13645 let params = ListPromptsParams {
13646 cursor: None,
13647 include_tags: None,
13648 exclude_tags: Some(vec!["internal".to_string()]),
13649 };
13650 let request_ctx = McpContext::new(cx, 1);
13651 let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
13652 assert_eq!(result.prompts.len(), 1);
13653 assert_eq!(result.prompts[0].name, "b");
13654 }
13655
13656 #[test]
13659 fn handle_resource_templates_list_with_pagination() {
13660 let mut r = Router::new();
13661 r.set_list_page_size(Some(1));
13662 r.add_resource_template(ResourceTemplate {
13663 uri_template: "db://{table}".to_string(),
13664 name: "db".to_string(),
13665 description: None,
13666 mime_type: None,
13667 icon: None,
13668 version: None,
13669 tags: vec![],
13670 });
13671 r.add_resource_template(ResourceTemplate {
13672 uri_template: "cache://{key}".to_string(),
13673 name: "cache".to_string(),
13674 description: None,
13675 mime_type: None,
13676 icon: None,
13677 version: None,
13678 tags: vec![],
13679 });
13680 let cx = Cx::for_testing();
13681 let request_ctx = McpContext::new(cx, 1);
13682 let params = ListResourceTemplatesParams {
13683 cursor: None,
13684 include_tags: None,
13685 exclude_tags: None,
13686 };
13687 let result = r
13688 .handle_resource_templates_list(&request_ctx, params, None)
13689 .unwrap();
13690 assert_eq!(result.resource_templates.len(), 1);
13691 assert!(result.next_cursor.is_some());
13692
13693 let params = ListResourceTemplatesParams {
13694 cursor: result.next_cursor,
13695 include_tags: None,
13696 exclude_tags: None,
13697 };
13698 let result = r
13699 .handle_resource_templates_list(&request_ctx, params, None)
13700 .unwrap();
13701 assert_eq!(result.resource_templates.len(), 1);
13702 assert!(result.next_cursor.is_none());
13703 }
13704
13705 #[test]
13708 fn handle_resource_templates_list_with_tag_filter() {
13709 let mut r = Router::new();
13710 r.add_resource_template(ResourceTemplate {
13711 uri_template: "db://{table}".to_string(),
13712 name: "db".to_string(),
13713 description: None,
13714 mime_type: None,
13715 icon: None,
13716 version: None,
13717 tags: vec!["admin".to_string()],
13718 });
13719 r.add_resource_template(ResourceTemplate {
13720 uri_template: "cache://{key}".to_string(),
13721 name: "cache".to_string(),
13722 description: None,
13723 mime_type: None,
13724 icon: None,
13725 version: None,
13726 tags: vec!["public".to_string()],
13727 });
13728 let cx = Cx::for_testing();
13729 let params = ListResourceTemplatesParams {
13730 cursor: None,
13731 include_tags: Some(vec!["public".to_string()]),
13732 exclude_tags: None,
13733 };
13734 let request_ctx = McpContext::new(cx, 1);
13735 let result = r
13736 .handle_resource_templates_list(&request_ctx, params, None)
13737 .unwrap();
13738 assert_eq!(result.resource_templates.len(), 1);
13739 assert_eq!(result.resource_templates[0].name, "cache");
13740 }
13741
13742 #[test]
13745 fn mount_resources_only() {
13746 let mut main = Router::new();
13747 let mut sub = Router::new();
13748 sub.add_resource(NamedResource::new("file:///a"));
13749 sub.add_tool(NamedTool::new("t1"))
13750 .expect("tool registration succeeds");
13751 sub.add_resource_template(ResourceTemplate {
13752 uri_template: "db://{t}".to_string(),
13753 name: "db".to_string(),
13754 description: None,
13755 mime_type: None,
13756 icon: None,
13757 version: None,
13758 tags: vec![],
13759 });
13760 let result = main.mount_resources(sub, Some("ns"));
13761 assert_eq!(result.resources, 1);
13762 assert_eq!(result.resource_templates, 1);
13763 assert!(main.get_resource("ns/file:///a").is_some());
13764 assert_eq!(main.tools_count(), 0); }
13766
13767 #[test]
13770 fn mount_result_with_resources_has_components() {
13771 let mut r = MountResult::default();
13772 r.resources = 1;
13773 assert!(r.has_components());
13774 }
13775
13776 #[test]
13777 fn mount_result_with_templates_has_components() {
13778 let mut r = MountResult::default();
13779 r.resource_templates = 1;
13780 assert!(r.has_components());
13781 }
13782
13783 #[test]
13784 fn mount_result_with_prompts_has_components() {
13785 let mut r = MountResult::default();
13786 r.prompts = 1;
13787 assert!(r.has_components());
13788 }
13789
13790 #[test]
13791 fn mount_result_is_success_with_warnings() {
13792 let mut r = MountResult::default();
13793 r.warnings.push("something".to_string());
13794 assert!(r.is_success());
13795 }
13796
13797 #[test]
13798 fn mount_result_reports_errors_as_failure() {
13799 let mut result = MountResult::default();
13800 result.errors.push("mount rejected".to_string());
13801 assert!(!result.is_success());
13802 assert!(!result.has_components());
13803 }
13804
13805 #[test]
13808 fn mount_all_component_types() {
13809 let mut main = Router::new();
13810 let mut sub = Router::new();
13811 sub.add_tool(NamedTool::new("t1"))
13812 .expect("tool registration succeeds");
13813 sub.add_resource(NamedResource::new("file:///r1"));
13814 sub.add_prompt(NamedPrompt::new("p1"));
13815 sub.add_resource_template(ResourceTemplate {
13816 uri_template: "db://{table}".to_string(),
13817 name: "db".to_string(),
13818 description: None,
13819 mime_type: None,
13820 icon: None,
13821 version: None,
13822 tags: vec![],
13823 });
13824 let result = main.mount(sub, Some("ns"));
13825 assert_eq!(result.tools, 1);
13826 assert_eq!(result.resources, 1);
13827 assert_eq!(result.prompts, 1);
13828 assert_eq!(result.resource_templates, 1);
13829 assert!(result.has_components());
13830 assert!(main.get_tool("ns/t1").is_some());
13831 assert!(main.get_resource("ns/file:///r1").is_some());
13832 assert!(main.get_prompt("ns/p1").is_some());
13833 }
13834
13835 #[test]
13838 fn mount_warns_on_resource_conflict() {
13839 let mut main = Router::new();
13840 main.add_resource(NamedResource::new("file:///a"));
13841 let mut sub = Router::new();
13842 sub.add_resource(NamedResource::new("file:///a"));
13843 let result = main.mount(sub, None);
13844 assert!(!result.warnings.is_empty());
13845 assert!(result.warnings[0].contains("Resource"));
13846 }
13847
13848 #[test]
13849 fn mount_warns_on_prompt_conflict() {
13850 let mut main = Router::new();
13851 main.add_prompt(NamedPrompt::new("p"));
13852 let mut sub = Router::new();
13853 sub.add_prompt(NamedPrompt::new("p"));
13854 let result = main.mount(sub, None);
13855 assert!(!result.warnings.is_empty());
13856 assert!(result.warnings[0].contains("Prompt"));
13857 }
13858
13859 #[test]
13862 fn tag_filters_clone() {
13863 let include = vec!["a".to_string()];
13864 let f = TagFilters::new(Some(&include), None);
13865 let cloned = f.clone();
13866 assert!(cloned.matches(&["a".to_string()]));
13867 assert!(!cloned.matches(&["b".to_string()]));
13868 }
13869
13870 #[test]
13873 fn handle_tools_list_pagination_with_tags() {
13874 let mut r = Router::new();
13875 r.set_list_page_size(Some(1));
13876 r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
13877 .expect("tool registration succeeds");
13878 r.add_tool(NamedTool::with_tags("b", vec!["db".to_string()]))
13879 .expect("tool registration succeeds");
13880 r.add_tool(NamedTool::with_tags("c", vec!["web".to_string()]))
13881 .expect("tool registration succeeds");
13882 let cx = Cx::for_testing();
13883 let request_ctx = McpContext::new(cx, 1);
13884
13885 let params = ListToolsParams {
13887 cursor: None,
13888 include_tags: Some(vec!["db".to_string()]),
13889 exclude_tags: None,
13890 };
13891 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
13892 assert_eq!(result.tools.len(), 1);
13893 assert_eq!(result.tools[0].name, "a");
13894 assert!(result.next_cursor.is_some());
13895
13896 let params = ListToolsParams {
13898 cursor: result.next_cursor,
13899 include_tags: Some(vec!["db".to_string()]),
13900 exclude_tags: None,
13901 };
13902 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
13903 assert_eq!(result.tools.len(), 1);
13904 assert_eq!(result.tools[0].name, "b");
13905 assert!(result.next_cursor.is_none());
13906 }
13907
13908 #[test]
13909 fn final_mounted_tools_preserve_admitted_order_before_cursor_pagination() {
13910 let mut router = mounted_tool_router();
13911 let cx = Cx::for_testing();
13912 let state = SessionState::new();
13913 let request_ctx = request_context(&cx, 164, Budget::INFINITE, &state);
13914
13915 let legacy = router
13916 .handle_tools_list(
13917 &request_ctx,
13918 ListToolsParams {
13919 cursor: None,
13920 include_tags: Some(vec!["visible".to_owned()]),
13921 exclude_tags: Some(vec!["excluded".to_owned()]),
13922 },
13923 None,
13924 )
13925 .expect("legacy filtering retains every registered tool");
13926 assert_eq!(
13927 legacy
13928 .tools
13929 .iter()
13930 .map(|tool| tool.name.as_str())
13931 .collect::<Vec<_>>(),
13932 vec!["peer/first", "peer/second"],
13933 "the legacy catalog retains mounted tools in source order"
13934 );
13935
13936 router.set_list_page_size(Some(1));
13937 let first_page = router
13938 .dispatch_stateless(
13939 &request_ctx,
13940 &final_tools_list_request(
13941 None,
13942 Some(vec!["visible"]),
13943 Some(vec!["excluded"]),
13944 164_i64,
13945 ),
13946 )
13947 .expect("the first final page contains the first admitted entry");
13948 assert_eq!(first_page["tools"][0]["name"], "peer/first");
13949 assert_eq!(first_page["tools"].as_array().map(Vec::len), Some(1));
13950 let cursor = first_page["nextCursor"]
13951 .as_str()
13952 .expect("the first admitted page has a continuation cursor");
13953 let include_tags = vec!["visible".to_owned()];
13954 let exclude_tags = vec!["excluded".to_owned()];
13955 let query = FinalCatalogQuery::from_tag_filters(Some(&include_tags), Some(&exclude_tags));
13956 assert_eq!(
13957 decode_final_catalog_cursor_offset(
13958 Some(cursor),
13959 FinalCatalogKind::Tools,
13960 router.final_catalog_revision,
13961 &query,
13962 2,
13963 )
13964 .expect("cursor is router-generated for this exact final catalog revision"),
13965 1,
13966 "the cursor advances across admitted entries"
13967 );
13968
13969 let query_mismatch = router
13970 .dispatch_stateless(
13971 &request_ctx,
13972 &final_tools_list_request(
13973 Some(cursor),
13974 Some(vec!["other"]),
13975 Some(vec!["excluded"]),
13976 165_i64,
13977 ),
13978 )
13979 .expect_err("changing only the final list filter rejects the continuation");
13980 assert_eq!(query_mismatch.code, McpErrorCode::InvalidParams);
13981 assert!(query_mismatch.message.contains("query filters"));
13982
13983 let second_page = router
13984 .dispatch_stateless(
13985 &request_ctx,
13986 &final_tools_list_request(
13987 Some(cursor),
13988 Some(vec!["visible"]),
13989 Some(vec!["excluded"]),
13990 166_i64,
13991 ),
13992 )
13993 .expect("the continuation page keeps admitted source order");
13994 assert_eq!(second_page["tools"][0]["name"], "peer/second");
13995 assert_eq!(second_page["tools"].as_array().map(Vec::len), Some(1));
13996 assert!(
13997 second_page.get("nextCursor").is_none(),
13998 "the second admitted entry terminates the filtered final sequence"
13999 );
14000 }
14001
14002 #[test]
14003 fn final_default_list_validates_every_supplied_catalog_cursor() {
14004 let mut router = Router::new();
14005 router
14006 .add_tool(NamedTool::new("default-cursor-first"))
14007 .expect("first final tool registers");
14008 router
14009 .add_tool(NamedTool::new("default-cursor-second"))
14010 .expect("second final tool registers");
14011 assert!(
14012 router.list_page_size.is_none(),
14013 "the default final list path has pagination disabled"
14014 );
14015
14016 let cx = Cx::for_testing();
14017 let state = SessionState::new();
14018 let request_ctx = request_context(&cx, 167, Budget::INFINITE, &state);
14019 let full = router
14020 .dispatch_stateless(
14021 &request_ctx,
14022 &final_tools_list_request(None, None, None, 167_i64),
14023 )
14024 .expect("a cursor-free default final list returns the full catalog");
14025 assert_eq!(full["tools"].as_array().map(Vec::len), Some(2));
14026 assert!(full.get("nextCursor").is_none());
14027
14028 let query = FinalCatalogQuery::from_tag_filters(None, None);
14029 let valid_cursor = encode_final_catalog_cursor(
14030 FinalCatalogKind::Tools,
14031 router.final_catalog_revision,
14032 &query,
14033 0,
14034 );
14035 let validated_full = router
14036 .dispatch_stateless(
14037 &request_ctx,
14038 &final_tools_list_request(Some(&valid_cursor), None, None, 168_i64),
14039 )
14040 .expect(
14041 "a valid cursor remains admitted while default listing returns the full catalog",
14042 );
14043 assert_eq!(validated_full["tools"].as_array().map(Vec::len), Some(2));
14044 assert!(validated_full.get("nextCursor").is_none());
14045
14046 let stale_revision = router
14047 .final_catalog_revision
14048 .checked_sub(1)
14049 .expect("registered tools advance the final catalog revision");
14050 let stale_cursor =
14051 encode_final_catalog_cursor(FinalCatalogKind::Tools, stale_revision, &query, 0);
14052 let stale = router
14053 .dispatch_stateless(
14054 &request_ctx,
14055 &final_tools_list_request(Some(&stale_cursor), None, None, 169_i64),
14056 )
14057 .expect_err("changing only the revision rejects a default-list cursor");
14058 assert_eq!(stale.code, McpErrorCode::InvalidParams);
14059 assert!(stale.message.contains("stale catalog revision"));
14060
14061 let wrong_kind_cursor = encode_final_catalog_cursor(
14062 FinalCatalogKind::Prompts,
14063 router.final_catalog_revision,
14064 &query,
14065 0,
14066 );
14067 let wrong_kind = router
14068 .dispatch_stateless(
14069 &request_ctx,
14070 &final_tools_list_request(Some(&wrong_kind_cursor), None, None, 170_i64),
14071 )
14072 .expect_err("changing only the kind rejects a default-list cursor");
14073 assert_eq!(wrong_kind.code, McpErrorCode::InvalidParams);
14074 assert!(wrong_kind.message.contains("another list method"));
14075
14076 let other_tags = vec!["other".to_owned()];
14077 let other_query = FinalCatalogQuery::from_tag_filters(Some(&other_tags), None);
14078 let wrong_query_cursor = encode_final_catalog_cursor(
14079 FinalCatalogKind::Tools,
14080 router.final_catalog_revision,
14081 &other_query,
14082 0,
14083 );
14084 let wrong_query = router
14085 .dispatch_stateless(
14086 &request_ctx,
14087 &final_tools_list_request(Some(&wrong_query_cursor), None, None, 171_i64),
14088 )
14089 .expect_err("changing only the filters rejects a default-list cursor");
14090 assert_eq!(wrong_query.code, McpErrorCode::InvalidParams);
14091 assert!(wrong_query.message.contains("query filters"));
14092
14093 let out_of_range_cursor = encode_final_catalog_cursor(
14094 FinalCatalogKind::Tools,
14095 router.final_catalog_revision,
14096 &query,
14097 2,
14098 );
14099 let out_of_range = router
14100 .dispatch_stateless(
14101 &request_ctx,
14102 &final_tools_list_request(Some(&out_of_range_cursor), None, None, 172_i64),
14103 )
14104 .expect_err("changing only the offset rejects a default-list cursor");
14105 assert_eq!(out_of_range.code, McpErrorCode::InvalidParams);
14106 assert!(
14107 out_of_range
14108 .message
14109 .contains("outside the requested catalog page")
14110 );
14111 }
14112
14113 #[test]
14114 fn final_resource_and_prompt_cursors_reject_stale_catalog_revisions() {
14115 fn final_list_request(method: &str, cursor: Option<&str>, id: i64) -> JsonRpcRequest {
14116 let mut params = serde_json::json!({
14117 "_meta": {
14118 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
14119 "io.modelcontextprotocol/clientCapabilities": {},
14120 },
14121 });
14122 if let Some(cursor) = cursor {
14123 params["cursor"] = serde_json::json!(cursor);
14124 }
14125 JsonRpcRequest::new(method, Some(params), id)
14126 }
14127
14128 let mut router = Router::new();
14129 router.set_list_page_size(Some(1));
14130 router.add_resource(NamedResource::new("file:///cursor-resource-a"));
14131 router.add_resource(NamedResource::new("file:///cursor-resource-b"));
14132 router.add_prompt(NamedPrompt::new("cursor-prompt-a"));
14133 router.add_prompt(NamedPrompt::new("cursor-prompt-b"));
14134
14135 let cx = Cx::for_testing();
14136 let state = SessionState::new();
14137 let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
14138
14139 let resource_first = router
14140 .dispatch_stateless(
14141 &request_ctx,
14142 &final_list_request("resources/list", None, 166_i64),
14143 )
14144 .expect("the first final resource page is emitted");
14145 let resource_cursor = resource_first["nextCursor"]
14146 .as_str()
14147 .expect("the first resource page has a continuation")
14148 .to_owned();
14149 let resource_second = router
14150 .dispatch_stateless(
14151 &request_ctx,
14152 &final_list_request("resources/list", Some(&resource_cursor), 167_i64),
14153 )
14154 .expect("an unchanged final resource catalog accepts its cursor");
14155 assert_eq!(
14156 resource_second["resources"][0]["uri"],
14157 "file:///cursor-resource-b"
14158 );
14159
14160 router.add_resource(NamedResource::new("file:///cursor-resource-c"));
14161 let resource_stale = router
14162 .dispatch_stateless(
14163 &request_ctx,
14164 &final_list_request("resources/list", Some(&resource_cursor), 168_i64),
14165 )
14166 .expect_err("adding only one catalog resource invalidates the old continuation");
14167 assert_eq!(resource_stale.code, McpErrorCode::InvalidParams);
14168 assert!(resource_stale.message.contains("stale catalog revision"));
14169
14170 let prompt_first = router
14171 .dispatch_stateless(
14172 &request_ctx,
14173 &final_list_request("prompts/list", None, 169_i64),
14174 )
14175 .expect("the first final prompt page is emitted");
14176 let prompt_cursor = prompt_first["nextCursor"]
14177 .as_str()
14178 .expect("the first prompt page has a continuation")
14179 .to_owned();
14180 let prompt_second = router
14181 .dispatch_stateless(
14182 &request_ctx,
14183 &final_list_request("prompts/list", Some(&prompt_cursor), 170_i64),
14184 )
14185 .expect("an unchanged final prompt catalog accepts its cursor");
14186 assert_eq!(prompt_second["prompts"][0]["name"], "cursor-prompt-b");
14187
14188 router.add_prompt(NamedPrompt::new("cursor-prompt-c"));
14189 let prompt_stale = router
14190 .dispatch_stateless(
14191 &request_ctx,
14192 &final_list_request("prompts/list", Some(&prompt_cursor), 171_i64),
14193 )
14194 .expect_err("adding only one catalog prompt invalidates the old continuation");
14195 assert_eq!(prompt_stale.code, McpErrorCode::InvalidParams);
14196 assert!(prompt_stale.message.contains("stale catalog revision"));
14197 }
14198
14199 #[test]
14200 fn final_mounted_tools_apply_tag_filters_in_admitted_order() {
14201 let router = mounted_tool_router();
14202 let cx = Cx::for_testing();
14203 let state = SessionState::new();
14204 let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
14205
14206 let visible = router
14207 .dispatch_stateless(
14208 &request_ctx,
14209 &final_tools_list_request(None, Some(vec!["visible"]), None, 166_i64),
14210 )
14211 .expect("the final include filter projects only admitted visible entries");
14212 assert_eq!(
14213 visible["tools"]
14214 .as_array()
14215 .expect("final tools are an array")
14216 .iter()
14217 .map(|tool| tool["name"].as_str().expect("tool name is a string"))
14218 .collect::<Vec<_>>(),
14219 vec!["peer/first", "peer/second", "peer/excluded"],
14220 "tag filtering preserves admitted insertion order"
14221 );
14222
14223 let other = router
14224 .dispatch_stateless(
14225 &request_ctx,
14226 &final_tools_list_request(None, Some(vec!["other"]), None, 167_i64),
14227 )
14228 .expect("the include filter is evaluated after final admission");
14229 assert_eq!(other["tools"][0]["name"], "peer/other");
14230 assert_eq!(other["tools"].as_array().map(Vec::len), Some(1));
14231 }
14232
14233 #[test]
14236 fn handle_resources_list_with_session_state_filter() {
14237 let mut r = Router::new();
14238 r.add_resource(NamedResource::new("file:///a"));
14239 r.add_resource(NamedResource::new("file:///b"));
14240 let cx = Cx::for_testing();
14241 let state = SessionState::new();
14242 let disabled: std::collections::HashSet<String> =
14243 ["file:///a".to_string()].into_iter().collect();
14244 state.set("fastmcp.disabled_resources", &disabled);
14245 let params = ListResourcesParams {
14246 cursor: None,
14247 include_tags: None,
14248 exclude_tags: None,
14249 };
14250 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14251 let result = r
14252 .handle_resources_list(&request_ctx, params, Some(&state))
14253 .unwrap();
14254 assert_eq!(result.resources.len(), 1);
14255 assert_eq!(result.resources[0].uri, "file:///b");
14256 }
14257
14258 #[test]
14261 fn handle_prompts_list_with_session_state_filter() {
14262 let mut r = Router::new();
14263 r.add_prompt(NamedPrompt::new("a"));
14264 r.add_prompt(NamedPrompt::new("b"));
14265 let cx = Cx::for_testing();
14266 let state = SessionState::new();
14267 let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
14268 state.set("fastmcp.disabled_prompts", &disabled);
14269 let params = ListPromptsParams {
14270 cursor: None,
14271 include_tags: None,
14272 exclude_tags: None,
14273 };
14274 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14275 let result = r
14276 .handle_prompts_list(&request_ctx, params, Some(&state))
14277 .unwrap();
14278 assert_eq!(result.prompts.len(), 1);
14279 assert_eq!(result.prompts[0].name, "b");
14280 }
14281
14282 #[test]
14285 fn resource_templates_filtered_session_and_tags_combined() {
14286 let mut r = Router::new();
14287 r.add_resource_template(ResourceTemplate {
14288 uri_template: "db://{table}".to_string(),
14289 name: "db".to_string(),
14290 description: None,
14291 mime_type: None,
14292 icon: None,
14293 version: None,
14294 tags: vec!["admin".to_string()],
14295 });
14296 r.add_resource_template(ResourceTemplate {
14297 uri_template: "cache://{key}".to_string(),
14298 name: "cache".to_string(),
14299 description: None,
14300 mime_type: None,
14301 icon: None,
14302 version: None,
14303 tags: vec!["admin".to_string()],
14304 });
14305 r.add_resource_template(ResourceTemplate {
14306 uri_template: "log://{entry}".to_string(),
14307 name: "log".to_string(),
14308 description: None,
14309 mime_type: None,
14310 icon: None,
14311 version: None,
14312 tags: vec!["public".to_string()],
14313 });
14314 let state = SessionState::new();
14316 let disabled: std::collections::HashSet<String> =
14317 ["db://{table}".to_string()].into_iter().collect();
14318 state.set("fastmcp.disabled_resources", &disabled);
14319 let include = vec!["admin".to_string()];
14321 let filters = TagFilters::new(Some(&include), None);
14322 let tmpls = r.resource_templates_filtered(Some(&state), Some(&filters));
14323 assert_eq!(tmpls.len(), 1);
14325 assert_eq!(tmpls[0].name, "cache");
14326 }
14327
14328 #[test]
14331 fn mount_resource_template_warns_on_conflict() {
14332 let mut main = Router::new();
14333 main.add_resource_template(ResourceTemplate {
14334 uri_template: "db://{table}".to_string(),
14335 name: "db".to_string(),
14336 description: None,
14337 mime_type: None,
14338 icon: None,
14339 version: None,
14340 tags: vec![],
14341 });
14342 let mut sub = Router::new();
14343 sub.add_resource_template(ResourceTemplate {
14344 uri_template: "db://{table}".to_string(),
14345 name: "db2".to_string(),
14346 description: None,
14347 mime_type: None,
14348 icon: None,
14349 version: None,
14350 tags: vec![],
14351 });
14352 let result = main.mount(sub, None);
14353 assert!(!result.warnings.is_empty());
14354 assert!(result.warnings[0].contains("Resource template"));
14355 }
14356
14357 #[test]
14360 fn handle_tools_call_disabled_tool_returns_error() {
14361 let mut r = Router::new();
14362 r.add_tool(NamedTool::new("my_tool"))
14363 .expect("tool registration succeeds");
14364 let cx = Cx::for_testing();
14365 let budget = Budget::INFINITE;
14366 let state = SessionState::new();
14367 let disabled: std::collections::HashSet<String> =
14368 ["my_tool".to_string()].into_iter().collect();
14369 state.set("fastmcp.disabled_tools", &disabled);
14370 let params = CallToolParams {
14371 name: "my_tool".to_string(),
14372 arguments: None,
14373 meta: None,
14374 };
14375 let request_ctx = request_context(&cx, 1, budget, &state);
14376 let err = r
14377 .handle_tools_call(&request_ctx, params, state, None, None)
14378 .unwrap_err();
14379 assert!(err.message.contains("disabled"));
14380 }
14381
14382 #[test]
14385 fn handle_tools_call_success() {
14386 let mut r = Router::new();
14387 r.add_tool(NamedTool::new("echo"))
14388 .expect("tool registration succeeds");
14389 let cx = Cx::for_testing();
14390 let budget = Budget::INFINITE;
14391 let params = CallToolParams {
14392 name: "echo".to_string(),
14393 arguments: None,
14394 meta: None,
14395 };
14396 let state = SessionState::new();
14397 let request_ctx = request_context(&cx, 1, budget, &state);
14398 let result = r
14399 .handle_tools_call(&request_ctx, params, state, None, None)
14400 .unwrap();
14401 assert!(!result.is_error);
14402 assert!(!result.content.is_empty());
14403 }
14404
14405 #[test]
14408 fn handle_tools_call_not_found() {
14409 let r = Router::new();
14410 let cx = Cx::for_testing();
14411 let budget = Budget::INFINITE;
14412 let params = CallToolParams {
14413 name: "missing".to_string(),
14414 arguments: None,
14415 meta: None,
14416 };
14417 let state = SessionState::new();
14418 let request_ctx = request_context(&cx, 1, budget, &state);
14419 let err = r
14420 .handle_tools_call(&request_ctx, params, state, None, None)
14421 .unwrap_err();
14422 assert_eq!(err.code, McpErrorCode::MethodNotFound);
14425 assert!(!err.message.contains("missing"));
14426 }
14427
14428 #[test]
14431 fn handle_tools_call_zero_poll_balance_allows_handler_without_checkpoint() {
14432 let mut r = Router::new();
14433 r.add_tool(NamedTool::new("t"))
14434 .expect("tool registration succeeds");
14435 let cx = Cx::for_testing();
14436 let budget = Budget::unlimited().with_poll_quota(0);
14437 let params = CallToolParams {
14438 name: "t".to_string(),
14439 arguments: None,
14440 meta: None,
14441 };
14442 let state = SessionState::new();
14443 let request_ctx = request_context(&cx, 1, budget, &state);
14444 let result = r
14445 .handle_tools_call(&request_ctx, params, state, None, None)
14446 .expect("a zero balance is not a retroactive failure");
14447 assert!(!result.is_error);
14448 }
14449
14450 #[test]
14451 fn handle_tools_call_defers_pending_cancellation_inside_context_mask() {
14452 let mut router = Router::new();
14453 router
14454 .add_tool(NamedTool::new("t"))
14455 .expect("tool registration succeeds");
14456 let cx = Cx::for_testing();
14457 cx.set_cancel_requested(true);
14458 let state = SessionState::new();
14459 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14460 let params = || CallToolParams {
14461 name: "t".to_string(),
14462 arguments: None,
14463 meta: None,
14464 };
14465
14466 let masked_result = request_ctx
14467 .masked(|| router.handle_tools_call(&request_ctx, params(), state.clone(), None, None))
14468 .expect("mask should be admitted");
14469 assert!(masked_result.is_ok());
14470
14471 let unmasked_error = router
14472 .handle_tools_call(&request_ctx, params(), state, None, None)
14473 .expect_err("pending cancellation should surface after mask exit");
14474 assert_eq!(unmasked_error.code, McpErrorCode::RequestCancelled);
14475 }
14476
14477 #[test]
14480 fn handle_resources_read_disabled_resource_returns_error() {
14481 let mut r = Router::new();
14482 r.add_resource(NamedResource::new("file:///secret"));
14483 let cx = Cx::for_testing();
14484 let budget = Budget::INFINITE;
14485 let state = SessionState::new();
14486 let disabled: std::collections::HashSet<String> =
14487 ["file:///secret".to_string()].into_iter().collect();
14488 state.set("fastmcp.disabled_resources", &disabled);
14489 let params = ReadResourceParams {
14490 uri: "file:///secret".to_string(),
14491 meta: None,
14492 };
14493 let request_ctx = request_context(&cx, 1, budget, &state);
14494 let err = r
14495 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
14496 .unwrap_err();
14497 assert!(err.message.contains("disabled"));
14498 }
14499
14500 #[test]
14503 fn handle_resources_read_success() {
14504 let mut r = Router::new();
14505 r.add_resource(NamedResource::new("file:///a"));
14506 let cx = Cx::for_testing();
14507 let budget = Budget::INFINITE;
14508 let params = ReadResourceParams {
14509 uri: "file:///a".to_string(),
14510 meta: None,
14511 };
14512 let state = SessionState::new();
14513 let request_ctx = request_context(&cx, 1, budget, &state);
14514 let result = r
14515 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
14516 .unwrap();
14517 assert_eq!(result.contents.len(), 1);
14518 let [
14519 LegacyResourceContent::Text {
14520 uri,
14521 text,
14522 additional,
14523 ..
14524 },
14525 ] = result.contents.as_slice()
14526 else {
14527 panic!("resource read must retain the exact legacy text shape");
14528 };
14529 assert_eq!(uri, "file:///a");
14530 assert_eq!(text, "content");
14531 assert!(additional.is_empty());
14532 }
14533
14534 #[test]
14537 fn handle_resources_read_not_found() {
14538 let r = Router::new();
14539 let cx = Cx::for_testing();
14540 let budget = Budget::INFINITE;
14541 let params = ReadResourceParams {
14542 uri: "file:///nonexistent".to_string(),
14543 meta: None,
14544 };
14545 let state = SessionState::new();
14546 let request_ctx = request_context(&cx, 1, budget, &state);
14547 let err = r
14548 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
14549 .unwrap_err();
14550 assert!(err.message.contains("nonexistent") || err.message.contains("not found"));
14551 }
14552
14553 #[test]
14556 fn handle_resources_read_zero_poll_balance_allows_handler_without_checkpoint() {
14557 let mut r = Router::new();
14558 r.add_resource(NamedResource::new("file:///a"));
14559 let cx = Cx::for_testing();
14560 let budget = Budget::unlimited().with_poll_quota(0);
14561 let params = ReadResourceParams {
14562 uri: "file:///a".to_string(),
14563 meta: None,
14564 };
14565 let state = SessionState::new();
14566 let request_ctx = request_context(&cx, 1, budget, &state);
14567 let result = r
14568 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
14569 .expect("a zero balance is not a retroactive failure");
14570 assert_eq!(result.contents.len(), 1);
14571 }
14572
14573 #[test]
14576 fn handle_prompts_get_disabled_prompt_returns_error() {
14577 let mut r = Router::new();
14578 r.add_prompt(NamedPrompt::new("secret_prompt"));
14579 let cx = Cx::for_testing();
14580 let budget = Budget::INFINITE;
14581 let state = SessionState::new();
14582 let disabled: std::collections::HashSet<String> =
14583 ["secret_prompt".to_string()].into_iter().collect();
14584 state.set("fastmcp.disabled_prompts", &disabled);
14585 let params = GetPromptParams {
14586 name: "secret_prompt".to_string(),
14587 arguments: None,
14588 meta: None,
14589 };
14590 let request_ctx = request_context(&cx, 1, budget, &state);
14591 let err = r
14592 .handle_prompts_get(&request_ctx, params, state, None, None)
14593 .unwrap_err();
14594 assert!(err.message.contains("disabled"));
14595 }
14596
14597 #[test]
14600 fn nested_get_prompt_returns_handler_text_and_refuses_missing() {
14601 let mut router = Router::new();
14602 router.add_prompt(NamedPrompt::new("greet"));
14603 let router = Arc::new(router);
14604 let cx = Cx::for_testing();
14605 let state = SessionState::new();
14606 let request_ctx = McpContext::with_state(cx, 1, state.clone()).with_prompt_caller(
14607 Arc::new(RouterPromptCaller::new(Arc::clone(&router), state)),
14608 );
14609
14610 let missing = block_on(request_ctx.get_prompt("missing", HashMap::new()))
14611 .expect_err("an unknown nested prompt must stay refused");
14612 assert_eq!(missing.code, McpErrorCode::PromptNotFound);
14613
14614 let result = block_on(request_ctx.get_prompt("greet", HashMap::new()))
14615 .expect("a registered nested prompt must complete");
14616 assert_eq!(result.description.as_deref(), Some("Prompt greet"));
14617 assert!(result.messages.is_empty());
14618 }
14619
14620 fn handle_prompts_get_success() {
14621 let mut r = Router::new();
14622 r.add_prompt(NamedPrompt::new("greet"));
14623 let cx = Cx::for_testing();
14624 let budget = Budget::INFINITE;
14625 let params = GetPromptParams {
14626 name: "greet".to_string(),
14627 arguments: None,
14628 meta: None,
14629 };
14630 let state = SessionState::new();
14631 let request_ctx = request_context(&cx, 1, budget, &state);
14632 let result = r
14633 .handle_prompts_get(&request_ctx, params, state, None, None)
14634 .unwrap();
14635 assert!(result.description.is_some());
14636 }
14637
14638 #[test]
14641 fn handle_prompts_get_not_found() {
14642 let r = Router::new();
14643 let cx = Cx::for_testing();
14644 let budget = Budget::INFINITE;
14645 let params = GetPromptParams {
14646 name: "missing".to_string(),
14647 arguments: None,
14648 meta: None,
14649 };
14650 let state = SessionState::new();
14651 let request_ctx = request_context(&cx, 1, budget, &state);
14652 let err = r
14653 .handle_prompts_get(&request_ctx, params, state, None, None)
14654 .unwrap_err();
14655 assert!(err.message.contains("missing") || err.message.contains("not found"));
14656 }
14657
14658 #[test]
14661 fn handle_prompts_get_zero_poll_balance_allows_handler_without_checkpoint() {
14662 let mut r = Router::new();
14663 r.add_prompt(NamedPrompt::new("p"));
14664 let cx = Cx::for_testing();
14665 let budget = Budget::unlimited().with_poll_quota(0);
14666 let params = GetPromptParams {
14667 name: "p".to_string(),
14668 arguments: None,
14669 meta: None,
14670 };
14671 let state = SessionState::new();
14672 let request_ctx = request_context(&cx, 1, budget, &state);
14673 let result = r
14674 .handle_prompts_get(&request_ctx, params, state, None, None)
14675 .expect("a zero balance is not a retroactive failure");
14676 assert!(result.messages.is_empty());
14677 }
14678
14679 #[test]
14680 fn handler_budget_composition_uses_exact_earliest_deadline() {
14681 let now = Time::from_secs(100);
14682 let at = |seconds| Budget::new().with_deadline(Time::from_secs(seconds));
14683 let cases = [
14684 (Budget::INFINITE, Budget::INFINITE, None, None),
14685 (at(110), Budget::INFINITE, None, Some(Time::from_secs(110))),
14686 (Budget::INFINITE, at(115), None, Some(Time::from_secs(115))),
14687 (
14688 at(110),
14689 at(115),
14690 Some(Duration::from_secs(30)),
14691 Some(Time::from_secs(110)),
14692 ),
14693 (
14694 at(140),
14695 at(130),
14696 Some(Duration::from_secs(5)),
14697 Some(Time::from_secs(105)),
14698 ),
14699 (
14700 at(110),
14701 at(115),
14702 Some(Duration::ZERO),
14703 Some(Time::from_secs(110)),
14704 ),
14705 (
14706 at(90),
14707 at(115),
14708 Some(Duration::from_secs(5)),
14709 Some(Time::from_secs(90)),
14710 ),
14711 (
14712 Budget::INFINITE,
14713 Budget::new().with_deadline(Time::from_nanos(u64::MAX - 1)),
14714 Some(Duration::MAX),
14715 Some(Time::from_nanos(u64::MAX - 1)),
14716 ),
14717 ];
14718
14719 for (ambient, request, handler, expected) in cases {
14720 assert_eq!(
14721 compose_handler_budget(ambient, request, handler, now).deadline,
14722 expected
14723 );
14724 }
14725 }
14726
14727 #[test]
14728 fn alternating_tool_resource_recursion_uses_one_effective_depth() {
14729 let calls = Arc::new(AtomicU64::new(0));
14730 let mut router = Router::new();
14731 router
14732 .add_tool(AlternatingTool {
14733 calls: Arc::clone(&calls),
14734 })
14735 .expect("tool registration succeeds");
14736 router.add_resource(AlternatingResource {
14737 calls: Arc::clone(&calls),
14738 });
14739 let router = Arc::new(router);
14740 let cx = Cx::for_testing();
14741 let state = SessionState::new();
14742 let request_ctx = McpContext::with_state(cx, 1, state.clone())
14743 .with_tool_caller(Arc::new(RouterToolCaller::new(
14744 Arc::clone(&router),
14745 state.clone(),
14746 )))
14747 .with_resource_reader(Arc::new(RouterResourceReader::new(
14748 Arc::clone(&router),
14749 state.clone(),
14750 )));
14751
14752 let error = router
14753 .handle_tools_call(
14754 &request_ctx,
14755 CallToolParams {
14756 name: "alternating_tool".to_string(),
14757 arguments: None,
14758 meta: None,
14759 },
14760 state,
14761 None,
14762 None,
14763 )
14764 .expect_err("alternating recursion must stop at the shared depth limit");
14765
14766 assert_eq!(error.code, McpErrorCode::InternalError);
14767 assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
14771 assert_eq!(calls.load(Ordering::Relaxed), 11);
14772 }
14773
14774 #[test]
14775 fn nested_tool_resource_call_shares_parent_cost_ledger() {
14776 let remaining_after_parent_debit = Arc::new(AtomicU64::new(u64::MAX));
14777 let remaining_after_nested_debit = Arc::new(AtomicU64::new(u64::MAX));
14778 let remaining_after_nested_read = Arc::new(AtomicU64::new(u64::MAX));
14779 let mut router = Router::new();
14780 router
14781 .add_tool(CostLedgerTool {
14782 remaining_after_parent_debit: Arc::clone(&remaining_after_parent_debit),
14783 remaining_after_nested_read: Arc::clone(&remaining_after_nested_read),
14784 })
14785 .expect("tool registration succeeds");
14786 router.add_resource(CostLedgerResource {
14787 remaining_after_nested_debit: Arc::clone(&remaining_after_nested_debit),
14788 });
14789
14790 let router = Arc::new(router);
14791 let state = SessionState::new();
14792 let cx = Cx::for_testing_with_budget(Budget::new().with_cost_quota(3));
14793 let request_ctx = McpContext::with_state(cx, 77, state.clone())
14794 .with_tool_caller(Arc::new(RouterToolCaller::new(
14795 Arc::clone(&router),
14796 state.clone(),
14797 )))
14798 .with_resource_reader(Arc::new(RouterResourceReader::new(router, state)));
14799
14800 let result = block_on(request_ctx.call_tool("cost_ledger_tool", serde_json::json!({})))
14801 .expect("parent and nested debits fit the shared cost quota");
14802
14803 assert!(!result.is_error);
14804 assert_eq!(remaining_after_parent_debit.load(Ordering::Relaxed), 2);
14805 assert_eq!(remaining_after_nested_debit.load(Ordering::Relaxed), 1);
14806 assert_eq!(remaining_after_nested_read.load(Ordering::Relaxed), 1);
14807 assert_eq!(request_ctx.budget().cost_quota, Some(1));
14808 }
14809
14810 #[test]
14811 fn nested_tool_calls_preserve_framework_terminal_errors() {
14812 let mut router = Router::new();
14813 router
14814 .add_tool(ErrorTool {
14815 name: "nested_cancelled",
14816 code: McpErrorCode::RequestCancelled,
14817 })
14818 .expect("tool registration succeeds");
14819 router
14820 .add_tool(ErrorTool {
14821 name: "nested_internal",
14822 code: McpErrorCode::InternalError,
14823 })
14824 .expect("tool registration succeeds");
14825 router
14826 .add_tool(ErrorTool {
14827 name: "nested_tool_failure",
14828 code: McpErrorCode::ToolExecutionError,
14829 })
14830 .expect("tool registration succeeds");
14831 let router = Arc::new(router);
14832 let cx = Cx::for_testing();
14833 let state = SessionState::new();
14834 let request_ctx = McpContext::with_state(cx, 88, state.clone())
14835 .with_tool_caller(Arc::new(RouterToolCaller::new(Arc::clone(&router), state)));
14836
14837 for (name, expected) in [
14838 ("nested_cancelled", McpErrorCode::RequestCancelled),
14839 ("nested_internal", McpErrorCode::InternalError),
14840 ] {
14841 let error = block_on(request_ctx.call_tool(name, serde_json::json!({})))
14842 .expect_err("framework terminal errors must remain outer failures");
14843 assert_eq!(error.code, expected);
14844 }
14845
14846 let tool_failure =
14847 block_on(request_ctx.call_tool("nested_tool_failure", serde_json::json!({})))
14848 .expect("ordinary tool failures remain protocol-level tool results");
14849 assert!(tool_failure.is_error);
14850 }
14851
14852 #[test]
14853 fn manual_handler_timeout_is_read_exposed_and_enforced() {
14854 let observed_deadline = Arc::new(Mutex::new(None));
14855 let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
14856 let mut router = Router::new();
14857 router
14861 .add_tool(BudgetProbeTool {
14862 timeout: Some(Duration::from_millis(10)),
14863 delay: Duration::from_millis(100),
14864 observed_deadline: Arc::clone(&observed_deadline),
14865 timeout_read: Arc::clone(&timeout_read),
14866 })
14867 .expect("tool registration succeeds");
14868 let cx = Cx::for_testing();
14869 let state = SessionState::new();
14870 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14871 let error = router
14872 .handle_tools_call(
14873 &request_ctx,
14874 CallToolParams {
14875 name: "budget_probe".to_string(),
14876 arguments: None,
14877 meta: None,
14878 },
14879 state,
14880 None,
14881 None,
14882 )
14883 .expect_err("the handler deadline must reject a late completion");
14884
14885 assert!(timeout_read.load(Ordering::Relaxed));
14886 assert!(
14887 observed_deadline
14888 .lock()
14889 .unwrap_or_else(std::sync::PoisonError::into_inner)
14890 .is_some()
14891 );
14892 assert_eq!(error.code, McpErrorCode::RequestCancelled);
14893 assert_eq!(error.message, "Request timeout exceeded");
14894 assert!(!cx.is_cancel_requested());
14895 }
14896
14897 #[test]
14898 fn admitted_definition_is_not_requeried_inside_handler_budget() {
14899 let definition_reads = Arc::new(AtomicU64::new(0));
14900 let called = Arc::new(std::sync::atomic::AtomicBool::new(false));
14901 let mut router = Router::new();
14902 router
14903 .add_tool(SlowDefinitionTool {
14904 definition_reads: Arc::clone(&definition_reads),
14905 called: Arc::clone(&called),
14906 })
14907 .expect("the definition snapshot is admitted once");
14908 let cx = Cx::for_testing();
14909 let state = SessionState::new();
14910 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14911
14912 router
14913 .handle_tools_call(
14914 &request_ctx,
14915 CallToolParams {
14916 name: "slow_definition".to_string(),
14917 arguments: None,
14918 meta: None,
14919 },
14920 state,
14921 None,
14922 None,
14923 )
14924 .expect("dispatch uses the admitted snapshot and reaches the handler");
14925
14926 assert!(called.load(Ordering::Relaxed));
14927 assert_eq!(definition_reads.load(Ordering::Relaxed), 1);
14928 }
14929
14930 #[test]
14931 fn zero_handler_timeout_cannot_relax_request_deadline() {
14932 let observed_deadline = Arc::new(Mutex::new(None));
14933 let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
14934 let request_deadline = wall_now().saturating_add_nanos(5_000_000_000);
14935 let mut router = Router::new();
14936 router
14937 .add_tool(BudgetProbeTool {
14938 timeout: Some(Duration::ZERO),
14939 delay: Duration::ZERO,
14940 observed_deadline: Arc::clone(&observed_deadline),
14941 timeout_read: Arc::clone(&timeout_read),
14942 })
14943 .expect("tool registration succeeds");
14944 let cx = Cx::for_testing();
14945 let state = SessionState::new();
14946 let request_ctx = request_context(
14947 &cx,
14948 1,
14949 Budget::new().with_deadline(request_deadline),
14950 &state,
14951 );
14952 router
14953 .handle_tools_call(
14954 &request_ctx,
14955 CallToolParams {
14956 name: "budget_probe".to_string(),
14957 arguments: None,
14958 meta: None,
14959 },
14960 state,
14961 None,
14962 None,
14963 )
14964 .expect("zero adds no ceiling but preserves the request deadline");
14965
14966 assert!(timeout_read.load(Ordering::Relaxed));
14967 assert_eq!(
14968 *observed_deadline
14969 .lock()
14970 .unwrap_or_else(std::sync::PoisonError::into_inner),
14971 Some(request_deadline)
14972 );
14973 }
14974
14975 #[test]
14976 fn ambient_deadline_remains_visible_when_server_and_handler_are_looser() {
14977 let observed_deadline = Arc::new(Mutex::new(None));
14978 let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
14979 let ambient_deadline = wall_now().saturating_add_nanos(2_000_000_000);
14980 let request_deadline = ambient_deadline.saturating_add_nanos(2_000_000_000);
14981 let mut router = Router::new();
14982 router
14983 .add_tool(BudgetProbeTool {
14984 timeout: Some(Duration::from_secs(10)),
14985 delay: Duration::ZERO,
14986 observed_deadline: Arc::clone(&observed_deadline),
14987 timeout_read,
14988 })
14989 .expect("tool registration succeeds");
14990 let cx = Cx::for_testing_with_budget(Budget::new().with_deadline(ambient_deadline));
14991 let state = SessionState::new();
14992 let request_ctx = request_context(
14993 &cx,
14994 1,
14995 Budget::new().with_deadline(request_deadline),
14996 &state,
14997 );
14998 router
14999 .handle_tools_call(
15000 &request_ctx,
15001 CallToolParams {
15002 name: "budget_probe".to_string(),
15003 arguments: None,
15004 meta: None,
15005 },
15006 state,
15007 None,
15008 None,
15009 )
15010 .expect("looser inner limits must preserve the ambient deadline");
15011
15012 assert_eq!(
15013 *observed_deadline
15014 .lock()
15015 .unwrap_or_else(std::sync::PoisonError::into_inner),
15016 Some(ambient_deadline)
15017 );
15018 }
15019
15020 fn assert_sanitized_panic_tool(handler: impl ToolHandler + 'static, name: &str) {
15021 let mut router = Router::new();
15022 router
15023 .add_tool(handler)
15024 .expect("panic-test tool registration succeeds");
15025 let cx = Cx::for_testing();
15026 let state = SessionState::new();
15027 let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
15028 let error = router
15029 .handle_tools_call(
15030 &request_ctx,
15031 CallToolParams {
15032 name: name.to_string(),
15033 arguments: None,
15034 meta: None,
15035 },
15036 state,
15037 None,
15038 None,
15039 )
15040 .expect_err("panic must terminate as a sanitized protocol error");
15041 let wire = serde_json::to_string(&error).expect("error serializes");
15042 assert_eq!(error.code, McpErrorCode::InternalError);
15043 assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
15044 assert_eq!(error.data, None);
15045 assert!(!wire.contains(PANIC_CANARY));
15046 assert!(!wire.contains("Bearer"));
15047 assert!(!wire.contains("secret"));
15048 assert!(!wire.contains("peer-secret"));
15049 assert!(!wire.contains('\u{001b}'));
15050 assert!(wire.len() < 256);
15051 assert!(!cx.is_cancel_requested());
15052 }
15053
15054 #[test]
15055 fn unwinding_string_and_non_string_panics_have_one_fixed_peer_error() {
15056 assert_sanitized_panic_tool(
15057 UnwindingPanicTool {
15058 payload: "Bearer actual-secret".to_string(),
15059 non_string: false,
15060 },
15061 "panic_tool",
15062 );
15063 assert_sanitized_panic_tool(
15064 UnwindingPanicTool {
15065 payload: PANIC_CANARY.to_string(),
15066 non_string: true,
15067 },
15068 "panic_tool",
15069 );
15070 }
15071
15072 #[test]
15073 fn four_valued_panic_payload_is_never_rendered_for_peer() {
15074 assert_sanitized_panic_tool(
15075 OutcomePanicTool(format!("{PANIC_CANARY}{}", "y".repeat(64 * 1024))),
15076 "outcome_panic_tool",
15077 );
15078 }
15079
15080 #[test]
15081 fn opaque_internal_handler_errors_use_the_fixed_peer_contract() {
15082 let cx = Cx::for_testing();
15083
15084 let mut tool_router = Router::new();
15085 tool_router
15086 .add_tool(OpaqueInternalTool)
15087 .expect("tool registration succeeds");
15088 let tool_state = SessionState::new();
15089 let tool_ctx = request_context(&cx, 1, Budget::INFINITE, &tool_state);
15090 let tool_error = tool_router
15091 .handle_tools_call(
15092 &tool_ctx,
15093 CallToolParams {
15094 name: "opaque_internal_tool".to_string(),
15095 arguments: None,
15096 meta: None,
15097 },
15098 tool_state,
15099 None,
15100 None,
15101 )
15102 .expect_err("opaque internal tool errors must remain protocol failures");
15103
15104 let mut resource_router = Router::new();
15105 resource_router.add_resource(OpaqueInternalResource);
15106 let resource_state = SessionState::new();
15107 let resource_ctx = request_context(&cx, 2, Budget::INFINITE, &resource_state);
15108 let resource_error = resource_router
15109 .handle_resources_read(
15110 &resource_ctx,
15111 &ReadResourceParams {
15112 uri: "opaque://internal".to_string(),
15113 meta: None,
15114 },
15115 resource_state,
15116 None,
15117 None,
15118 )
15119 .expect_err("opaque internal resource errors must be sanitized");
15120
15121 let mut prompt_router = Router::new();
15122 prompt_router.add_prompt(OpaqueInternalPrompt);
15123 let prompt_state = SessionState::new();
15124 let prompt_ctx = request_context(&cx, 3, Budget::INFINITE, &prompt_state);
15125 let prompt_error = prompt_router
15126 .handle_prompts_get(
15127 &prompt_ctx,
15128 GetPromptParams {
15129 name: "opaque_internal_prompt".to_string(),
15130 arguments: None,
15131 meta: None,
15132 },
15133 prompt_state,
15134 None,
15135 None,
15136 )
15137 .expect_err("opaque internal prompt errors must be sanitized");
15138
15139 for error in [tool_error, resource_error, prompt_error] {
15140 let wire = serde_json::to_string(&error).expect("error serializes");
15141 assert_eq!(error.code, McpErrorCode::InternalError);
15142 assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
15143 assert_eq!(error.data, None);
15144 assert!(!wire.contains(PANIC_CANARY));
15145 assert!(wire.len() < 256);
15146 }
15147 }
15148
15149 #[test]
15150 fn resource_and_prompt_panics_use_same_sanitized_contract() {
15151 let mut resource_router = Router::new();
15152 resource_router.add_resource(PanicResource);
15153 let resource_cx = Cx::for_testing();
15154 let resource_state = SessionState::new();
15155 let resource_ctx = request_context(&resource_cx, 1, Budget::INFINITE, &resource_state);
15156 let resource_error = resource_router
15157 .handle_resources_read(
15158 &resource_ctx,
15159 &ReadResourceParams {
15160 uri: "panic://resource".to_string(),
15161 meta: None,
15162 },
15163 resource_state,
15164 None,
15165 None,
15166 )
15167 .expect_err("resource panic must be sanitized");
15168 assert_eq!(resource_error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
15169
15170 let mut prompt_router = Router::new();
15171 prompt_router.add_prompt(PanicPrompt);
15172 let prompt_cx = Cx::for_testing();
15173 let prompt_state = SessionState::new();
15174 let prompt_ctx = request_context(&prompt_cx, 1, Budget::INFINITE, &prompt_state);
15175 let prompt_error = prompt_router
15176 .handle_prompts_get(
15177 &prompt_ctx,
15178 GetPromptParams {
15179 name: "panic_prompt".to_string(),
15180 arguments: None,
15181 meta: None,
15182 },
15183 prompt_state,
15184 None,
15185 None,
15186 )
15187 .expect_err("prompt panic must be sanitized");
15188 assert_eq!(prompt_error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
15189
15190 for error in [resource_error, prompt_error] {
15191 let wire = serde_json::to_string(&error).expect("error serializes");
15192 assert!(!wire.contains(PANIC_CANARY));
15193 assert!(wire.len() < 256);
15194 }
15195 }
15196
15197 #[test]
15198 fn admitted_tool_definition_is_snapshotted_while_other_list_panics_are_sanitized() {
15199 let cx = Cx::for_testing();
15200 let request_ctx = McpContext::new(cx, 1);
15201
15202 let mut tool_router = Router::new();
15203 tool_router
15204 .add_tool(DefinitionPanicTool(std::sync::atomic::AtomicBool::new(
15205 false,
15206 )))
15207 .expect("the definition hook is read exactly once during admission");
15208 let tools = tool_router
15209 .handle_tools_list(&request_ctx, ListToolsParams::default(), None)
15210 .expect("listing clones the immutable admitted definition snapshot");
15211 assert_eq!(tools.tools.len(), 1);
15212 assert_eq!(tools.tools[0].name, "definition_panic_tool");
15213
15214 let mut resource_router = Router::new();
15215 resource_router.add_resource(DefinitionPanicResource(std::sync::atomic::AtomicBool::new(
15216 false,
15217 )));
15218 let resource_error = resource_router
15219 .handle_resources_list(&request_ctx, ListResourcesParams::default(), None)
15220 .expect_err("resource definition panic must be sanitized");
15221
15222 let mut prompt_router = Router::new();
15223 prompt_router.add_prompt(DefinitionPanicPrompt(std::sync::atomic::AtomicBool::new(
15224 false,
15225 )));
15226 let prompt_error = prompt_router
15227 .handle_prompts_list(&request_ctx, ListPromptsParams::default(), None)
15228 .expect_err("prompt definition panic must be sanitized");
15229
15230 for error in [resource_error, prompt_error] {
15231 let wire = serde_json::to_string(&error).expect("error serializes");
15232 assert_eq!(error.code, McpErrorCode::InternalError);
15233 assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
15234 assert_eq!(error.data, None);
15235 assert!(!wire.contains(PANIC_CANARY));
15236 assert!(wire.len() < 256);
15237 }
15238 }
15239
15240 #[test]
15243 fn add_resource_with_behavior_template_error_on_duplicate() {
15244 struct TmplResource;
15245 impl ResourceHandler for TmplResource {
15246 fn definition(&self) -> Resource {
15247 Resource {
15248 uri: "db://placeholder".to_string(),
15249 name: "db".to_string(),
15250 description: None,
15251 mime_type: None,
15252 icon: None,
15253 version: None,
15254 tags: vec![],
15255 }
15256 }
15257 fn template(&self) -> Option<ResourceTemplate> {
15258 Some(ResourceTemplate {
15259 uri_template: "db://{table}".to_string(),
15260 name: "db".to_string(),
15261 description: None,
15262 mime_type: None,
15263 icon: None,
15264 version: None,
15265 tags: vec![],
15266 })
15267 }
15268 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
15269 Ok(vec![])
15270 }
15271 }
15272 let mut r = Router::new();
15273 r.add_resource(TmplResource);
15274 let err = r
15275 .add_resource_with_behavior(TmplResource, crate::DuplicateBehavior::Error)
15276 .unwrap_err();
15277 assert!(err.message.contains("already exists"));
15278 }
15279
15280 #[test]
15283 fn add_resource_with_behavior_template_ignore_on_duplicate() {
15284 struct TmplResource2;
15285 impl ResourceHandler for TmplResource2 {
15286 fn definition(&self) -> Resource {
15287 Resource {
15288 uri: "cache://placeholder".to_string(),
15289 name: "cache".to_string(),
15290 description: None,
15291 mime_type: None,
15292 icon: None,
15293 version: None,
15294 tags: vec![],
15295 }
15296 }
15297 fn template(&self) -> Option<ResourceTemplate> {
15298 Some(ResourceTemplate {
15299 uri_template: "cache://{key}".to_string(),
15300 name: "cache".to_string(),
15301 description: None,
15302 mime_type: None,
15303 icon: None,
15304 version: None,
15305 tags: vec![],
15306 })
15307 }
15308 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
15309 Ok(vec![])
15310 }
15311 }
15312 let mut r = Router::new();
15313 r.add_resource(TmplResource2);
15314 r.add_resource_with_behavior(TmplResource2, crate::DuplicateBehavior::Ignore)
15315 .unwrap();
15316 assert_eq!(r.resource_templates_count(), 1);
15317 }
15318
15319 #[test]
15322 fn add_resource_with_behavior_template_warn_on_duplicate() {
15323 struct TmplResource3;
15324 impl ResourceHandler for TmplResource3 {
15325 fn definition(&self) -> Resource {
15326 Resource {
15327 uri: "log://placeholder".to_string(),
15328 name: "log".to_string(),
15329 description: None,
15330 mime_type: None,
15331 icon: None,
15332 version: None,
15333 tags: vec![],
15334 }
15335 }
15336 fn template(&self) -> Option<ResourceTemplate> {
15337 Some(ResourceTemplate {
15338 uri_template: "log://{entry}".to_string(),
15339 name: "log".to_string(),
15340 description: None,
15341 mime_type: None,
15342 icon: None,
15343 version: None,
15344 tags: vec![],
15345 })
15346 }
15347 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
15348 Ok(vec![])
15349 }
15350 }
15351 let mut r = Router::new();
15352 r.add_resource(TmplResource3);
15353 r.add_resource_with_behavior(TmplResource3, crate::DuplicateBehavior::Warn)
15354 .unwrap();
15355 assert_eq!(r.resource_templates_count(), 1);
15356 }
15357
15358 #[test]
15361 fn mount_tools_warns_on_tool_conflict() {
15362 let mut main = Router::new();
15363 main.add_tool(NamedTool::new("t"))
15364 .expect("tool registration succeeds");
15365 let mut sub = Router::new();
15366 sub.add_tool(NamedTool::new("t"))
15367 .expect("tool registration succeeds");
15368 let result = main.mount_tools(sub, None);
15369 assert!(!result.warnings.is_empty());
15370 assert!(result.warnings[0].contains("Tool"));
15371 }
15372
15373 #[test]
15376 fn mount_prompts_warns_on_prompt_conflict() {
15377 let mut main = Router::new();
15378 main.add_prompt(NamedPrompt::new("p"));
15379 let mut sub = Router::new();
15380 sub.add_prompt(NamedPrompt::new("p"));
15381 let result = main.mount_prompts(sub, None);
15382 assert!(!result.warnings.is_empty());
15383 assert!(result.warnings[0].contains("Prompt"));
15384 }
15385
15386 #[test]
15389 fn invalid_cursor_returns_error() {
15390 let mut r = Router::new();
15391 r.set_list_page_size(Some(1));
15392 r.add_tool(NamedTool::new("a"))
15393 .expect("tool registration succeeds");
15394 let cx = Cx::for_testing();
15395 let params = ListToolsParams {
15396 cursor: Some("not-valid-base64!!!".to_string()),
15397 include_tags: None,
15398 exclude_tags: None,
15399 };
15400 let request_ctx = McpContext::new(cx, 1);
15401 let err = r.handle_tools_list(&request_ctx, params, None).unwrap_err();
15402 assert!(err.message.contains("cursor") || err.message.contains("Invalid"));
15403 }
15404
15405 #[test]
15408 fn set_list_page_size_zero_disables_pagination() {
15409 let mut r = Router::new();
15410 r.set_list_page_size(Some(0));
15411 r.add_tool(NamedTool::new("a"))
15412 .expect("tool registration succeeds");
15413 r.add_tool(NamedTool::new("b"))
15414 .expect("tool registration succeeds");
15415 let cx = Cx::for_testing();
15416 let params = ListToolsParams {
15417 cursor: None,
15418 include_tags: None,
15419 exclude_tags: None,
15420 };
15421 let request_ctx = McpContext::new(cx, 1);
15422 let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
15423 assert_eq!(result.tools.len(), 2);
15425 assert!(result.next_cursor.is_none());
15426 }
15427
15428 #[test]
15431 fn strict_input_validation_toggle() {
15432 let mut r = Router::new();
15433 assert!(!r.strict_input_validation());
15434 r.set_strict_input_validation(true);
15435 assert!(r.strict_input_validation());
15436 r.set_strict_input_validation(false);
15437 assert!(!r.strict_input_validation());
15438 }
15439
15440 #[test]
15443 fn handle_tools_call_cancelled_cx_returns_error() {
15444 let mut r = Router::new();
15445 r.add_tool(NamedTool::new("t"))
15446 .expect("tool registration succeeds");
15447 let cx = Cx::for_testing();
15448 cx.set_cancel_requested(true);
15449 let budget = Budget::INFINITE;
15450 let params = CallToolParams {
15451 name: "t".to_string(),
15452 arguments: None,
15453 meta: None,
15454 };
15455 let state = SessionState::new();
15456 let request_ctx = request_context(&cx, 1, budget, &state);
15457 let err = r
15458 .handle_tools_call(&request_ctx, params, state, None, None)
15459 .unwrap_err();
15460 assert_eq!(err.code, McpErrorCode::RequestCancelled);
15461 }
15462
15463 #[test]
15464 fn handle_resources_read_cancelled_cx_returns_error() {
15465 let mut r = Router::new();
15466 r.add_resource(NamedResource::new("file:///a.txt"));
15467 let cx = Cx::for_testing();
15468 cx.set_cancel_requested(true);
15469 let budget = Budget::INFINITE;
15470 let params = ReadResourceParams {
15471 uri: "file:///a.txt".to_string(),
15472 meta: None,
15473 };
15474 let state = SessionState::new();
15475 let request_ctx = request_context(&cx, 1, budget, &state);
15476 let err = r
15477 .handle_resources_read(&request_ctx, ¶ms, state, None, None)
15478 .unwrap_err();
15479 assert_eq!(err.code, McpErrorCode::RequestCancelled);
15480 }
15481
15482 #[test]
15483 fn handle_prompts_get_cancelled_cx_returns_error() {
15484 let mut r = Router::new();
15485 r.add_prompt(NamedPrompt::new("p"));
15486 let cx = Cx::for_testing();
15487 cx.set_cancel_requested(true);
15488 let budget = Budget::INFINITE;
15489 let params = GetPromptParams {
15490 name: "p".to_string(),
15491 arguments: None,
15492 meta: None,
15493 };
15494 let state = SessionState::new();
15495 let request_ctx = request_context(&cx, 1, budget, &state);
15496 let err = r
15497 .handle_prompts_get(&request_ctx, params, state, None, None)
15498 .unwrap_err();
15499 assert_eq!(err.code, McpErrorCode::RequestCancelled);
15500 }
15501
15502 #[test]
15503 fn completion_handler_dispatches_exact_legacy_and_final_contracts() {
15504 let mut router = Router::new();
15505 assert!(!router.has_completion_handler());
15506 router.add_completion_handler(EchoCompletion);
15507 router.add_prompt(PromptArgumentBoundary {
15508 final_calls: Arc::new(AtomicUsize::new(0)),
15509 legacy_calls: Arc::new(AtomicUsize::new(0)),
15510 });
15511 assert!(router.has_completion_handler());
15512 assert!(
15513 router
15514 .server_discovery_behavior_registry()
15515 .contains(ServerBehavior::CompletionComplete),
15516 "discovery advertises completion only after the handler is installed"
15517 );
15518
15519 let cx = Cx::for_testing();
15520 let state = SessionState::new();
15521 let request_ctx = request_context(&cx, 87, Budget::INFINITE, &state);
15522 let legacy_request = JsonRpcRequest::new(
15523 COMPLETION_COMPLETE,
15524 Some(serde_json::json!({
15525 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
15526 "argument": {"name": "topic", "value": "sta"},
15527 })),
15528 87_i64,
15529 );
15530 let legacy = router
15531 .dispatch_legacy_completion(&request_ctx, &legacy_request)
15532 .expect("the exact legacy request reaches the registered completion handler");
15533 assert!(
15534 legacy.get("resultType").is_none(),
15535 "the exact legacy completion result remains discriminator-free"
15536 );
15537 assert_eq!(
15538 legacy["completion"]["values"],
15539 serde_json::json!(["staging"])
15540 );
15541
15542 let modern = router
15543 .dispatch_stateless(
15544 &request_ctx,
15545 &JsonRpcRequest::new(
15546 COMPLETION_COMPLETE,
15547 Some(serde_json::json!({
15548 "_meta": {
15549 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
15550 "io.modelcontextprotocol/clientCapabilities": {},
15551 },
15552 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
15553 "argument": {"name": "topic", "value": "sta"},
15554 })),
15555 88_i64,
15556 ),
15557 )
15558 .expect("the final request reaches the same registered completion handler");
15559 assert_eq!(
15560 modern.get("resultType"),
15561 Some(&serde_json::json!("complete"))
15562 );
15563 assert_eq!(modern.get("completion"), legacy.get("completion"));
15564 }
15565
15566 #[test]
15567 fn final_completion_routes_to_the_registered_prompt_or_resource_provider() {
15568 let prompt_calls = Arc::new(AtomicUsize::new(0));
15569 let resource_calls = Arc::new(AtomicUsize::new(0));
15570 let fallback_calls = Arc::new(AtomicUsize::new(0));
15571 let mut router = Router::new();
15572 router.add_completion_handler(ProviderCompletion {
15573 value: "fallback-provider",
15574 final_calls: Arc::clone(&fallback_calls),
15575 });
15576 router.add_prompt(PromptArgumentBoundary {
15577 final_calls: Arc::new(AtomicUsize::new(0)),
15578 legacy_calls: Arc::new(AtomicUsize::new(0)),
15579 });
15580 router.add_resource_template(marked_template("resource://first/{id}", "first"));
15581 router.add_resource_template(marked_template("resource://second/{id}", "second"));
15582 router.add_prompt_completion_handler(
15583 "prompt-argument-boundary",
15584 ProviderCompletion {
15585 value: "prompt-provider",
15586 final_calls: Arc::clone(&prompt_calls),
15587 },
15588 );
15589 router.add_resource_template_completion_handler(
15590 "resource://first/{id}",
15591 ProviderCompletion {
15592 value: "resource-provider",
15593 final_calls: Arc::clone(&resource_calls),
15594 },
15595 );
15596
15597 assert!(
15598 router
15599 .server_discovery_behavior_registry()
15600 .contains(ServerBehavior::CompletionComplete),
15601 "a registered final completion provider enables discovery"
15602 );
15603
15604 let cx = Cx::for_testing();
15605 let state = SessionState::new();
15606 let request_ctx = request_context(&cx, 187, Budget::INFINITE, &state);
15607 let metadata = serde_json::json!({
15608 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
15609 "io.modelcontextprotocol/clientCapabilities": {},
15610 });
15611 let prompt_request = JsonRpcRequest::new(
15612 COMPLETION_COMPLETE,
15613 Some(serde_json::json!({
15614 "_meta": metadata.clone(),
15615 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
15616 "argument": {"name": "topic", "value": "pro"},
15617 })),
15618 187_i64,
15619 );
15620 let resource_request = JsonRpcRequest::new(
15621 COMPLETION_COMPLETE,
15622 Some(serde_json::json!({
15623 "_meta": metadata,
15624 "ref": {"type": "ref/resource", "uri": "resource://first/{id}"},
15625 "argument": {"name": "id", "value": "pro"},
15626 })),
15627 188_i64,
15628 );
15629
15630 let prompt = router
15631 .dispatch_stateless(&request_ctx, &prompt_request)
15632 .expect("the registered prompt provider handles its exact target");
15633 let resource = router
15634 .dispatch_stateless(&request_ctx, &resource_request)
15635 .expect("the registered resource provider handles its exact target");
15636 assert_eq!(
15637 prompt["completion"]["values"],
15638 serde_json::json!(["prompt-provider"])
15639 );
15640 assert_eq!(
15641 resource["completion"]["values"],
15642 serde_json::json!(["resource-provider"])
15643 );
15644 assert_eq!(prompt_calls.load(Ordering::SeqCst), 1);
15645 assert_eq!(resource_calls.load(Ordering::SeqCst), 1);
15646 assert_eq!(
15647 fallback_calls.load(Ordering::SeqCst),
15648 0,
15649 "a target-specific provider takes precedence over the installed fallback"
15650 );
15651
15652 let mut unregistered_provider = resource_request.clone();
15653 unregistered_provider
15654 .params
15655 .as_mut()
15656 .and_then(serde_json::Value::as_object_mut)
15657 .and_then(|params| params.get_mut("ref"))
15658 .and_then(serde_json::Value::as_object_mut)
15659 .expect("completion reference is an object")
15660 .insert(
15661 "uri".to_owned(),
15662 serde_json::json!("resource://second/{id}"),
15663 );
15664 assert_eq!(resource_request.method, unregistered_provider.method);
15665 assert_eq!(resource_request.id, unregistered_provider.id);
15666 assert_eq!(
15667 resource_request
15668 .params
15669 .as_ref()
15670 .and_then(|params| params.get("argument")),
15671 unregistered_provider
15672 .params
15673 .as_ref()
15674 .and_then(|params| params.get("argument")),
15675 "the referenced resource template is the sole planted dimension"
15676 );
15677 let fallback = router
15678 .dispatch_stateless(&request_ctx, &unregistered_provider)
15679 .expect("an admitted target without a provider-specific handler reaches the fallback");
15680 assert_eq!(
15681 fallback["completion"]["values"],
15682 serde_json::json!(["fallback-provider"])
15683 );
15684 assert_eq!(prompt_calls.load(Ordering::SeqCst), 1);
15685 assert_eq!(resource_calls.load(Ordering::SeqCst), 1);
15686 assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
15687 }
15688
15689 #[test]
15690 fn modern_connection_visibility_blocks_final_completion_before_provider_invocation() {
15691 let final_calls = Arc::new(AtomicUsize::new(0));
15692 let mut router = Router::new();
15693 router.add_completion_handler(CountingCompletion {
15694 final_calls: Arc::clone(&final_calls),
15695 });
15696 router.add_prompt(PromptArgumentBoundary {
15697 final_calls: Arc::new(AtomicUsize::new(0)),
15698 legacy_calls: Arc::new(AtomicUsize::new(0)),
15699 });
15700 const TEMPLATE_URI: &str = "resource://completion-visibility/{id}";
15701 router.add_resource_template(marked_template(TEMPLATE_URI, "completion-visibility"));
15702
15703 let prompt_catalog_before = serde_json::to_vec(&router.prompts())
15704 .expect("prompt catalog serializes before completion dispatch");
15705 let template_catalog_before = serde_json::to_vec(&router.resource_templates())
15706 .expect("resource-template catalog serializes before completion dispatch");
15707 let prompt_request = JsonRpcRequest::new(
15708 COMPLETION_COMPLETE,
15709 Some(serde_json::json!({
15710 "_meta": {
15711 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
15712 "io.modelcontextprotocol/clientCapabilities": {},
15713 },
15714 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
15715 "argument": {"name": "topic", "value": "sta"},
15716 })),
15717 1_871_i64,
15718 );
15719 let resource_request = JsonRpcRequest::new(
15720 COMPLETION_COMPLETE,
15721 Some(serde_json::json!({
15722 "_meta": {
15723 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
15724 "io.modelcontextprotocol/clientCapabilities": {},
15725 },
15726 "ref": {"type": "ref/resource", "uri": TEMPLATE_URI},
15727 "argument": {"name": "id", "value": "sta"},
15728 })),
15729 1_872_i64,
15730 );
15731 let prompt_request_bytes =
15732 serde_json::to_vec(&prompt_request).expect("prompt completion request serializes");
15733 let resource_request_bytes = serde_json::to_vec(&resource_request)
15734 .expect("resource-template completion request serializes");
15735
15736 let cx = Cx::for_testing();
15737 let allowed_connection = ModernConnection::new();
15738 let allowed_inbound = InboundRequestContext::with_modern_connection(
15739 cx.clone(),
15740 1_871,
15741 InboundRequestTransport::Stdio,
15742 &allowed_connection,
15743 );
15744 let allowed_context = allowed_inbound.request_context();
15745 for request in [&prompt_request, &resource_request] {
15746 let result = router
15747 .dispatch_stateless(&allowed_context, request)
15748 .expect("a visible completion target reaches the fallback provider");
15749 assert_eq!(
15750 result["completion"]["values"],
15751 serde_json::json!(["staging"])
15752 );
15753 }
15754 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
15755
15756 let denied_connection = ModernConnection::new();
15757 let denied_inbound = InboundRequestContext::with_modern_connection(
15758 cx,
15759 1_871,
15760 InboundRequestTransport::Stdio,
15761 &denied_connection,
15762 );
15763 let denied_context = denied_inbound.request_context();
15764 assert!(denied_context.disable_prompt("prompt-argument-boundary"));
15765 assert!(denied_context.disable_resource(TEMPLATE_URI));
15766
15767 assert_eq!(
15768 serde_json::to_vec(&prompt_request)
15769 .expect("prompt completion request remains serializable"),
15770 prompt_request_bytes,
15771 "connection visibility is the sole planted prompt-completion dimension"
15772 );
15773 let hidden_prompt = router
15774 .dispatch_stateless(&denied_context, &prompt_request)
15775 .expect_err("a hidden prompt cannot be used as a completion reference");
15776 assert_eq!(hidden_prompt.code, McpErrorCode::InvalidParams);
15777 assert_eq!(
15778 hidden_prompt.message,
15779 "completion prompt reference is not registered"
15780 );
15781
15782 assert_eq!(
15783 serde_json::to_vec(&resource_request)
15784 .expect("resource-template completion request remains serializable"),
15785 resource_request_bytes,
15786 "connection visibility is the sole planted resource-template-completion dimension"
15787 );
15788 let hidden_resource = router
15789 .dispatch_stateless(&denied_context, &resource_request)
15790 .expect_err("a hidden resource template cannot reach the fallback provider");
15791 assert_eq!(hidden_resource.code, McpErrorCode::InvalidParams);
15792 assert_eq!(
15793 hidden_resource.message,
15794 "completion resource reference is not registered"
15795 );
15796 assert_eq!(
15797 final_calls.load(Ordering::SeqCst),
15798 2,
15799 "refused completion references must not invoke the fallback provider"
15800 );
15801 assert_eq!(
15802 serde_json::to_vec(&router.prompts())
15803 .expect("prompt catalog serializes after completion refusal"),
15804 prompt_catalog_before,
15805 "completion refusal cannot mutate the prompt catalog"
15806 );
15807 assert_eq!(
15808 serde_json::to_vec(&router.resource_templates())
15809 .expect("resource-template catalog serializes after completion refusal"),
15810 template_catalog_before,
15811 "completion refusal cannot mutate the resource-template catalog"
15812 );
15813 }
15814
15815 #[test]
15816 fn explicit_legacy_completion_is_not_discovered_or_dispatched_as_final() {
15817 let mut router = Router::new();
15818 router.add_legacy_completion_handler(EchoCompletion);
15819 router.add_prompt(NamedPrompt::new("deploy"));
15820 let cx = Cx::for_testing();
15821 let state = SessionState::new();
15822 let request_ctx = request_context(&cx, 184, Budget::INFINITE, &state);
15823 let request = JsonRpcRequest::new(
15824 COMPLETION_COMPLETE,
15825 Some(serde_json::json!({
15826 "ref": {"type": "ref/prompt", "name": "deploy"},
15827 "argument": {"name": "environment", "value": "sta"},
15828 })),
15829 184_i64,
15830 );
15831 assert!(
15832 router
15833 .dispatch_legacy_completion(&request_ctx, &request)
15834 .is_ok()
15835 );
15836
15837 let mut final_request = request.clone();
15838 final_request
15839 .params
15840 .as_mut()
15841 .and_then(serde_json::Value::as_object_mut)
15842 .expect("completion parameters are an object")
15843 .insert(
15844 "_meta".to_owned(),
15845 serde_json::json!({
15846 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
15847 "io.modelcontextprotocol/clientCapabilities": {},
15848 }),
15849 );
15850 let error = router
15851 .dispatch_stateless(&request_ctx, &final_request)
15852 .expect_err("only the selected protocol era changes completion availability");
15853 assert_eq!(error.code, McpErrorCode::MethodNotFound);
15854 assert!(
15855 !router
15856 .server_discovery_behavior_registry()
15857 .contains(ServerBehavior::CompletionComplete)
15858 );
15859 }
15860
15861 #[test]
15862 fn final_completion_resource_template_reference_and_argument_are_validated_before_handler() {
15863 let final_calls = Arc::new(AtomicUsize::new(0));
15864 let mut router = Router::new();
15865 router.add_completion_handler(CountingCompletion {
15866 final_calls: Arc::clone(&final_calls),
15867 });
15868 router.add_resource(NamedResource::new("resource://static"));
15869 router.add_resource_template(marked_template("resource://{id}", "registered"));
15870 router
15871 .add_legacy_resource_template(marked_template("resource://{legacy_id}", "legacy-only"));
15872 let cx = Cx::for_testing();
15873 let state = SessionState::new();
15874 let request_ctx = request_context(&cx, 188, Budget::INFINITE, &state);
15875 let baseline = JsonRpcRequest::new(
15876 COMPLETION_COMPLETE,
15877 Some(serde_json::json!({
15878 "_meta": {
15879 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
15880 "io.modelcontextprotocol/clientCapabilities": {},
15881 },
15882 "ref": {"type": "ref/resource", "uri": "resource://{id}"},
15883 "argument": {"name": "id", "value": "sta"},
15884 })),
15885 188_i64,
15886 );
15887 let templates_before = serde_json::to_vec(&router.resource_templates())
15888 .expect("resource-template catalog serializes");
15889 let template_accepted = router
15890 .dispatch_stateless(&request_ctx, &baseline)
15891 .expect("a registered final resource-template reference is accepted");
15892 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
15893
15894 let mut static_reference = baseline.clone();
15895 static_reference
15896 .params
15897 .as_mut()
15898 .and_then(serde_json::Value::as_object_mut)
15899 .and_then(|params| params.get_mut("ref"))
15900 .and_then(serde_json::Value::as_object_mut)
15901 .expect("completion reference is an object")
15902 .insert("uri".to_owned(), serde_json::json!("resource://static"));
15903 let static_error = router
15904 .dispatch_stateless(&request_ctx, &static_reference)
15905 .expect_err("a static resource is not a final completion-template target");
15906 assert_eq!(static_error.code, McpErrorCode::InvalidParams);
15907 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
15908
15909 let mut legacy_template = baseline.clone();
15910 legacy_template
15911 .params
15912 .as_mut()
15913 .and_then(serde_json::Value::as_object_mut)
15914 .and_then(|params| params.get_mut("ref"))
15915 .and_then(serde_json::Value::as_object_mut)
15916 .expect("completion reference is an object")
15917 .insert(
15918 "uri".to_owned(),
15919 serde_json::json!("resource://{legacy_id}"),
15920 );
15921 assert_eq!(baseline.method, legacy_template.method);
15922 assert_eq!(baseline.id, legacy_template.id);
15923 assert_eq!(
15924 baseline
15925 .params
15926 .as_ref()
15927 .and_then(|params| params.get("argument")),
15928 legacy_template
15929 .params
15930 .as_ref()
15931 .and_then(|params| params.get("argument")),
15932 "the target URI is the sole planted visibility dimension"
15933 );
15934 let legacy_template_error = router
15935 .dispatch_stateless(&request_ctx, &legacy_template)
15936 .expect_err("an exact-2024-only template is not final-visible");
15937 assert_eq!(legacy_template_error.code, McpErrorCode::InvalidParams);
15938 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
15939
15940 let mut unknown_argument = baseline.clone();
15941 unknown_argument
15942 .params
15943 .as_mut()
15944 .and_then(serde_json::Value::as_object_mut)
15945 .and_then(|params| params.get_mut("argument"))
15946 .and_then(serde_json::Value::as_object_mut)
15947 .expect("completion argument is an object")
15948 .insert("name".to_owned(), serde_json::json!("unknown"));
15949 assert_eq!(baseline.method, unknown_argument.method);
15950 assert_eq!(baseline.id, unknown_argument.id);
15951 assert_eq!(
15952 baseline
15953 .params
15954 .as_ref()
15955 .and_then(|params| params.get("ref")),
15956 unknown_argument
15957 .params
15958 .as_ref()
15959 .and_then(|params| params.get("ref")),
15960 "the argument name is the sole planted validation dimension"
15961 );
15962 let unknown_argument_error = router
15963 .dispatch_stateless(&request_ctx, &unknown_argument)
15964 .expect_err("an undeclared template argument is rejected before the handler");
15965 assert_eq!(unknown_argument_error.code, McpErrorCode::InvalidParams);
15966 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
15967 assert_eq!(
15968 serde_json::to_vec(&router.resource_templates())
15969 .expect("resource-template catalog serializes after refusal"),
15970 templates_before,
15971 "completion reference refusal cannot mutate the registered catalog"
15972 );
15973 assert_eq!(
15974 router
15975 .dispatch_stateless(&request_ctx, &baseline)
15976 .expect("the registered reference remains accepted after refusal"),
15977 template_accepted,
15978 "rejected target or argument changes cannot alter the accepted completion"
15979 );
15980 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
15981 }
15982
15983 #[test]
15984 fn final_completion_prompt_reference_and_argument_are_validated_before_handler() {
15985 let final_calls = Arc::new(AtomicUsize::new(0));
15986 let mut router = Router::new();
15987 router.add_completion_handler(CountingCompletion {
15988 final_calls: Arc::clone(&final_calls),
15989 });
15990 router.add_prompt(PromptArgumentBoundary {
15991 final_calls: Arc::new(AtomicUsize::new(0)),
15992 legacy_calls: Arc::new(AtomicUsize::new(0)),
15993 });
15994 router.add_legacy_prompt(NamedPrompt::new("legacy-completion-prompt"));
15995 let cx = Cx::for_testing();
15996 let state = SessionState::new();
15997 let request_ctx = request_context(&cx, 189, Budget::INFINITE, &state);
15998 let baseline = JsonRpcRequest::new(
15999 COMPLETION_COMPLETE,
16000 Some(serde_json::json!({
16001 "_meta": {
16002 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
16003 "io.modelcontextprotocol/clientCapabilities": {},
16004 },
16005 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
16006 "argument": {"name": "topic", "value": "sta"},
16007 })),
16008 189_i64,
16009 );
16010 let accepted = router
16011 .dispatch_stateless(&request_ctx, &baseline)
16012 .expect("a final-visible prompt and its declared argument are accepted");
16013 assert_eq!(accepted["resultType"], "complete");
16014 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16015
16016 let mut legacy_prompt = baseline.clone();
16017 legacy_prompt
16018 .params
16019 .as_mut()
16020 .and_then(serde_json::Value::as_object_mut)
16021 .and_then(|params| params.get_mut("ref"))
16022 .and_then(serde_json::Value::as_object_mut)
16023 .expect("completion reference is an object")
16024 .insert(
16025 "name".to_owned(),
16026 serde_json::json!("legacy-completion-prompt"),
16027 );
16028 assert_eq!(baseline.method, legacy_prompt.method);
16029 assert_eq!(baseline.id, legacy_prompt.id);
16030 assert_eq!(
16031 baseline
16032 .params
16033 .as_ref()
16034 .and_then(|params| params.get("argument")),
16035 legacy_prompt
16036 .params
16037 .as_ref()
16038 .and_then(|params| params.get("argument")),
16039 "the prompt name is the sole planted visibility dimension"
16040 );
16041 let legacy_prompt_error = router
16042 .dispatch_stateless(&request_ctx, &legacy_prompt)
16043 .expect_err("an exact-2024-only prompt is not final-visible");
16044 assert_eq!(legacy_prompt_error.code, McpErrorCode::InvalidParams);
16045 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16046
16047 let mut unknown_argument = baseline.clone();
16048 unknown_argument
16049 .params
16050 .as_mut()
16051 .and_then(serde_json::Value::as_object_mut)
16052 .and_then(|params| params.get_mut("argument"))
16053 .and_then(serde_json::Value::as_object_mut)
16054 .expect("completion argument is an object")
16055 .insert("name".to_owned(), serde_json::json!("unknown"));
16056 assert_eq!(baseline.method, unknown_argument.method);
16057 assert_eq!(baseline.id, unknown_argument.id);
16058 assert_eq!(
16059 baseline
16060 .params
16061 .as_ref()
16062 .and_then(|params| params.get("ref")),
16063 unknown_argument
16064 .params
16065 .as_ref()
16066 .and_then(|params| params.get("ref")),
16067 "the argument name is the sole planted validation dimension"
16068 );
16069 let unknown_argument_error = router
16070 .dispatch_stateless(&request_ctx, &unknown_argument)
16071 .expect_err("an undeclared prompt argument is rejected before the handler");
16072 assert_eq!(unknown_argument_error.code, McpErrorCode::InvalidParams);
16073 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16074
16075 let legacy = router
16076 .dispatch_legacy_completion(
16077 &request_ctx,
16078 &JsonRpcRequest::new(
16079 COMPLETION_COMPLETE,
16080 Some(serde_json::json!({
16081 "ref": {"type": "ref/prompt", "name": "legacy-completion-prompt"},
16082 "argument": {"name": "unknown", "value": "sta"},
16083 })),
16084 190_i64,
16085 ),
16086 )
16087 .expect("exact-2024 completion retains its unvalidated target argument behavior");
16088 assert!(legacy.get("resultType").is_none());
16089 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16090 }
16091
16092 #[test]
16093 fn final_completion_rejects_invalid_local_handler_results() {
16094 let final_calls = Arc::new(AtomicUsize::new(0));
16095 let mut router = Router::new();
16096 router.add_completion_handler(CompletionValueBoundary {
16097 final_calls: Arc::clone(&final_calls),
16098 });
16099 router.add_prompt(PromptArgumentBoundary {
16100 final_calls: Arc::new(AtomicUsize::new(0)),
16101 legacy_calls: Arc::new(AtomicUsize::new(0)),
16102 });
16103 let cx = Cx::for_testing();
16104 let state = SessionState::new();
16105 let request_ctx = request_context(&cx, 191, Budget::INFINITE, &state);
16106 let baseline = JsonRpcRequest::new(
16107 COMPLETION_COMPLETE,
16108 Some(serde_json::json!({
16109 "_meta": {
16110 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
16111 "io.modelcontextprotocol/clientCapabilities": {},
16112 },
16113 "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
16114 "argument": {"name": "topic", "value": "at-bound"},
16115 })),
16116 191_i64,
16117 );
16118 let accepted = router
16119 .dispatch_stateless(&request_ctx, &baseline)
16120 .expect("a local handler result at the 100-value limit is accepted");
16121 assert_eq!(
16122 accepted["completion"]["values"].as_array().map(Vec::len),
16123 Some(fastmcp_protocol::MAX_COMPLETION_VALUES)
16124 );
16125 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16126
16127 let mut one_over = baseline.clone();
16128 one_over
16129 .params
16130 .as_mut()
16131 .and_then(serde_json::Value::as_object_mut)
16132 .and_then(|params| params.get_mut("argument"))
16133 .and_then(serde_json::Value::as_object_mut)
16134 .expect("completion argument is an object")
16135 .insert("value".to_owned(), serde_json::json!("one-over"));
16136 assert_eq!(baseline.method, one_over.method);
16137 assert_eq!(baseline.id, one_over.id);
16138 assert_eq!(
16139 baseline
16140 .params
16141 .as_ref()
16142 .and_then(|params| params.get("ref")),
16143 one_over
16144 .params
16145 .as_ref()
16146 .and_then(|params| params.get("ref")),
16147 "the handler result boundary is the sole planted dimension"
16148 );
16149 let error = router
16150 .dispatch_stateless(&request_ctx, &one_over)
16151 .expect_err("a local handler cannot return a 101st completion value");
16152 assert_eq!(error.code, McpErrorCode::InternalError);
16153 assert_eq!(
16154 error.message,
16155 "completion handler returned more than 100 values"
16156 );
16157 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
16158
16159 let mut negative_total = baseline;
16160 negative_total
16161 .params
16162 .as_mut()
16163 .and_then(serde_json::Value::as_object_mut)
16164 .and_then(|params| params.get_mut("argument"))
16165 .and_then(serde_json::Value::as_object_mut)
16166 .expect("completion argument is an object")
16167 .insert("value".to_owned(), serde_json::json!("negative-total"));
16168 let error = router
16169 .dispatch_stateless(&request_ctx, &negative_total)
16170 .expect_err("a local handler cannot return a negative final completion total");
16171 assert_eq!(error.code, McpErrorCode::InternalError);
16172 assert_eq!(
16173 error.message,
16174 "final completion total must be a nonnegative JSON integer"
16175 );
16176 assert_eq!(final_calls.load(Ordering::SeqCst), 3);
16177 }
16178
16179 #[test]
16180 fn resource_template_registration_is_final_visible_via_protocol_rfc6570_matcher() {
16181 struct AmbiguousTemplateResource;
16182
16183 impl ResourceHandler for AmbiguousTemplateResource {
16184 fn definition(&self) -> Resource {
16185 Resource {
16186 uri: "mcp://resource/ambiguous".to_owned(),
16187 name: "ambiguous-template".to_owned(),
16188 description: None,
16189 mime_type: None,
16190 icon: None,
16191 version: None,
16192 tags: Vec::new(),
16193 }
16194 }
16195
16196 fn template(&self) -> Option<ResourceTemplate> {
16197 Some(marked_template(
16198 "mcp://resource/{first}{second}",
16199 "ambiguous",
16200 ))
16201 }
16202
16203 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
16204 Ok(Vec::new())
16205 }
16206 }
16207
16208 let mut router = Router::new();
16209 let read_calls = Arc::new(AtomicUsize::new(0));
16210 let completion_calls = Arc::new(AtomicUsize::new(0));
16211 router.add_completion_handler(CountingCompletion {
16212 final_calls: Arc::clone(&completion_calls),
16213 });
16214 router
16215 .add_resource_with_behavior(
16216 ReversibleLevelFourTemplateResource {
16217 read_calls: Arc::clone(&read_calls),
16218 },
16219 crate::DuplicateBehavior::Replace,
16220 )
16221 .expect("a reversible level-four template is admitted");
16222 let cx = Cx::for_testing();
16223 let state = SessionState::new();
16224 let request_ctx = request_context(&cx, 189, Budget::INFINITE, &state);
16225 let legacy_error = router
16226 .handle_resources_read(
16227 &request_ctx,
16228 &fastmcp_protocol::ReadResourceParams {
16229 uri: "mcp://resource/books/manifest?revision=stable".to_owned(),
16230 meta: None,
16231 },
16232 state,
16233 None,
16234 None,
16235 )
16236 .expect_err("scalar explode remains unavailable to exact-2024 routing");
16237 assert_eq!(legacy_error.code, McpErrorCode::ResourceNotFound);
16238 assert_eq!(read_calls.load(Ordering::SeqCst), 0);
16239
16240 let final_metadata = serde_json::json!({
16241 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
16242 "io.modelcontextprotocol/clientCapabilities": {},
16243 });
16244 let templates = router
16245 .dispatch_stateless(
16246 &request_ctx,
16247 &JsonRpcRequest::new(
16248 "resources/templates/list",
16249 Some(serde_json::json!({"_meta": final_metadata.clone()})),
16250 189_i64,
16251 ),
16252 )
16253 .expect("the admitted template is final-visible");
16254 assert_eq!(templates["resultType"], "complete");
16255 assert_eq!(
16256 templates["resourceTemplates"][0]["uriTemplate"],
16257 "mcp://resource{/collection*}/manifest{?revision*}"
16258 );
16259
16260 let final_read = router
16261 .dispatch_stateless(
16262 &request_ctx,
16263 &JsonRpcRequest::new(
16264 "resources/read",
16265 Some(serde_json::json!({
16266 "_meta": final_metadata.clone(),
16267 "uri": "mcp://resource/books%2Ffiction/manifest?revision=stable",
16268 })),
16269 189_i64,
16270 ),
16271 )
16272 .expect("the final route uses the same reversible matcher");
16273 assert_eq!(final_read["resultType"], "complete");
16274 assert_eq!(
16275 final_read["contents"][0]["text"], "books/fiction:stable",
16276 "the final route decodes the scalar capture exactly once"
16277 );
16278 assert_eq!(read_calls.load(Ordering::SeqCst), 1);
16279
16280 let completion = router
16281 .dispatch_stateless(
16282 &request_ctx,
16283 &JsonRpcRequest::new(
16284 COMPLETION_COMPLETE,
16285 Some(serde_json::json!({
16286 "_meta": final_metadata,
16287 "ref": {
16288 "type": "ref/resource",
16289 "uri": "mcp://resource{/collection*}/manifest{?revision*}",
16290 },
16291 "argument": {"name": "revision", "value": "sta"},
16292 })),
16293 189_i64,
16294 ),
16295 )
16296 .expect("the final completion target exposes protocol-derived variables");
16297 assert_eq!(completion["resultType"], "complete");
16298 assert_eq!(
16299 completion["completion"]["values"],
16300 serde_json::json!(["staging"])
16301 );
16302 assert_eq!(completion_calls.load(Ordering::SeqCst), 1);
16303
16304 let template_count = router.resource_templates_count();
16305 let error = router
16306 .add_resource_with_behavior(
16307 AmbiguousTemplateResource,
16308 crate::DuplicateBehavior::Replace,
16309 )
16310 .expect_err("changing only the adjacent scalar boundary remains ambiguous");
16311 assert_eq!(error.code, McpErrorCode::InvalidParams);
16312 assert_eq!(
16313 router.resource_templates_count(),
16314 template_count,
16315 "rejected template admission cannot mutate the registered catalog"
16316 );
16317 }
16318
16319 #[test]
16320 fn final_resource_registration_rejects_template_language_collisions_atomically() {
16321 let mut template_router = Router::new();
16322 template_router
16323 .add_resource_template_with_behavior(
16324 marked_template("mcp://resource/{first}", "first"),
16325 crate::DuplicateBehavior::Replace,
16326 )
16327 .expect("the first reversible template is admitted");
16328 let templates_before = serde_json::to_vec(&template_router.resource_templates())
16329 .expect("the admitted template catalog serializes");
16330
16331 let error = template_router
16332 .add_resource_template_with_behavior(
16333 marked_template("mcp://resource/{second}", "second"),
16334 crate::DuplicateBehavior::Replace,
16335 )
16336 .expect_err("renaming only the capture must not create a tie-broken route");
16337 assert_eq!(error.code, McpErrorCode::InvalidParams);
16338 assert_eq!(
16339 serde_json::to_vec(&template_router.resource_templates())
16340 .expect("a rejected collision leaves the template catalog serializable"),
16341 templates_before,
16342 "template-template collision rejection leaves the catalog unchanged"
16343 );
16344
16345 let mut exact_router = Router::new();
16346 exact_router
16347 .add_resource_with_behavior(
16348 NamedResource::new("mcp://resource/books"),
16349 crate::DuplicateBehavior::Replace,
16350 )
16351 .expect("the exact final resource is admitted");
16352 let exact_count = exact_router.resources_count();
16353 let error = exact_router
16354 .add_resource_template_with_behavior(
16355 marked_template("mcp://resource/{id}", "template"),
16356 crate::DuplicateBehavior::Replace,
16357 )
16358 .expect_err("a template cannot shadow an exact final resource");
16359 assert_eq!(error.code, McpErrorCode::InvalidParams);
16360 assert_eq!(exact_router.resources_count(), exact_count);
16361 assert_eq!(exact_router.resource_templates_count(), 0);
16362
16363 let mut reverse_router = Router::new();
16364 reverse_router
16365 .add_resource_template_with_behavior(
16366 marked_template("mcp://resource/{id}", "template"),
16367 crate::DuplicateBehavior::Replace,
16368 )
16369 .expect("the reversible template is admitted before the planted exact collision");
16370 let templates_before = serde_json::to_vec(&reverse_router.resource_templates())
16371 .expect("the admitted template catalog serializes");
16372 let error = reverse_router
16373 .add_resource_with_behavior(
16374 NamedResource::new("mcp://resource/books"),
16375 crate::DuplicateBehavior::Replace,
16376 )
16377 .expect_err("an exact final resource cannot be hidden behind a template");
16378 assert_eq!(error.code, McpErrorCode::InvalidParams);
16379 assert_eq!(reverse_router.resources_count(), 0);
16380 assert_eq!(
16381 serde_json::to_vec(&reverse_router.resource_templates())
16382 .expect("the template catalog remains serializable after refusal"),
16383 templates_before,
16384 "exact-resource collision rejection leaves the existing template unchanged"
16385 );
16386
16387 let mut disjoint_router = Router::new();
16388 disjoint_router
16389 .add_resource_template_with_behavior(
16390 marked_template("mcp://alpha/{id}", "alpha"),
16391 crate::DuplicateBehavior::Replace,
16392 )
16393 .expect("a disjoint template is admitted");
16394 disjoint_router
16395 .add_resource_template_with_behavior(
16396 marked_template("mcp://beta/{id}", "beta"),
16397 crate::DuplicateBehavior::Replace,
16398 )
16399 .expect("a template with a conflicting literal prefix remains independent");
16400 assert_eq!(disjoint_router.resource_templates_count(), 2);
16401 }
16402
16403 #[test]
16404 fn final_resource_mount_rejects_cross_router_language_collisions_atomically() {
16405 let exact_uri = "mcp://resource/books";
16406
16407 let mut exact_destination = Router::new();
16408 exact_destination
16409 .add_resource_with_behavior(
16410 NamedResource::new(exact_uri),
16411 crate::DuplicateBehavior::Replace,
16412 )
16413 .expect("the exact final resource is admitted before mounting");
16414 let exact_before = serde_json::to_vec(&exact_destination.resources())
16415 .expect("the exact destination catalog serializes");
16416 let mut template_source = Router::new();
16417 template_source
16418 .add_resource_template_with_behavior(
16419 marked_template("mcp://resource/{id}", "mounted-template"),
16420 crate::DuplicateBehavior::Replace,
16421 )
16422 .expect("the independently admitted source template is valid");
16423
16424 let result = exact_destination.mount_resources(template_source, None);
16425 assert!(!result.is_success());
16426 assert_eq!(
16427 serde_json::to_vec(&exact_destination.resources())
16428 .expect("a rejected mount preserves the destination catalog"),
16429 exact_before,
16430 "template-after-exact mount rejection leaves every destination resource unchanged"
16431 );
16432 assert_eq!(exact_destination.resource_templates_count(), 0);
16433
16434 let mut template_destination = Router::new();
16435 template_destination
16436 .add_resource_template_with_behavior(
16437 marked_template("mcp://resource/{id}", "destination-template"),
16438 crate::DuplicateBehavior::Replace,
16439 )
16440 .expect("the final template is admitted before mounting");
16441 let templates_before = serde_json::to_vec(&template_destination.resource_templates())
16442 .expect("the template destination catalog serializes");
16443 let mut exact_source = Router::new();
16444 exact_source
16445 .add_resource_with_behavior(
16446 NamedResource::new(exact_uri),
16447 crate::DuplicateBehavior::Replace,
16448 )
16449 .expect("the independently admitted source resource is valid");
16450
16451 let result = template_destination.mount_resources(exact_source, None);
16452 assert!(!result.is_success());
16453 assert_eq!(template_destination.resources_count(), 0);
16454 assert_eq!(
16455 serde_json::to_vec(&template_destination.resource_templates())
16456 .expect("a rejected mount preserves the destination template catalog"),
16457 templates_before,
16458 "exact-after-template mount rejection leaves every destination template unchanged"
16459 );
16460
16461 let mut first_template_destination = Router::new();
16462 first_template_destination
16463 .add_resource_template_with_behavior(
16464 marked_template("mcp://resource/{first}", "first-template"),
16465 crate::DuplicateBehavior::Replace,
16466 )
16467 .expect("the first final template is admitted");
16468 let templates_before = serde_json::to_vec(&first_template_destination.resource_templates())
16469 .expect("the first template destination catalog serializes");
16470 let mut second_template_source = Router::new();
16471 second_template_source
16472 .add_resource_template_with_behavior(
16473 marked_template("mcp://resource/{second}", "second-template"),
16474 crate::DuplicateBehavior::Replace,
16475 )
16476 .expect("the independently admitted second template is valid");
16477
16478 let result = first_template_destination.mount_resources(second_template_source, None);
16479 assert!(!result.is_success());
16480 assert_eq!(
16481 serde_json::to_vec(&first_template_destination.resource_templates())
16482 .expect("a rejected mount preserves the original template catalog"),
16483 templates_before,
16484 "template-language overlap cannot make mount order a dispatch authority"
16485 );
16486 }
16487
16488 #[test]
16489 fn empty_prefix_mount_matches_unprefixed_final_route_projection() {
16490 const EXACT_URI: &str = "mcp://resource/books";
16491
16492 for prefix in [None, Some("")] {
16493 let mut destination = Router::new();
16494 destination
16495 .add_resource_with_behavior(
16496 NamedResource::new(EXACT_URI),
16497 crate::DuplicateBehavior::Replace,
16498 )
16499 .expect("the destination exact final resource is admitted");
16500 let resources_before = serde_json::to_vec(&destination.resources())
16501 .expect("the destination catalog serializes before the mount");
16502 let mut source = Router::new();
16503 source
16504 .add_resource_template_with_behavior(
16505 marked_template("mcp://resource/{id}", "one-variable-template"),
16506 crate::DuplicateBehavior::Replace,
16507 )
16508 .expect("the source one-variable final template is admitted");
16509
16510 let result = destination.mount_resources(source, prefix);
16511
16512 assert!(
16513 !result.is_success(),
16514 "an unchanged-key mount must reject the final route collision for {prefix:?}"
16515 );
16516 assert!(
16517 result
16518 .errors
16519 .iter()
16520 .any(|error| error.contains("collides with an exact final resource")),
16521 "the rejection must retain the exact/template collision reason for {prefix:?}"
16522 );
16523 assert_eq!(
16524 serde_json::to_vec(&destination.resources())
16525 .expect("the rejected mount leaves the destination catalog serializable"),
16526 resources_before,
16527 "the one-variable collision leaves the destination unchanged for {prefix:?}"
16528 );
16529 assert_eq!(destination.resource_templates_count(), 0);
16530 assert!(destination.final_resources.contains_key(EXACT_URI));
16531
16532 let mut template_destination = Router::new();
16533 template_destination
16534 .add_resource_template_with_behavior(
16535 marked_template("mcp://resource/{id}", "one-variable-destination-template"),
16536 crate::DuplicateBehavior::Replace,
16537 )
16538 .expect("the destination one-variable final template is admitted");
16539 let templates_before = serde_json::to_vec(&template_destination.resource_templates())
16540 .expect("the destination template catalog serializes before the mount");
16541 let mut exact_source = Router::new();
16542 exact_source
16543 .add_resource_with_behavior(
16544 NamedResource::new(EXACT_URI),
16545 crate::DuplicateBehavior::Replace,
16546 )
16547 .expect("the source exact final resource is admitted");
16548
16549 let result = template_destination.mount_resources(exact_source, prefix);
16550
16551 assert!(
16552 !result.is_success(),
16553 "the reverse unchanged-key collision must reject for {prefix:?}"
16554 );
16555 assert!(
16556 result
16557 .errors
16558 .iter()
16559 .any(|error| error.contains("collides with an exact final resource")),
16560 "the reverse rejection must retain the exact/template collision reason for {prefix:?}"
16561 );
16562 assert_eq!(
16563 serde_json::to_vec(&template_destination.resource_templates())
16564 .expect("the rejected reverse mount leaves the template catalog serializable"),
16565 templates_before,
16566 "the reverse one-variable collision leaves the destination unchanged for {prefix:?}"
16567 );
16568 assert_eq!(template_destination.resources_count(), 0);
16569 assert!(
16570 template_destination.resource_templates["mcp://resource/{id}"]
16571 .final_definition
16572 .is_some()
16573 );
16574 }
16575
16576 for prefix in [None, Some("")] {
16577 let mut destination = Router::new();
16578 destination
16579 .add_resource_template_with_behavior(
16580 marked_template("mcp://alpha/{id}", "destination-disjoint"),
16581 crate::DuplicateBehavior::Replace,
16582 )
16583 .expect("the destination disjoint final template is admitted");
16584 let mut source = Router::new();
16585 source
16586 .add_resource_template_with_behavior(
16587 marked_template("mcp://beta/{id}", "source-disjoint"),
16588 crate::DuplicateBehavior::Replace,
16589 )
16590 .expect("the source disjoint final template is admitted");
16591
16592 let result = destination.mount_resources(source, prefix);
16593
16594 assert!(
16595 result.is_success(),
16596 "a disjoint unchanged-key mount remains admissible for {prefix:?}"
16597 );
16598 assert_eq!(destination.resource_templates_count(), 2);
16599 assert!(
16600 destination.resource_templates["mcp://beta/{id}"]
16601 .final_definition
16602 .is_some(),
16603 "an empty prefix cannot downgrade a disjoint final template for {prefix:?}"
16604 );
16605 }
16606
16607 for prefix in [None, Some("")] {
16608 let mut destination = Router::new();
16609 destination
16610 .add_final_resource_with_behavior(
16611 NamedResource::new(EXACT_URI),
16612 crate::DuplicateBehavior::Replace,
16613 )
16614 .expect("the destination final-only exact resource is admitted");
16615 let mut source = Router::new();
16616 source
16617 .add_legacy_resource_template_with_behavior(
16618 marked_template("mcp://resource/{id}", "legacy-template"),
16619 crate::DuplicateBehavior::Replace,
16620 )
16621 .expect("the source legacy-only template is admitted");
16622
16623 let result = destination.mount_resources(source, prefix);
16624
16625 assert!(
16626 result.is_success(),
16627 "a legacy-only template remains isolated for {prefix:?}"
16628 );
16629 assert!(destination.final_resources.contains_key(EXACT_URI));
16630 assert!(
16631 destination.resource_templates["mcp://resource/{id}"]
16632 .final_definition
16633 .is_none(),
16634 "legacy-only template mounting cannot enter final routing for {prefix:?}"
16635 );
16636 }
16637 }
16638
16639 #[test]
16640 fn legacy_only_template_mount_remains_final_isolated() {
16641 let exact_uri = "mcp://resource/books";
16642 let mut destination = Router::new();
16643 destination
16644 .add_final_resource_with_behavior(
16645 NamedResource::new(exact_uri),
16646 crate::DuplicateBehavior::Replace,
16647 )
16648 .expect("the final-only exact resource is admitted");
16649 let mut legacy_source = Router::new();
16650 legacy_source
16651 .add_legacy_resource_template_with_behavior(
16652 marked_template("mcp://resource/{id}", "legacy-template"),
16653 crate::DuplicateBehavior::Replace,
16654 )
16655 .expect("the frozen exact-2024 template is admitted in its own catalog");
16656
16657 let result = destination.mount_resources(legacy_source, None);
16658 assert!(result.is_success());
16659 assert_eq!(destination.resources_count(), 1);
16660 assert_eq!(destination.resource_templates_count(), 1);
16661 assert!(destination.final_resources.contains_key(exact_uri));
16662 assert!(
16663 destination.resource_templates["mcp://resource/{id}"]
16664 .final_definition
16665 .is_none(),
16666 "a legacy-only template remains absent from final dispatch after mounting"
16667 );
16668 }
16669
16670 #[test]
16671 fn legacy_resource_template_is_inert_on_final_list_read_and_completion() {
16672 let mut router = Router::new();
16673 let read_calls = Arc::new(AtomicUsize::new(0));
16674 let completion_calls = Arc::new(AtomicUsize::new(0));
16675 router.add_completion_handler(CountingCompletion {
16676 final_calls: Arc::clone(&completion_calls),
16677 });
16678 router
16679 .add_legacy_resource_with_behavior(
16680 LegacyTemplateResource {
16681 read_calls: Arc::clone(&read_calls),
16682 },
16683 crate::DuplicateBehavior::Replace,
16684 )
16685 .expect("the exact-2024 template is admitted for its own route");
16686 let cx = Cx::for_testing();
16687 let state = SessionState::new();
16688 let request_ctx = request_context(&cx, 190, Budget::INFINITE, &state);
16689 let uri = "mcp://resource/books/manifest?revision=stable";
16690 let legacy_read = router
16691 .handle_resources_read(
16692 &request_ctx,
16693 &ReadResourceParams {
16694 uri: uri.to_owned(),
16695 meta: None,
16696 },
16697 state,
16698 None,
16699 None,
16700 )
16701 .expect("the exact legacy route retains the registered template");
16702 let legacy_wire =
16703 serde_json::to_value(legacy_read).expect("legacy resource result serializes");
16704 assert_eq!(legacy_wire["contents"][0]["text"], "books:stable");
16705 assert!(legacy_wire.get("resultType").is_none());
16706 assert_eq!(read_calls.load(Ordering::SeqCst), 1);
16707
16708 let final_metadata = serde_json::json!({
16709 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
16710 "io.modelcontextprotocol/clientCapabilities": {},
16711 });
16712 let templates = router
16713 .dispatch_stateless(
16714 &request_ctx,
16715 &JsonRpcRequest::new(
16716 "resources/templates/list",
16717 Some(serde_json::json!({"_meta": final_metadata.clone()})),
16718 190_i64,
16719 ),
16720 )
16721 .expect("final template discovery remains valid with only exact-2024 templates");
16722 assert_eq!(templates["resultType"], "complete");
16723 assert_eq!(templates["resourceTemplates"], serde_json::json!([]));
16724
16725 let final_read = router
16726 .dispatch_stateless(
16727 &request_ctx,
16728 &JsonRpcRequest::new(
16729 "resources/read",
16730 Some(serde_json::json!({
16731 "_meta": final_metadata.clone(),
16732 "uri": uri,
16733 })),
16734 190_i64,
16735 ),
16736 )
16737 .expect_err("changing only the dispatch era cannot invoke a legacy-only template");
16738 assert_eq!(final_read.code, McpErrorCode::InvalidParams);
16739 assert_eq!(read_calls.load(Ordering::SeqCst), 1);
16740
16741 let completion = router
16742 .dispatch_stateless(
16743 &request_ctx,
16744 &JsonRpcRequest::new(
16745 COMPLETION_COMPLETE,
16746 Some(serde_json::json!({
16747 "_meta": final_metadata,
16748 "ref": {
16749 "type": "ref/resource",
16750 "uri": "mcp://resource/{collection}/manifest?revision={revision}",
16751 },
16752 "argument": {"name": "revision", "value": "sta"},
16753 })),
16754 190_i64,
16755 ),
16756 )
16757 .expect_err("a legacy-only template is not a final completion target");
16758 assert_eq!(completion.code, McpErrorCode::InvalidParams);
16759 assert_eq!(completion_calls.load(Ordering::SeqCst), 0);
16760 }
16761
16762 #[test]
16763 fn exact_2024_resource_template_admission_rejects_final_only_star_and_multi_variable_forms() {
16764 let mut legacy_router = Router::new();
16765 legacy_router
16766 .add_legacy_resource_with_behavior(
16767 LegacyTemplateResource {
16768 read_calls: Arc::new(AtomicUsize::new(0)),
16769 },
16770 crate::DuplicateBehavior::Replace,
16771 )
16772 .expect("the frozen {name} legacy grammar remains admitted");
16773 let catalog_before = serde_json::to_vec(&legacy_router.resource_templates())
16774 .expect("the admitted legacy catalog serializes");
16775
16776 let star_error = legacy_router
16777 .add_legacy_resource_with_behavior(
16778 ReversibleLevelFourTemplateResource {
16779 read_calls: Arc::new(AtomicUsize::new(0)),
16780 },
16781 crate::DuplicateBehavior::Replace,
16782 )
16783 .expect_err("changing only a legacy variable to scalar explode is final-only");
16784 assert_eq!(star_error.code, McpErrorCode::InvalidParams);
16785 assert_eq!(
16786 serde_json::to_vec(&legacy_router.resource_templates())
16787 .expect("rejected star admission leaves the catalog serializable"),
16788 catalog_before,
16789 "legacy rejection cannot mutate the existing catalog"
16790 );
16791
16792 let multi_variable_error = legacy_router
16793 .add_legacy_resource_template_with_behavior(
16794 marked_template("mcp://resource{?collection*,revision*}", "final-only-multi"),
16795 crate::DuplicateBehavior::Replace,
16796 )
16797 .expect_err("changing only to a named multi-variable expression is final-only");
16798 assert_eq!(multi_variable_error.code, McpErrorCode::InvalidParams);
16799 assert_eq!(
16800 serde_json::to_vec(&legacy_router.resource_templates())
16801 .expect("rejected multi-variable admission leaves the catalog serializable"),
16802 catalog_before,
16803 "the near-identical final syntax cannot alter an exact-2024 catalog"
16804 );
16805
16806 let mut final_router = Router::new();
16807 final_router
16808 .add_final_resource_with_behavior(
16809 ReversibleLevelFourTemplateResource {
16810 read_calls: Arc::new(AtomicUsize::new(0)),
16811 },
16812 crate::DuplicateBehavior::Replace,
16813 )
16814 .expect("the scalar-explode form remains admitted on the final route");
16815 final_router
16816 .add_resource_template_with_behavior(
16817 marked_template("mcp://resource{?collection*,revision*}", "final-multi"),
16818 crate::DuplicateBehavior::Replace,
16819 )
16820 .expect("the named multi-variable form remains admitted on the final route");
16821 assert_eq!(final_router.resource_templates_count(), 2);
16822 }
16823
16824 #[test]
16825 fn frozen_exact_2024_matcher_preserves_plus_capture_and_percent_decoding() {
16826 let matcher = admit_legacy_resource_template("legacy://resource/{+path}")
16827 .expect("the frozen legacy grammar retains {+name}");
16828 let params = matcher
16829 .matches("legacy://resource/books%2Ffiction")
16830 .expect("legacy plus capture matches a percent-encoded path");
16831 assert_eq!(
16832 params.get("path").map(String::as_str),
16833 Some("books/fiction")
16834 );
16835
16836 assert!(
16837 admit_legacy_resource_template("legacy://resource/{+path*}").is_err(),
16838 "changing only the scalar modifier keeps the new syntax final-only"
16839 );
16840 }
16841
16842 #[test]
16843 fn resource_template_admission_rejects_bare_literal_percent_without_catalog_mutation() {
16844 let accepted = marked_template("mcp://percent/reports%2Fdaily", "percent-template");
16845 let rejected = marked_template("mcp://percent/reports%Qdaily", "percent-template");
16846 let mut router = Router::new();
16847
16848 router
16849 .add_resource_template_with_behavior(accepted, crate::DuplicateBehavior::Replace)
16850 .expect("a complete literal percent triplet is admitted");
16851 let catalog_before = serde_json::to_vec(&router.resource_templates())
16852 .expect("accepted resource-template catalog serializes");
16853
16854 let error = router
16855 .add_resource_template_with_behavior(rejected, crate::DuplicateBehavior::Replace)
16856 .expect_err("changing only the percent triplet to a bare percent is refused");
16857 assert_eq!(error.code, McpErrorCode::InvalidParams);
16858 assert_eq!(
16859 serde_json::to_vec(&router.resource_templates())
16860 .expect("rejected admission leaves the catalog serializable"),
16861 catalog_before,
16862 "rejected literal syntax cannot rewrite the advertised template"
16863 );
16864 }
16865
16866 #[test]
16867 fn resource_resolution_skips_cross_era_static_shadows_for_matching_templates() {
16868 struct ShadowTemplate {
16869 label: &'static str,
16870 }
16871
16872 impl ResourceHandler for ShadowTemplate {
16873 fn definition(&self) -> Resource {
16874 Resource {
16875 uri: "mcp://shadow/template".to_owned(),
16876 name: self.label.to_owned(),
16877 description: None,
16878 mime_type: Some("text/plain".to_owned()),
16879 icon: None,
16880 version: None,
16881 tags: Vec::new(),
16882 }
16883 }
16884
16885 fn template(&self) -> Option<ResourceTemplate> {
16886 Some(marked_template("mcp://shadow/{id}", self.label))
16887 }
16888
16889 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
16890 unreachable!("templated reads receive their URI parameters")
16891 }
16892
16893 fn read_with_uri(
16894 &self,
16895 _ctx: &McpContext,
16896 uri: &str,
16897 _params: &UriParams,
16898 ) -> McpResult<Vec<ResourceContent>> {
16899 Ok(vec![ResourceContent {
16900 uri: uri.to_owned(),
16901 mime_type: Some("text/plain".to_owned()),
16902 text: Some(self.label.to_owned()),
16903 blob: None,
16904 }])
16905 }
16906 }
16907
16908 let cx = Cx::for_testing();
16909 let state = SessionState::new();
16910 let request_ctx = request_context(&cx, 190, Budget::INFINITE, &state);
16911 let uri = "mcp://shadow/item";
16912
16913 let mut legacy_router = Router::new();
16914 legacy_router
16915 .add_legacy_resource_with_behavior(
16916 ShadowTemplate {
16917 label: "legacy-template",
16918 },
16919 crate::DuplicateBehavior::Replace,
16920 )
16921 .expect("legacy template registers");
16922 legacy_router
16923 .add_final_resource_with_behavior(
16924 NamedResource::new(uri),
16925 crate::DuplicateBehavior::Replace,
16926 )
16927 .expect("final-only static resource registers");
16928 let legacy_read = legacy_router
16929 .handle_resources_read(
16930 &request_ctx,
16931 &ReadResourceParams {
16932 uri: uri.to_owned(),
16933 meta: None,
16934 },
16935 state.clone(),
16936 None,
16937 None,
16938 )
16939 .expect("a final-only static URI cannot hide a listed legacy template");
16940 assert_eq!(
16941 serde_json::to_value(legacy_read).expect("legacy result serializes")["contents"][0]["text"],
16942 "legacy-template"
16943 );
16944
16945 let mut final_router = Router::new();
16946 final_router
16947 .add_final_resource_with_behavior(
16948 ShadowTemplate {
16949 label: "final-template",
16950 },
16951 crate::DuplicateBehavior::Replace,
16952 )
16953 .expect("final template registers");
16954 final_router
16955 .add_legacy_resource_with_behavior(
16956 NamedResource::new(uri),
16957 crate::DuplicateBehavior::Replace,
16958 )
16959 .expect("legacy-only static resource registers");
16960 let final_read = final_router
16961 .dispatch_stateless(
16962 &request_ctx,
16963 &JsonRpcRequest::new(
16964 "resources/read",
16965 Some(serde_json::json!({
16966 "_meta": {
16967 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
16968 "io.modelcontextprotocol/clientCapabilities": {},
16969 },
16970 "uri": uri,
16971 })),
16972 190_i64,
16973 ),
16974 )
16975 .expect("a legacy-only static URI cannot hide a listed final template");
16976 assert_eq!(final_read["contents"][0]["text"], "final-template");
16977 }
16978
16979 #[test]
16980 fn completion_handler_rejects_one_field_final_metadata_in_legacy_request() {
16981 let mut router = Router::new();
16982 router.add_completion_handler(EchoCompletion);
16983 let cx = Cx::for_testing();
16984 let state = SessionState::new();
16985 let request_ctx = request_context(&cx, 89, Budget::INFINITE, &state);
16986 let baseline = JsonRpcRequest::new(
16987 COMPLETION_COMPLETE,
16988 Some(serde_json::json!({
16989 "ref": {"type": "ref/prompt", "name": "deploy"},
16990 "argument": {"name": "environment", "value": "sta"},
16991 })),
16992 89_i64,
16993 );
16994 let mut planted = baseline.clone();
16995 planted
16996 .params
16997 .as_mut()
16998 .and_then(serde_json::Value::as_object_mut)
16999 .expect("completion parameters are an object")
17000 .insert(
17001 "_meta".to_string(),
17002 serde_json::json!({
17003 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17004 }),
17005 );
17006
17007 assert_eq!(baseline.method, planted.method);
17008 assert_eq!(baseline.id, planted.id);
17009 assert_eq!(
17010 baseline
17011 .params
17012 .as_ref()
17013 .and_then(|params| params.get("ref")),
17014 planted.params.as_ref().and_then(|params| params.get("ref")),
17015 "the final metadata object is the sole planted dimension"
17016 );
17017 let catalog_before = router.has_completion_handler();
17018 let planted_before = serde_json::to_vec(&planted).expect("planted request serializes");
17019
17020 let baseline_result = router
17021 .dispatch_legacy_completion(&request_ctx, &baseline)
17022 .expect("the baseline legacy completion request is accepted");
17023 let error = router
17024 .dispatch_legacy_completion(&request_ctx, &planted)
17025 .expect_err("only final metadata is refused in the exact legacy request");
17026 assert_eq!(error.code, McpErrorCode::InvalidParams);
17027 assert_eq!(
17028 serde_json::to_vec(&planted).expect("rejected request serializes"),
17029 planted_before,
17030 "cross-era rejection cannot mutate caller-owned completion parameters"
17031 );
17032 assert_eq!(
17033 router.has_completion_handler(),
17034 catalog_before,
17035 "cross-era rejection cannot alter the installed completion handler"
17036 );
17037 assert_eq!(
17038 router
17039 .dispatch_legacy_completion(&request_ctx, &baseline)
17040 .expect("the baseline remains accepted after the planted rejection"),
17041 baseline_result,
17042 "the one-field rejection cannot alter the accepted legacy completion result"
17043 );
17044 }
17045
17046 #[test]
17047 fn macro_tool_dispatches_exact_legacy_and_final_complete_results() {
17048 let _counter_guard = MACRO_DUAL_ERA_TOOL_LOCK
17049 .lock()
17050 .unwrap_or_else(std::sync::PoisonError::into_inner);
17051 let mut router = Router::new();
17052 MACRO_DUAL_ERA_TOOL_CALLS.store(0, Ordering::SeqCst);
17053 router
17054 .add_tool(MacroDualEraTool)
17055 .expect("macro tool registration succeeds");
17056 let cx = Cx::for_testing();
17057 let state = SessionState::new();
17058 let request_ctx = request_context(&cx, 91, Budget::INFINITE, &state);
17059
17060 let legacy = router
17061 .handle_tools_call(
17062 &request_ctx,
17063 CallToolParams {
17064 name: "macro_dual_era_tool".to_string(),
17065 arguments: Some(serde_json::json!({})),
17066 meta: None,
17067 },
17068 state,
17069 None,
17070 None,
17071 )
17072 .expect("the legacy adapter still invokes the registered handler");
17073 let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
17074 assert!(
17075 legacy_wire.get("resultType").is_none(),
17076 "the exact legacy result shape remains unchanged"
17077 );
17078 assert_eq!(legacy_wire["content"][0]["text"], "macro final tool result");
17079
17080 let modern = router
17081 .dispatch_stateless(
17082 &request_ctx,
17083 &JsonRpcRequest::new(
17084 "tools/call",
17085 Some(serde_json::json!({
17086 "_meta": {
17087 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17088 "io.modelcontextprotocol/clientCapabilities": {},
17089 },
17090 "name": "macro_dual_era_tool",
17091 "arguments": {},
17092 })),
17093 91_i64,
17094 ),
17095 )
17096 .expect("the modern router invokes the same installed handler");
17097
17098 assert_eq!(
17099 modern.get("resultType"),
17100 Some(&serde_json::json!("complete"))
17101 );
17102 assert_eq!(modern.get("content"), legacy_wire.get("content"));
17103 assert_eq!(modern.get("isError"), legacy_wire.get("isError"));
17104 assert!(modern.get("serverInfo").is_none());
17105 assert_eq!(
17106 modern["structuredContent"],
17107 serde_json::json!({"weather": "clear"})
17108 );
17109 assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 2);
17110 }
17111
17112 #[test]
17113 fn final_tools_call_input_schema_failure_is_bounded_tool_error_without_handler_call() {
17114 let final_calls = Arc::new(AtomicUsize::new(0));
17115 let legacy_calls = Arc::new(AtomicUsize::new(0));
17116 let mut router = Router::new();
17117 router
17118 .add_tool(SchemaBoundaryTool {
17119 final_calls: Arc::clone(&final_calls),
17120 legacy_calls: Arc::clone(&legacy_calls),
17121 output_matches_schema: true,
17122 output_is_error: false,
17123 output_has_unevaluated_property: false,
17124 invalid_final_input_schema: false,
17125 missing_final_input_object_type: false,
17126 invalid_final_output_schema: false,
17127 })
17128 .expect("schema-boundary tool registration succeeds");
17129 let cx = Cx::for_testing();
17130 let state = SessionState::new();
17131 let request_ctx = request_context(&cx, 150, Budget::INFINITE, &state);
17132
17133 let rejected = router
17134 .dispatch_stateless(
17135 &request_ctx,
17136 &final_tools_call_request(
17137 "schema-boundary-tool",
17138 serde_json::json!({"value": 7}),
17139 150_i64,
17140 ),
17141 )
17142 .expect("registered final tool input failures are tool results");
17143 assert_eq!(rejected["resultType"], "complete");
17144 assert_eq!(rejected["isError"], true);
17145 assert_eq!(
17146 rejected["content"][0]["text"],
17147 "Tool arguments do not match the declared input schema."
17148 );
17149 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
17150 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
17151
17152 let accepted = router
17153 .dispatch_stateless(
17154 &request_ctx,
17155 &final_tools_call_request(
17156 "schema-boundary-tool",
17157 serde_json::json!({"value": "accepted"}),
17158 151_i64,
17159 ),
17160 )
17161 .expect("changing only the input value to match the schema is accepted");
17162 assert_eq!(accepted["resultType"], "complete");
17163 assert!(accepted.get("isError").is_none());
17164 assert_eq!(
17165 accepted["structuredContent"],
17166 serde_json::json!({"accepted": true})
17167 );
17168 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17169 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
17170 }
17171
17172 #[test]
17173 fn final_unknown_tool_is_invalid_params_while_legacy_unknown_tool_is_unchanged() {
17174 let router = Router::new();
17175 let cx = Cx::for_testing();
17176 let state = SessionState::new();
17177 let request_ctx = request_context(&cx, 152, Budget::INFINITE, &state);
17178
17179 let modern_error = router
17180 .dispatch_stateless(
17181 &request_ctx,
17182 &final_tools_call_request("unknown-tool", serde_json::json!({}), 152_i64),
17183 )
17184 .expect_err("an unknown final tool is an invalid-params protocol error");
17185 assert_eq!(modern_error.code, McpErrorCode::InvalidParams);
17186
17187 let legacy_error = router
17188 .handle_tools_call(
17189 &request_ctx,
17190 CallToolParams {
17191 name: "unknown-tool".to_owned(),
17192 arguments: Some(serde_json::json!({})),
17193 meta: None,
17194 },
17195 state,
17196 None,
17197 None,
17198 )
17199 .expect_err("the exact legacy unknown-tool result remains method-not-found");
17200 assert_eq!(legacy_error.code, McpErrorCode::MethodNotFound);
17201 }
17202
17203 #[test]
17204 fn final_tool_output_schema_is_checked_before_success_and_legacy_is_unchanged() {
17205 let final_calls = Arc::new(AtomicUsize::new(0));
17206 let legacy_calls = Arc::new(AtomicUsize::new(0));
17207 let mut router = Router::new();
17208 router
17209 .add_tool(SchemaBoundaryTool {
17210 final_calls: Arc::clone(&final_calls),
17211 legacy_calls: Arc::clone(&legacy_calls),
17212 output_matches_schema: true,
17213 output_is_error: false,
17214 output_has_unevaluated_property: false,
17215 invalid_final_input_schema: false,
17216 missing_final_input_object_type: false,
17217 invalid_final_output_schema: false,
17218 })
17219 .expect("schema-boundary tool registration succeeds");
17220 let cx = Cx::for_testing();
17221 let state = SessionState::new();
17222 let request_ctx = request_context(&cx, 153, Budget::INFINITE, &state);
17223
17224 let accepted = router
17225 .dispatch_stateless(
17226 &request_ctx,
17227 &final_tools_call_request(
17228 "schema-boundary-tool",
17229 serde_json::json!({"value": "accepted"}),
17230 153_i64,
17231 ),
17232 )
17233 .expect("a complete result matching the declared output schema is emitted");
17234 assert_eq!(
17235 accepted["structuredContent"],
17236 serde_json::json!({"accepted": true})
17237 );
17238 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17239
17240 let legacy = router
17241 .handle_tools_call(
17242 &request_ctx,
17243 CallToolParams {
17244 name: "schema-boundary-tool".to_owned(),
17245 arguments: Some(serde_json::json!({"value": "accepted"})),
17246 meta: None,
17247 },
17248 state,
17249 None,
17250 None,
17251 )
17252 .expect("the legacy tool path does not apply final output-schema validation");
17253 assert!(!legacy.is_error);
17254 let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
17255 assert_eq!(
17256 legacy_wire["content"][0]["text"],
17257 "legacy schema-boundary result"
17258 );
17259 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
17260
17261 let rejected_final_calls = Arc::new(AtomicUsize::new(0));
17262 let rejected_legacy_calls = Arc::new(AtomicUsize::new(0));
17263 let mut rejected_router = Router::new();
17264 rejected_router
17265 .add_tool(SchemaBoundaryTool {
17266 final_calls: Arc::clone(&rejected_final_calls),
17267 legacy_calls: Arc::clone(&rejected_legacy_calls),
17268 output_matches_schema: false,
17269 output_is_error: false,
17270 output_has_unevaluated_property: false,
17271 invalid_final_input_schema: false,
17272 missing_final_input_object_type: false,
17273 invalid_final_output_schema: false,
17274 })
17275 .expect("schema-boundary tool registration succeeds");
17276 let rejected = rejected_router
17277 .dispatch_stateless(
17278 &request_ctx,
17279 &final_tools_call_request(
17280 "schema-boundary-tool",
17281 serde_json::json!({"value": "accepted"}),
17282 154_i64,
17283 ),
17284 )
17285 .expect_err("a complete result failing the declared output schema is not emitted");
17286 assert_eq!(rejected.code, McpErrorCode::InternalError);
17287 assert_eq!(
17288 rejected.message,
17289 "tool output does not match the declared output schema"
17290 );
17291 assert_eq!(rejected_final_calls.load(Ordering::SeqCst), 1);
17292 assert_eq!(rejected_legacy_calls.load(Ordering::SeqCst), 0);
17293 }
17294
17295 #[test]
17296 fn final_tool_output_schema_applies_to_complete_error_payloads() {
17297 let accepted_calls = Arc::new(AtomicUsize::new(0));
17298 let mut accepted_router = Router::new();
17299 accepted_router
17300 .add_tool(SchemaBoundaryTool {
17301 final_calls: Arc::clone(&accepted_calls),
17302 legacy_calls: Arc::new(AtomicUsize::new(0)),
17303 output_matches_schema: true,
17304 output_is_error: true,
17305 output_has_unevaluated_property: false,
17306 invalid_final_input_schema: false,
17307 missing_final_input_object_type: false,
17308 invalid_final_output_schema: false,
17309 })
17310 .expect("schema-boundary tool registration succeeds");
17311 let cx = Cx::for_testing();
17312 let state = SessionState::new();
17313 let request_ctx = request_context(&cx, 154, Budget::INFINITE, &state);
17314
17315 let accepted = accepted_router
17316 .dispatch_stateless(
17317 &request_ctx,
17318 &final_tools_call_request(
17319 "schema-boundary-tool",
17320 serde_json::json!({"value": "accepted"}),
17321 154_i64,
17322 ),
17323 )
17324 .expect("a schema-conforming complete error payload is emitted");
17325 assert_eq!(accepted["resultType"], "complete");
17326 assert_eq!(accepted["isError"], true);
17327 assert_eq!(
17328 accepted["structuredContent"],
17329 serde_json::json!({"accepted": true})
17330 );
17331 assert_eq!(accepted_calls.load(Ordering::SeqCst), 1);
17332
17333 let rejected_calls = Arc::new(AtomicUsize::new(0));
17334 let mut rejected_router = Router::new();
17335 rejected_router
17336 .add_tool(SchemaBoundaryTool {
17337 final_calls: Arc::clone(&rejected_calls),
17338 legacy_calls: Arc::new(AtomicUsize::new(0)),
17339 output_matches_schema: false,
17340 output_is_error: true,
17341 output_has_unevaluated_property: false,
17342 invalid_final_input_schema: false,
17343 missing_final_input_object_type: false,
17344 invalid_final_output_schema: false,
17345 })
17346 .expect("schema-boundary tool registration succeeds");
17347
17348 let rejected = rejected_router
17349 .dispatch_stateless(
17350 &request_ctx,
17351 &final_tools_call_request(
17352 "schema-boundary-tool",
17353 serde_json::json!({"value": "accepted"}),
17354 155_i64,
17355 ),
17356 )
17357 .expect_err("a nonconforming complete error payload is not emitted");
17358 assert_eq!(rejected.code, McpErrorCode::InternalError);
17359 assert_eq!(
17360 rejected.message,
17361 "tool output does not match the declared output schema"
17362 );
17363 assert_eq!(rejected_calls.load(Ordering::SeqCst), 1);
17364 }
17365
17366 #[test]
17367 fn final_tool_admitted_schemas_enforce_unevaluated_properties_on_shipped_path() {
17368 let input_final_calls = Arc::new(AtomicUsize::new(0));
17369 let input_legacy_calls = Arc::new(AtomicUsize::new(0));
17370 let mut input_router = Router::new();
17371 input_router
17372 .add_tool(SchemaBoundaryTool {
17373 final_calls: Arc::clone(&input_final_calls),
17374 legacy_calls: Arc::clone(&input_legacy_calls),
17375 output_matches_schema: true,
17376 output_is_error: false,
17377 output_has_unevaluated_property: false,
17378 invalid_final_input_schema: false,
17379 missing_final_input_object_type: false,
17380 invalid_final_output_schema: false,
17381 })
17382 .expect("schema-boundary tool registration succeeds");
17383 let cx = Cx::for_testing();
17384 let state = SessionState::new();
17385 let request_ctx = request_context(&cx, 155, Budget::INFINITE, &state);
17386
17387 let rejected_input = input_router
17388 .dispatch_stateless(
17389 &request_ctx,
17390 &final_tools_call_request(
17391 "schema-boundary-tool",
17392 serde_json::json!({"value": "accepted", "unexpected": true}),
17393 155_i64,
17394 ),
17395 )
17396 .expect("unevaluated final input properties return the bounded tool error result");
17397 assert_eq!(rejected_input["resultType"], "complete");
17398 assert_eq!(rejected_input["isError"], true);
17399 assert_eq!(input_final_calls.load(Ordering::SeqCst), 0);
17400 assert_eq!(input_legacy_calls.load(Ordering::SeqCst), 0);
17401
17402 let accepted_input = input_router
17403 .dispatch_stateless(
17404 &request_ctx,
17405 &final_tools_call_request(
17406 "schema-boundary-tool",
17407 serde_json::json!({"value": "accepted"}),
17408 156_i64,
17409 ),
17410 )
17411 .expect("removing only the unevaluated input property reaches the handler");
17412 assert_eq!(accepted_input["resultType"], "complete");
17413 assert_eq!(input_final_calls.load(Ordering::SeqCst), 1);
17414
17415 let output_final_calls = Arc::new(AtomicUsize::new(0));
17416 let output_legacy_calls = Arc::new(AtomicUsize::new(0));
17417 let mut output_router = Router::new();
17418 output_router
17419 .add_tool(SchemaBoundaryTool {
17420 final_calls: Arc::clone(&output_final_calls),
17421 legacy_calls: Arc::clone(&output_legacy_calls),
17422 output_matches_schema: true,
17423 output_is_error: false,
17424 output_has_unevaluated_property: true,
17425 invalid_final_input_schema: false,
17426 missing_final_input_object_type: false,
17427 invalid_final_output_schema: false,
17428 })
17429 .expect("schema-boundary tool registration succeeds");
17430
17431 let rejected_output = output_router
17432 .dispatch_stateless(
17433 &request_ctx,
17434 &final_tools_call_request(
17435 "schema-boundary-tool",
17436 serde_json::json!({"value": "accepted"}),
17437 157_i64,
17438 ),
17439 )
17440 .expect_err("an unevaluated final output property is not emitted as success");
17441 assert_eq!(rejected_output.code, McpErrorCode::InternalError);
17442 assert_eq!(
17443 rejected_output.message,
17444 "tool output does not match the declared output schema"
17445 );
17446 assert_eq!(output_final_calls.load(Ordering::SeqCst), 1);
17447 assert_eq!(output_legacy_calls.load(Ordering::SeqCst), 0);
17448 }
17449
17450 #[test]
17451 fn valid_final_tool_replace_updates_both_catalogs_without_reordering() {
17452 let original_legacy_calls = Arc::new(AtomicUsize::new(0));
17453 let original_final_calls = Arc::new(AtomicUsize::new(0));
17454 let replacement_legacy_calls = Arc::new(AtomicUsize::new(0));
17455 let replacement_final_calls = Arc::new(AtomicUsize::new(0));
17456 let mut router = Router::new();
17457 router
17458 .add_tool(NamedTool::new("before"))
17459 .expect("tool registration succeeds");
17460 router
17461 .add_tool(AdmittedSchemaReplacementTool {
17462 legacy_calls: Arc::clone(&original_legacy_calls),
17463 final_calls: Arc::clone(&original_final_calls),
17464 legacy_label: "original",
17465 output_schema: serde_json::json!({"type": "string"}),
17466 structured_content: Some(serde_json::json!("original")),
17467 })
17468 .expect("original tool registration succeeds");
17469 router
17470 .add_tool(NamedTool::new("after"))
17471 .expect("tool registration succeeds");
17472 let order_before = router
17473 .tools()
17474 .into_iter()
17475 .map(|tool| tool.name)
17476 .collect::<Vec<_>>();
17477
17478 router
17479 .add_tool_with_behavior(
17480 AdmittedSchemaReplacementTool {
17481 legacy_calls: Arc::clone(&replacement_legacy_calls),
17482 final_calls: Arc::clone(&replacement_final_calls),
17483 legacy_label: "replacement",
17484 output_schema: serde_json::json!({"type": "boolean"}),
17485 structured_content: Some(serde_json::json!(true)),
17486 },
17487 crate::DuplicateBehavior::Replace,
17488 )
17489 .expect("a fully admitted replacement commits both catalog views");
17490 assert_eq!(
17491 router
17492 .tools()
17493 .into_iter()
17494 .map(|tool| tool.name)
17495 .collect::<Vec<_>>(),
17496 order_before,
17497 "replacement retains the original registration position"
17498 );
17499
17500 let cx = Cx::for_testing();
17501 let state = SessionState::new();
17502 let request_ctx = request_context(&cx, 158, Budget::INFINITE, &state);
17503 let legacy = router
17504 .handle_tools_call(
17505 &request_ctx,
17506 CallToolParams {
17507 name: "admitted-schema-replacement-tool".to_owned(),
17508 arguments: Some(serde_json::json!({})),
17509 meta: None,
17510 },
17511 state,
17512 None,
17513 None,
17514 )
17515 .expect("the replacement is installed for legacy dispatch");
17516 let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
17517 assert_eq!(legacy_wire["content"][0]["text"], "replacement");
17518 assert_eq!(replacement_legacy_calls.load(Ordering::SeqCst), 1);
17519 assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 0);
17520
17521 let modern = router
17522 .dispatch_stateless(
17523 &request_ctx,
17524 &final_tools_call_request(
17525 "admitted-schema-replacement-tool",
17526 serde_json::json!({}),
17527 159_i64,
17528 ),
17529 )
17530 .expect("the replacement is installed for modern dispatch");
17531 assert_eq!(modern["structuredContent"], serde_json::json!(true));
17532 assert_eq!(replacement_final_calls.load(Ordering::SeqCst), 1);
17533 assert_eq!(original_final_calls.load(Ordering::SeqCst), 0);
17534
17535 let modern_catalog = router
17536 .dispatch_stateless(
17537 &request_ctx,
17538 &final_tools_list_request(None, None, None, 160_i64),
17539 )
17540 .expect("the admitted replacement remains visible to the modern catalog");
17541 assert_eq!(
17542 modern_catalog["tools"]
17543 .as_array()
17544 .expect("modern tools remain an array")
17545 .iter()
17546 .map(|tool| tool["name"].as_str().expect("tool name is a string"))
17547 .collect::<Vec<_>>(),
17548 vec!["before", "admitted-schema-replacement-tool", "after"]
17549 );
17550 assert_eq!(
17551 modern_catalog["tools"][1]["outputSchema"]["type"],
17552 "boolean"
17553 );
17554 }
17555
17556 #[test]
17557 fn invalid_final_schema_new_tool_leaves_both_catalogs_unchanged() {
17558 let mut router = Router::new();
17559 router
17560 .add_tool(NamedTool::new("existing"))
17561 .expect("baseline tool registration succeeds");
17562 let cx = Cx::for_testing();
17563 let state = SessionState::new();
17564 let request_ctx = request_context(&cx, 161, Budget::INFINITE, &state);
17565 let legacy_before =
17566 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
17567 let modern_before = router
17568 .dispatch_stateless(
17569 &request_ctx,
17570 &final_tools_list_request(None, None, None, 161_i64),
17571 )
17572 .expect("baseline modern catalog is available");
17573
17574 let error = router
17575 .add_tool_with_behavior(
17576 InvalidFinalSchemaNamedTool::with_tags("new-invalid", Vec::new()),
17577 crate::DuplicateBehavior::Error,
17578 )
17579 .expect_err("a new normal tool with a scalar outputSchema is rejected");
17580 assert_eq!(error.code, McpErrorCode::InternalError);
17581 assert_eq!(
17582 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
17583 legacy_before,
17584 "failed admission cannot add a legacy-only entry"
17585 );
17586 assert_eq!(
17587 router
17588 .dispatch_stateless(
17589 &request_ctx,
17590 &final_tools_list_request(None, None, None, 162_i64),
17591 )
17592 .expect("modern catalog remains available"),
17593 modern_before,
17594 "failed admission cannot alter the modern catalog"
17595 );
17596 assert!(router.get_tool("new-invalid").is_none());
17597 }
17598
17599 #[test]
17600 fn only_tokenized_upstream_schema_registration_bypasses_local_validation() {
17601 let cx = Cx::for_testing();
17602 let state = SessionState::new();
17603 let request_ctx = request_context(&cx, 1601, Budget::INFINITE, &state);
17604 let mut registered_proxy_router = Router::new();
17605 registered_proxy_router
17606 .add_tool(UpstreamScalarSchemaTool {
17607 registered_proxy: true,
17608 })
17609 .expect("an upstream-owned scalar schema is retained without local admission");
17610
17611 let response = registered_proxy_router
17612 .dispatch_stateless(
17613 &request_ctx,
17614 &final_tools_call_request(
17615 "upstream-scalar-schema-tool",
17616 serde_json::json!({}),
17617 1601_i64,
17618 ),
17619 )
17620 .expect("upstream-owned structured content is not locally revalidated");
17621 assert_eq!(
17622 response["structuredContent"],
17623 serde_json::json!({"upstream": true})
17624 );
17625
17626 let mut forged_router = Router::new();
17627 let error = forged_router
17628 .add_tool(UpstreamScalarSchemaTool {
17629 registered_proxy: false,
17630 })
17631 .expect_err("a forgeable authority label cannot bypass local schema admission");
17632 assert_eq!(error.code, McpErrorCode::InternalError);
17633 assert!(
17634 forged_router
17635 .get_tool("upstream-scalar-schema-tool")
17636 .is_none()
17637 );
17638 }
17639
17640 #[test]
17641 fn tokenized_upstream_schema_still_honors_strict_input_validation() {
17642 let cx = Cx::for_testing();
17643 let state = SessionState::new();
17644 let request_ctx = request_context(&cx, 1602, Budget::INFINITE, &state);
17645 let mut router = Router::new();
17646 router
17647 .add_tool(UpstreamScalarSchemaTool {
17648 registered_proxy: true,
17649 })
17650 .expect("an upstream-owned object schema is retained without local admission");
17651 router.set_strict_input_validation(true);
17652
17653 let refused = router
17654 .dispatch_stateless(
17655 &request_ctx,
17656 &final_tools_call_request(
17657 "upstream-scalar-schema-tool",
17658 serde_json::json!({"extra": 1}),
17659 1602_i64,
17660 ),
17661 )
17662 .expect("gateway strict mode returns a complete tools/call result");
17663 assert_eq!(refused["resultType"], "complete");
17664 assert_eq!(refused["isError"], true);
17665 assert_eq!(
17666 refused["content"][0]["text"],
17667 "Tool arguments do not match the declared input schema."
17668 );
17669
17670 let admitted = router
17671 .dispatch_stateless(
17672 &request_ctx,
17673 &final_tools_call_request(
17674 "upstream-scalar-schema-tool",
17675 serde_json::json!({}),
17676 1603_i64,
17677 ),
17678 )
17679 .expect("declared empty object arguments still reach the proxy handler");
17680 assert_eq!(
17681 admitted["structuredContent"],
17682 serde_json::json!({"upstream": true})
17683 );
17684
17685 router.set_strict_input_validation(false);
17686 let extra = router
17687 .dispatch_stateless(
17688 &request_ctx,
17689 &final_tools_call_request(
17690 "upstream-scalar-schema-tool",
17691 serde_json::json!({"extra": 1}),
17692 1604_i64,
17693 ),
17694 )
17695 .expect("changing only the strict flag must admit the extra property");
17696 assert_eq!(
17697 extra["structuredContent"],
17698 serde_json::json!({"upstream": true})
17699 );
17700 }
17701
17702 #[test]
17703 fn normal_registration_rejects_missing_input_schema_type_without_catalog_mutation() {
17704 let mut router = Router::new();
17705 router
17706 .add_tool(NamedTool::new("existing"))
17707 .expect("baseline tool registration succeeds");
17708 let legacy_before =
17709 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
17710
17711 let error = router
17712 .add_tool_with_behavior(
17713 SchemaBoundaryTool {
17714 final_calls: Arc::new(AtomicUsize::new(0)),
17715 legacy_calls: Arc::new(AtomicUsize::new(0)),
17716 output_matches_schema: true,
17717 output_is_error: false,
17718 output_has_unevaluated_property: false,
17719 invalid_final_input_schema: false,
17720 missing_final_input_object_type: true,
17721 invalid_final_output_schema: false,
17722 },
17723 crate::DuplicateBehavior::Error,
17724 )
17725 .expect_err("a normal inputSchema must declare type object");
17726 assert_eq!(error.code, McpErrorCode::InternalError);
17727 assert_eq!(
17728 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
17729 legacy_before
17730 );
17731 assert!(router.get_tool("schema-boundary-tool").is_none());
17732 }
17733
17734 #[test]
17735 fn final_tool_scalar_and_null_structured_content_are_present_and_validated() {
17736 let cx = Cx::for_testing();
17737 let state = SessionState::new();
17738 let request_ctx = request_context(&cx, 163, Budget::INFINITE, &state);
17739
17740 let mut scalar_router = Router::new();
17741 scalar_router
17742 .add_tool(AdmittedSchemaReplacementTool {
17743 legacy_calls: Arc::new(AtomicUsize::new(0)),
17744 final_calls: Arc::new(AtomicUsize::new(0)),
17745 legacy_label: "scalar",
17746 output_schema: serde_json::json!({"type": "string"}),
17747 structured_content: Some(serde_json::json!("")),
17748 })
17749 .expect("scalar-output tool registration succeeds");
17750 let scalar = scalar_router
17751 .dispatch_stateless(
17752 &request_ctx,
17753 &final_tools_call_request(
17754 "admitted-schema-replacement-tool",
17755 serde_json::json!({}),
17756 163_i64,
17757 ),
17758 )
17759 .expect("an object-valued schema document may describe a scalar result");
17760 assert_eq!(
17761 scalar.get("structuredContent"),
17762 Some(&serde_json::json!("")),
17763 "an empty string is present structured content, not an omitted value"
17764 );
17765
17766 let mut null_router = Router::new();
17767 null_router
17768 .add_tool(AdmittedSchemaReplacementTool {
17769 legacy_calls: Arc::new(AtomicUsize::new(0)),
17770 final_calls: Arc::new(AtomicUsize::new(0)),
17771 legacy_label: "null",
17772 output_schema: serde_json::json!({"type": "null"}),
17773 structured_content: Some(serde_json::Value::Null),
17774 })
17775 .expect("null-output tool registration succeeds");
17776 let null = null_router
17777 .dispatch_stateless(
17778 &request_ctx,
17779 &final_tools_call_request(
17780 "admitted-schema-replacement-tool",
17781 serde_json::json!({}),
17782 164_i64,
17783 ),
17784 )
17785 .expect("present JSON null is validated against a null output schema");
17786 assert_eq!(
17787 null.get("structuredContent"),
17788 Some(&serde_json::Value::Null),
17789 "explicit JSON null remains present on the server-emission path"
17790 );
17791 }
17792
17793 #[test]
17794 fn final_tool_declared_output_schema_rejects_absent_structured_content() {
17795 let mut router = Router::new();
17796 router
17797 .add_tool(AdmittedSchemaReplacementTool {
17798 legacy_calls: Arc::new(AtomicUsize::new(0)),
17799 final_calls: Arc::new(AtomicUsize::new(0)),
17800 legacy_label: "missing",
17801 output_schema: serde_json::json!({"type": "string"}),
17802 structured_content: None,
17803 })
17804 .expect("mapped tool registration succeeds");
17805 let cx = Cx::for_testing();
17806 let state = SessionState::new();
17807 let request_ctx = request_context(&cx, 165, Budget::INFINITE, &state);
17808
17809 let error = router
17810 .dispatch_stateless(
17811 &request_ctx,
17812 &final_tools_call_request(
17813 "admitted-schema-replacement-tool",
17814 serde_json::json!({}),
17815 165_i64,
17816 ),
17817 )
17818 .expect_err("a declared output schema requires structured content on complete output");
17819 assert_eq!(error.code, McpErrorCode::InternalError);
17820 assert_eq!(
17821 error.message,
17822 "tool output is missing structuredContent required by the declared output schema"
17823 );
17824 }
17825
17826 #[test]
17827 fn final_tool_error_mapper_covers_input_validation_and_handler_errors() {
17828 let calls = Arc::new(AtomicUsize::new(0));
17829 let mut router = Router::new();
17830 router
17831 .add_tool(ErrorMappedTool {
17832 mode: ErrorMapperMode::Complete,
17833 calls: Arc::clone(&calls),
17834 })
17835 .expect("both bounded mapper branches satisfy outputSchema");
17836 let cx = Cx::for_testing();
17837 let state = SessionState::new();
17838 let request_ctx = request_context(&cx, 169, Budget::INFINITE, &state);
17839
17840 let input_error = router
17841 .dispatch_stateless(
17842 &request_ctx,
17843 &final_tools_call_request(
17844 "error-mapped-tool",
17845 serde_json::json!({"value": 7}),
17846 169_i64,
17847 ),
17848 )
17849 .expect("input rejection is a schema-valid complete tool error");
17850 assert_eq!(input_error["isError"], true);
17851 assert_eq!(
17852 input_error["structuredContent"],
17853 serde_json::json!({"error": "input-validation"})
17854 );
17855 assert_eq!(calls.load(Ordering::SeqCst), 0);
17856
17857 let handler_error = router
17858 .dispatch_stateless(
17859 &request_ctx,
17860 &final_tools_call_request(
17861 "error-mapped-tool",
17862 serde_json::json!({"value": "accepted"}),
17863 170_i64,
17864 ),
17865 )
17866 .expect("handler rejection is a schema-valid complete tool error");
17867 assert_eq!(handler_error["isError"], true);
17868 assert_eq!(
17869 handler_error["structuredContent"],
17870 serde_json::json!({"error": "handler"})
17871 );
17872 assert_eq!(calls.load(Ordering::SeqCst), 1);
17873 }
17874
17875 #[test]
17876 fn incomplete_invalid_or_oversized_tool_error_mapper_is_rejected_atomically() {
17877 for (mode, expected_message) in [
17878 (
17879 ErrorMapperMode::MissingHandler,
17880 "tool declares outputSchema without a complete tool-error structured-content mapper",
17881 ),
17882 (
17883 ErrorMapperMode::InvalidHandler,
17884 "tool error structured-content mapper does not satisfy outputSchema",
17885 ),
17886 (
17887 ErrorMapperMode::OversizedHandler,
17888 "tool error structured-content mapper exceeded the registration limit",
17889 ),
17890 ] {
17891 let calls = Arc::new(AtomicUsize::new(0));
17892 let mut router = Router::new();
17893 router
17894 .add_tool(NamedTool::new("existing"))
17895 .expect("baseline tool admission succeeds");
17896 let cx = Cx::for_testing();
17897 let state = SessionState::new();
17898 let request_ctx = request_context(&cx, 171, Budget::INFINITE, &state);
17899 let legacy_before =
17900 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
17901 let modern_before = router
17902 .dispatch_stateless(
17903 &request_ctx,
17904 &final_tools_list_request(None, None, None, 171_i64),
17905 )
17906 .expect("modern catalog is available");
17907
17908 let error = router
17909 .add_tool(ErrorMappedTool {
17910 mode,
17911 calls: Arc::clone(&calls),
17912 })
17913 .expect_err("public add_tool exposes mapper admission failure");
17914 assert_eq!(error.code, McpErrorCode::InternalError);
17915 assert_eq!(error.message, expected_message);
17916 assert_eq!(
17917 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
17918 legacy_before
17919 );
17920 assert_eq!(
17921 router
17922 .dispatch_stateless(
17923 &request_ctx,
17924 &final_tools_list_request(None, None, None, 172_i64),
17925 )
17926 .expect("modern catalog remains available"),
17927 modern_before
17928 );
17929 assert!(router.get_tool("error-mapped-tool").is_none());
17930 assert_eq!(calls.load(Ordering::SeqCst), 0);
17931 }
17932 }
17933
17934 #[test]
17935 fn normal_registration_rejects_null_output_schema_without_catalog_mutation() {
17936 let mut router = Router::new();
17937 let error = router
17938 .add_tool_with_behavior(
17939 AdmittedSchemaReplacementTool {
17940 legacy_calls: Arc::new(AtomicUsize::new(0)),
17941 final_calls: Arc::new(AtomicUsize::new(0)),
17942 legacy_label: "null-schema",
17943 output_schema: serde_json::Value::Null,
17944 structured_content: None,
17945 },
17946 crate::DuplicateBehavior::Error,
17947 )
17948 .expect_err("the outputSchema wire field itself must be an object");
17949 assert_eq!(error.code, McpErrorCode::InternalError);
17950 assert!(router.tools().is_empty());
17951 assert!(
17952 router
17953 .get_tool("admitted-schema-replacement-tool")
17954 .is_none()
17955 );
17956 }
17957
17958 #[test]
17959 fn invalid_final_schema_replace_leaves_handlers_and_catalogs_unchanged() {
17960 let original_legacy_calls = Arc::new(AtomicUsize::new(0));
17961 let original_final_calls = Arc::new(AtomicUsize::new(0));
17962 let mut router = Router::new();
17963 router
17964 .add_tool(AdmittedSchemaReplacementTool {
17965 legacy_calls: Arc::clone(&original_legacy_calls),
17966 final_calls: Arc::clone(&original_final_calls),
17967 legacy_label: "original",
17968 output_schema: serde_json::json!({"type": "string"}),
17969 structured_content: Some(serde_json::json!("original")),
17970 })
17971 .expect("original tool registration succeeds");
17972 let cx = Cx::for_testing();
17973 let state = SessionState::new();
17974 let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
17975 let legacy_before =
17976 serde_json::to_value(router.tools()).expect("legacy catalog serializes");
17977 let modern_before = router
17978 .dispatch_stateless(
17979 &request_ctx,
17980 &final_tools_list_request(None, None, None, 166_i64),
17981 )
17982 .expect("baseline modern catalog is available");
17983
17984 let error = router
17985 .add_tool_with_behavior(
17986 InvalidFinalSchemaNamedTool::with_tags(
17987 "admitted-schema-replacement-tool",
17988 Vec::new(),
17989 ),
17990 crate::DuplicateBehavior::Replace,
17991 )
17992 .expect_err("changing only the candidate outputSchema rejects replacement");
17993 assert_eq!(error.code, McpErrorCode::InternalError);
17994 assert_eq!(
17995 serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
17996 legacy_before,
17997 "a rejected replacement cannot replace the legacy handler"
17998 );
17999 assert_eq!(
18000 router
18001 .dispatch_stateless(
18002 &request_ctx,
18003 &final_tools_list_request(None, None, None, 167_i64),
18004 )
18005 .expect("modern catalog remains available"),
18006 modern_before,
18007 "a rejected replacement cannot remove the admitted modern entry"
18008 );
18009
18010 let legacy = router
18011 .handle_tools_call(
18012 &request_ctx,
18013 CallToolParams {
18014 name: "admitted-schema-replacement-tool".to_owned(),
18015 arguments: Some(serde_json::json!({})),
18016 meta: None,
18017 },
18018 state,
18019 None,
18020 None,
18021 )
18022 .expect("the original legacy handler remains installed");
18023 let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
18024 assert_eq!(legacy_wire["content"][0]["text"], "original");
18025 assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 1);
18026
18027 let modern = router
18028 .dispatch_stateless(
18029 &request_ctx,
18030 &final_tools_call_request(
18031 "admitted-schema-replacement-tool",
18032 serde_json::json!({}),
18033 168_i64,
18034 ),
18035 )
18036 .expect("the original modern handler remains installed");
18037 assert_eq!(modern["structuredContent"], serde_json::json!("original"));
18038 assert_eq!(original_final_calls.load(Ordering::SeqCst), 1);
18039 }
18040
18041 #[test]
18042 fn final_tool_and_prompt_argument_nulls_are_rejected_without_erasing_absence() {
18043 let tool_final_calls = Arc::new(AtomicUsize::new(0));
18044 let prompt_final_calls = Arc::new(AtomicUsize::new(0));
18045 let mut router = Router::new();
18046 router
18047 .add_tool(InputRequiredTool {
18048 legacy_calls: Arc::new(AtomicUsize::new(0)),
18049 final_calls: Arc::clone(&tool_final_calls),
18050 })
18051 .expect("tool registration succeeds");
18052 router.add_prompt(DirectFinalPrompt {
18053 final_calls: Arc::clone(&prompt_final_calls),
18054 });
18055 let cx = Cx::for_testing();
18056 let connection = ModernConnection::new();
18057 let inbound = InboundRequestContext::with_modern_connection(
18058 cx,
18059 160,
18060 InboundRequestTransport::Memory,
18061 &connection,
18062 );
18063 let request_ctx = inbound.request_context();
18064
18065 let absent_tool_arguments = JsonRpcRequest::new(
18066 "tools/call",
18067 Some(serde_json::json!({
18068 "_meta": {
18069 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18070 "io.modelcontextprotocol/clientCapabilities": {},
18071 },
18072 "name": "input-required-tool",
18073 })),
18074 160_i64,
18075 );
18076 let mut null_tool_arguments = absent_tool_arguments.clone();
18077 null_tool_arguments
18078 .params
18079 .as_mut()
18080 .and_then(serde_json::Value::as_object_mut)
18081 .expect("final tool parameters are an object")
18082 .insert("arguments".to_owned(), serde_json::Value::Null);
18083
18084 let CoreRequest::Final(FinalCoreRequest::ToolsCall(absent_tool_params)) =
18085 CoreRequest::decode(
18086 ProtocolEra::Modern2026,
18087 "tools/call",
18088 absent_tool_arguments.params.as_ref(),
18089 )
18090 .expect("absent final tool arguments decode")
18091 else {
18092 panic!("the final tool parameter shape is selected");
18093 };
18094 assert!(absent_tool_params.arguments.is_absent());
18095 assert!(
18099 CoreRequest::decode(
18100 ProtocolEra::Modern2026,
18101 "tools/call",
18102 null_tool_arguments.params.as_ref(),
18103 )
18104 .is_err(),
18105 "explicit-null final tool arguments are rejected at decode"
18106 );
18107
18108 let absent_tool_result = router
18109 .dispatch_stateless(&request_ctx, &absent_tool_arguments)
18110 .expect("absent final tool arguments default to an empty object");
18111 assert_eq!(absent_tool_result["resultType"], "input_required");
18112 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
18113 let null_tool_error = router
18114 .dispatch_stateless(&request_ctx, &null_tool_arguments)
18115 .expect_err("explicit-null final tool arguments are rejected");
18116 assert_eq!(null_tool_error.code, McpErrorCode::InvalidParams);
18117 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
18118
18119 let absent_prompt_arguments = direct_final_prompt_request(161_i64);
18120 let mut null_prompt_arguments = absent_prompt_arguments.clone();
18121 null_prompt_arguments
18122 .params
18123 .as_mut()
18124 .and_then(serde_json::Value::as_object_mut)
18125 .expect("final prompt parameters are an object")
18126 .insert("arguments".to_owned(), serde_json::Value::Null);
18127
18128 let CoreRequest::Final(FinalCoreRequest::PromptsGet(absent_prompt_params)) =
18129 CoreRequest::decode(
18130 ProtocolEra::Modern2026,
18131 "prompts/get",
18132 absent_prompt_arguments.params.as_ref(),
18133 )
18134 .expect("absent final prompt arguments decode")
18135 else {
18136 panic!("the final prompt parameter shape is selected");
18137 };
18138 assert!(absent_prompt_params.arguments.is_absent());
18139 assert!(
18141 CoreRequest::decode(
18142 ProtocolEra::Modern2026,
18143 "prompts/get",
18144 null_prompt_arguments.params.as_ref(),
18145 )
18146 .is_err(),
18147 "explicit-null final prompt arguments are rejected at decode"
18148 );
18149
18150 let absent_prompt_result = router
18151 .dispatch_stateless(&request_ctx, &absent_prompt_arguments)
18152 .expect("absent final prompt arguments default to an empty map");
18153 assert_eq!(absent_prompt_result["resultType"], "complete");
18154 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
18155 let null_prompt_error = router
18156 .dispatch_stateless(&request_ctx, &null_prompt_arguments)
18157 .expect_err("explicit-null final prompt arguments are rejected");
18158 assert_eq!(null_prompt_error.code, McpErrorCode::InvalidParams);
18159 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
18160 }
18161
18162 #[cfg(feature = "tasks")]
18163 #[test]
18164 fn final_task_capable_tool_creates_work_bound_task_after_capability_and_service_admission() {
18165 let final_calls = Arc::new(AtomicUsize::new(0));
18166 let store = Arc::new(InMemoryFinalTaskStore::default());
18167 let runtime = task_runtime_for_router(Arc::clone(&store));
18168 let service_runner = runtime
18169 .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
18170 .expect("a bounded application-owned task service is installed");
18171 let service_cx = Cx::for_testing();
18172 let mut running_service = Box::pin(service_runner.run(&service_cx));
18173 let mut task_cx = std::task::Context::from_waker(std::task::Waker::noop());
18174 assert!(matches!(
18175 Future::poll(running_service.as_mut(), &mut task_cx),
18176 Poll::Pending
18177 ));
18178 let mut router = Router::new();
18179 router.set_final_task_runtime(Some(runtime));
18180 router
18181 .add_tool(TaskCapableRouterTool {
18182 final_calls: Arc::clone(&final_calls),
18183 })
18184 .expect("tool registration succeeds");
18185 let cx = Cx::for_testing();
18186 let state = SessionState::new();
18187 let request_ctx = request_context(&cx, 160, Budget::INFINITE, &state);
18188
18189 let result = router
18190 .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(160_i64))
18191 .expect("the admitted task-capable outcome creates a work-bound task");
18192 assert_eq!(result["resultType"], "task");
18193 assert_eq!(result["status"], "working");
18194 assert_eq!(result["statusMessage"], "router task created");
18195 assert!(
18196 result["taskId"].as_str().is_some(),
18197 "the created task has a final task identifier"
18198 );
18199 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18200 assert_eq!(store.task_count(), 1);
18201 }
18202
18203 #[cfg(feature = "tasks")]
18204 #[test]
18205 fn final_task_declaration_gates_only_the_create_task_outcome() {
18206 let final_calls = Arc::new(AtomicUsize::new(0));
18207 let store = Arc::new(InMemoryFinalTaskStore::default());
18208 let mut router = Router::new();
18209 router
18210 .add_tool(ConditionalTaskCapableRouterTool {
18211 final_calls: Arc::clone(&final_calls),
18212 })
18213 .expect("conditional task-capable tool registration succeeds");
18214 let cx = Cx::for_testing();
18215 let state = SessionState::new();
18216 let request_ctx = request_context(&cx, 164, Budget::INFINITE, &state);
18217
18218 let complete = final_tools_call_request(
18219 "conditional-task-capable-router-tool",
18220 serde_json::json!({ "createTask": false }),
18221 164_i64,
18222 );
18223 let task = final_tools_call_request(
18224 "conditional-task-capable-router-tool",
18225 serde_json::json!({ "createTask": true }),
18226 165_i64,
18227 );
18228 assert_eq!(complete.method, task.method);
18229 assert_eq!(
18230 complete
18231 .params
18232 .as_ref()
18233 .and_then(|params| params.get("_meta")),
18234 task.params.as_ref().and_then(|params| params.get("_meta")),
18235 "Tasks negotiation is unchanged between the paired requests"
18236 );
18237
18238 let result = router
18239 .dispatch_stateless(&request_ctx, &complete)
18240 .expect("a declared task-capable handler may complete without Tasks negotiation");
18241 assert_eq!(result["resultType"], "complete");
18242 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18243 assert_eq!(store.task_count(), 0);
18244
18245 let error = router
18246 .dispatch_stateless(&request_ctx, &task)
18247 .expect_err("only the CreateTask outcome requires Tasks negotiation");
18248 assert!(matches!(error.code, McpErrorCode::Custom(_)));
18249 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
18250 assert_eq!(
18251 store.task_count(),
18252 0,
18253 "a rejected CreateTask outcome must not mutate the task store"
18254 );
18255 }
18256
18257 #[cfg(feature = "tasks")]
18258 #[test]
18259 fn final_task_outcome_without_runtime_rejects_after_handler_before_store_mutation() {
18260 let final_calls = Arc::new(AtomicUsize::new(0));
18261 let store = Arc::new(InMemoryFinalTaskStore::default());
18262 let mut router = Router::new();
18263 router
18264 .add_tool(TaskCapableRouterTool {
18265 final_calls: Arc::clone(&final_calls),
18266 })
18267 .expect("tool registration succeeds");
18268 let cx = Cx::for_testing();
18269 let state = SessionState::new();
18270 let request_ctx = request_context(&cx, 161, Budget::INFINITE, &state);
18271
18272 let error = router
18273 .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(161_i64))
18274 .expect_err("a CreateTask outcome cannot persist without a final Tasks runtime");
18275 assert_eq!(error.code, McpErrorCode::InternalError);
18276 assert_eq!(
18277 error.message,
18278 "task-capable tool requires an installed final Tasks runtime"
18279 );
18280 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18281 assert_eq!(
18282 store.task_count(),
18283 0,
18284 "outcome-aware runtime admission rejects before task-store mutation"
18285 );
18286 }
18287
18288 #[cfg(feature = "tasks")]
18289 #[test]
18290 fn final_task_outcome_with_unready_service_rejects_after_handler_before_store_mutation() {
18291 let final_calls = Arc::new(AtomicUsize::new(0));
18292 let store = Arc::new(InMemoryFinalTaskStore::default());
18293 let runtime = task_runtime_for_router(Arc::clone(&store));
18294 let _unready_service = runtime
18295 .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
18296 .expect("an installed but unpolled task service remains unready");
18297 let mut router = Router::new();
18298 router.set_final_task_runtime(Some(runtime));
18299 router
18300 .add_tool(TaskCapableRouterTool {
18301 final_calls: Arc::clone(&final_calls),
18302 })
18303 .expect("tool registration succeeds");
18304 let cx = Cx::for_testing();
18305 let state = SessionState::new();
18306 let request_ctx = request_context(&cx, 162, Budget::INFINITE, &state);
18307
18308 let error = router
18309 .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(162_i64))
18310 .expect_err("an installed but unready task service is refused before task creation");
18311 assert_eq!(error.code, McpErrorCode::InvalidParams);
18312 assert_eq!(
18313 error.message,
18314 "Final task creation requires an installed ready task service"
18315 );
18316 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18317 assert_eq!(
18318 store.task_count(),
18319 0,
18320 "outcome-aware readiness admission cannot persist a task"
18321 );
18322 }
18323
18324 #[cfg(feature = "tasks")]
18325 #[test]
18326 fn final_task_outcome_requires_peer_capability_before_store_mutation() {
18327 let final_calls = Arc::new(AtomicUsize::new(0));
18328 let store = Arc::new(InMemoryFinalTaskStore::default());
18329 let runtime = task_runtime_for_router(Arc::clone(&store));
18330 let service_runner = runtime
18331 .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
18332 .expect("a bounded application-owned task service is installed");
18333 let service_cx = Cx::for_testing();
18334 let mut running_service = Box::pin(service_runner.run(&service_cx));
18335 let mut task_cx = std::task::Context::from_waker(std::task::Waker::noop());
18336 assert!(matches!(
18337 Future::poll(running_service.as_mut(), &mut task_cx),
18338 Poll::Pending
18339 ));
18340 let mut router = Router::new();
18341 router.set_final_task_runtime(Some(runtime));
18342 router
18343 .add_tool(TaskCapableRouterTool {
18344 final_calls: Arc::clone(&final_calls),
18345 })
18346 .expect("tool registration succeeds");
18347 let cx = Cx::for_testing();
18348 let state = SessionState::new();
18349 let request_ctx = request_context(&cx, 162, Budget::INFINITE, &state);
18350
18351 let error = router
18352 .dispatch_stateless(
18353 &request_ctx,
18354 &final_tools_call_request(
18355 "task-capable-router-tool",
18356 serde_json::json!({}),
18357 162_i64,
18358 ),
18359 )
18360 .expect_err("a missing peer Tasks capability is refused before task-store mutation");
18361 assert!(matches!(error.code, McpErrorCode::Custom(_)));
18362 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18363 assert_eq!(
18364 store.task_count(),
18365 0,
18366 "a rejected CreateTask outcome must not reach the task store"
18367 );
18368 }
18369
18370 #[cfg(feature = "tasks")]
18371 #[test]
18372 fn final_router_defensively_rejects_an_undeclared_task_outcome() {
18373 let final_calls = Arc::new(AtomicUsize::new(0));
18374 let mut router = Router::new();
18375 router
18376 .add_tool(UndeclaredTaskOutcomeRouterTool {
18377 final_calls: Arc::clone(&final_calls),
18378 })
18379 .expect("tool registration succeeds");
18380 let cx = Cx::for_testing();
18381 let state = SessionState::new();
18382 let request_ctx = request_context(&cx, 163, Budget::INFINITE, &state);
18383 let request = JsonRpcRequest::new(
18384 "tools/call",
18385 Some(serde_json::json!({
18386 "_meta": {
18387 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18388 "io.modelcontextprotocol/clientCapabilities": {
18389 "extensions": {
18390 "io.modelcontextprotocol/tasks": {}
18391 }
18392 },
18393 },
18394 "name": "undeclared-task-outcome-router-tool",
18395 "arguments": {},
18396 })),
18397 163_i64,
18398 );
18399
18400 let error = router
18401 .dispatch_stateless(&request_ctx, &request)
18402 .expect_err("the router refuses a task outcome without a preflight declaration");
18403 assert_eq!(error.code, McpErrorCode::InvalidRequest);
18404 assert_eq!(
18405 error.message,
18406 "tool returned CreateTask without declaring final Tasks capability"
18407 );
18408 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18409 }
18410
18411 #[test]
18412 fn macro_tool_final_metadata_negative_is_non_mutating() {
18413 let _counter_guard = MACRO_DUAL_ERA_TOOL_LOCK
18414 .lock()
18415 .unwrap_or_else(std::sync::PoisonError::into_inner);
18416 let mut router = Router::new();
18417 MACRO_DUAL_ERA_TOOL_CALLS.store(0, Ordering::SeqCst);
18418 router
18419 .add_tool(MacroDualEraTool)
18420 .expect("macro tool registration succeeds");
18421 let cx = Cx::for_testing();
18422 let state = SessionState::new();
18423 let request_ctx = request_context(&cx, 92, Budget::INFINITE, &state);
18424 let baseline = JsonRpcRequest::new(
18425 "tools/call",
18426 Some(serde_json::json!({
18427 "_meta": {
18428 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18429 "io.modelcontextprotocol/clientCapabilities": {},
18430 },
18431 "name": "macro_dual_era_tool",
18432 "arguments": {},
18433 })),
18434 92_i64,
18435 );
18436 let mut planted = baseline.clone();
18437 planted
18438 .params
18439 .as_mut()
18440 .and_then(serde_json::Value::as_object_mut)
18441 .expect("tools/call parameters are an object")
18442 .remove("_meta");
18443
18444 assert_eq!(baseline.method, planted.method);
18445 assert_eq!(baseline.id, planted.id);
18446 assert_eq!(
18447 baseline
18448 .params
18449 .as_ref()
18450 .and_then(|params| params.get("name")),
18451 planted
18452 .params
18453 .as_ref()
18454 .and_then(|params| params.get("name")),
18455 "the final metadata object is the sole planted dimension"
18456 );
18457 let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
18458 let planted_before = serde_json::to_vec(&planted).expect("request serializes");
18459
18460 let baseline_result = router
18461 .dispatch_stateless(&request_ctx, &baseline)
18462 .expect("the baseline invokes the registered handler");
18463 assert_eq!(
18464 baseline_result.get("resultType"),
18465 Some(&serde_json::json!("complete"))
18466 );
18467 assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 1);
18468
18469 let error = router
18470 .dispatch_stateless(&request_ctx, &planted)
18471 .expect_err("only final metadata is refused");
18472 assert_eq!(error.code, McpErrorCode::InvalidParams);
18473 assert_eq!(
18474 serde_json::to_vec(&planted).expect("rejected request serializes"),
18475 planted_before,
18476 "typed refusal cannot mutate caller-owned input"
18477 );
18478 assert_eq!(
18479 serde_json::to_vec(&router.tools()).expect("catalog serializes"),
18480 catalog_before,
18481 "typed refusal cannot mutate the installed handler catalog"
18482 );
18483 assert_eq!(
18484 MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst),
18485 1,
18486 "metadata refusal cannot invoke the macro-generated tool"
18487 );
18488 assert_eq!(
18489 router
18490 .dispatch_stateless(&request_ctx, &baseline)
18491 .expect("the unchanged baseline remains accepted after the rejection"),
18492 baseline_result,
18493 "the one-field rejection cannot alter the accepted final result"
18494 );
18495 assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 2);
18496 }
18497
18498 #[test]
18499 fn public_final_dispatch_encodes_input_required_handler_outcomes() {
18500 let tool_legacy_calls = Arc::new(AtomicUsize::new(0));
18501 let tool_final_calls = Arc::new(AtomicUsize::new(0));
18502 let resource_legacy_calls = Arc::new(AtomicUsize::new(0));
18503 let resource_final_calls = Arc::new(AtomicUsize::new(0));
18504 let prompt_legacy_calls = Arc::new(AtomicUsize::new(0));
18505 let prompt_final_calls = Arc::new(AtomicUsize::new(0));
18506 let mut router = Router::new();
18507 router
18508 .add_tool(InputRequiredTool {
18509 legacy_calls: Arc::clone(&tool_legacy_calls),
18510 final_calls: Arc::clone(&tool_final_calls),
18511 })
18512 .expect("tool registration succeeds");
18513 router.add_resource(InputRequiredResource {
18514 legacy_calls: Arc::clone(&resource_legacy_calls),
18515 final_calls: Arc::clone(&resource_final_calls),
18516 });
18517 router.add_prompt(InputRequiredPrompt {
18518 legacy_calls: Arc::clone(&prompt_legacy_calls),
18519 final_calls: Arc::clone(&prompt_final_calls),
18520 });
18521
18522 let cx = Cx::for_testing();
18523 let connection = ModernConnection::new();
18524 let inbound = InboundRequestContext::with_modern_connection(
18525 cx,
18526 141,
18527 InboundRequestTransport::Memory,
18528 &connection,
18529 );
18530 let request_ctx = inbound.request_context();
18531 let metadata = serde_json::json!({
18532 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18533 "io.modelcontextprotocol/clientCapabilities": {},
18534 });
18535
18536 let tool_request = JsonRpcRequest::new(
18537 "tools/call",
18538 Some(serde_json::json!({
18539 "_meta": metadata.clone(),
18540 "name": "input-required-tool",
18541 "arguments": {},
18542 })),
18543 141_i64,
18544 );
18545 let tool_typed = CoreRequest::decode(
18546 ProtocolEra::Modern2026,
18547 "tools/call",
18548 tool_request.params.as_ref(),
18549 )
18550 .expect("final tools/call request decodes");
18551 let tool_response = router
18552 .dispatch_stateless(&request_ctx, &tool_request)
18553 .expect("public final tools/call dispatch encodes input_required");
18554 assert_eq!(tool_response["resultType"], "input_required");
18555 assert_ne!(tool_response["requestState"], "tool-retry-state");
18556 assert_eq!(
18557 tool_response["inputRequests"]["roots"]["method"],
18558 "roots/list"
18559 );
18560 let tool_wire = serde_json::to_string(&tool_response).expect("tool response serializes");
18561 assert!(matches!(
18562 tool_typed.decode_result(&tool_wire),
18563 Ok(CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { result, .. }))
18564 if result.request_state().is_some()
18565 ));
18566
18567 let resource_request = JsonRpcRequest::new(
18568 "resources/read",
18569 Some(serde_json::json!({
18570 "_meta": metadata.clone(),
18571 "uri": "file:///input-required-resource",
18572 })),
18573 142_i64,
18574 );
18575 let resource_typed = CoreRequest::decode(
18576 ProtocolEra::Modern2026,
18577 "resources/read",
18578 resource_request.params.as_ref(),
18579 )
18580 .expect("final resources/read request decodes");
18581 let resource_response = router
18582 .dispatch_stateless(&request_ctx, &resource_request)
18583 .expect("public final resources/read dispatch encodes input_required");
18584 assert_eq!(resource_response["resultType"], "input_required");
18585 assert_ne!(resource_response["requestState"], "resource-retry-state");
18586 assert_eq!(
18587 resource_response["inputRequests"]["roots"]["method"],
18588 "roots/list"
18589 );
18590 let resource_wire =
18591 serde_json::to_string(&resource_response).expect("resource response serializes");
18592 assert!(matches!(
18593 resource_typed.decode_result(&resource_wire),
18594 Ok(CoreResult::Final(FinalCoreResult::ResourcesReadInputRequired { result, .. }))
18595 if result.request_state().is_some()
18596 ));
18597
18598 let prompt_request = JsonRpcRequest::new(
18599 "prompts/get",
18600 Some(serde_json::json!({
18601 "_meta": metadata,
18602 "name": "input-required-prompt",
18603 })),
18604 143_i64,
18605 );
18606 let prompt_typed = CoreRequest::decode(
18607 ProtocolEra::Modern2026,
18608 "prompts/get",
18609 prompt_request.params.as_ref(),
18610 )
18611 .expect("final prompts/get request decodes");
18612 let prompt_response = router
18613 .dispatch_stateless(&request_ctx, &prompt_request)
18614 .expect("public final prompts/get dispatch encodes input_required");
18615 assert_eq!(prompt_response["resultType"], "input_required");
18616 assert_ne!(prompt_response["requestState"], "prompt-retry-state");
18617 assert_eq!(
18618 prompt_response["inputRequests"]["roots"]["method"],
18619 "roots/list"
18620 );
18621 let prompt_wire =
18622 serde_json::to_string(&prompt_response).expect("prompt response serializes");
18623 assert!(matches!(
18624 prompt_typed.decode_result(&prompt_wire),
18625 Ok(CoreResult::Final(FinalCoreResult::PromptsGetInputRequired { result, .. }))
18626 if result.request_state().is_some()
18627 ));
18628
18629 assert_eq!(tool_legacy_calls.load(Ordering::SeqCst), 0);
18630 assert_eq!(resource_legacy_calls.load(Ordering::SeqCst), 0);
18631 assert_eq!(prompt_legacy_calls.load(Ordering::SeqCst), 0);
18632 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
18633 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
18634 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
18635 }
18636
18637 #[test]
18638 fn final_context_elicitation_round_trips_through_tools_call_mrtr() {
18639 let initial_calls = Arc::new(AtomicUsize::new(0));
18640 let resumed_calls = Arc::new(AtomicUsize::new(0));
18641 let mut router = Router::new();
18642 router
18643 .add_tool(ContextElicitationTool {
18644 initial_calls: Arc::clone(&initial_calls),
18645 resumed_calls: Arc::clone(&resumed_calls),
18646 })
18647 .expect("context elicitation tool registers");
18648
18649 let cx = Cx::for_testing();
18650 let connection = ModernConnection::new();
18651 let mut initial_request =
18652 final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 940_i64);
18653 initial_request
18654 .params
18655 .as_mut()
18656 .expect("tool params are present")["_meta"]["io.modelcontextprotocol/clientCapabilities"] = serde_json::json!({
18657 "elicitation": {"form": {}},
18658 });
18659 let initial_context = InboundRequestContext::with_modern_connection(
18660 cx.clone(),
18661 940,
18662 InboundRequestTransport::Memory,
18663 &connection,
18664 )
18665 .request_context()
18666 .with_client_capabilities(ClientCapabilityInfo::new().with_elicitation(true, false));
18667
18668 let initial = router
18669 .dispatch_stateless(&initial_context, &initial_request)
18670 .expect("an elicitation-capable final client receives MRTR input_required");
18671 assert_eq!(initial["resultType"], "input_required");
18672 assert_eq!(
18673 initial["inputRequests"]["approval"]["method"],
18674 "elicitation/create"
18675 );
18676 assert_eq!(
18677 initial["inputRequests"]["approval"]["params"]["mode"],
18678 "form"
18679 );
18680 assert!(
18681 initial["inputRequests"]["approval"]
18682 .get("jsonrpc")
18683 .is_none()
18684 && initial["inputRequests"]["approval"].get("id").is_none(),
18685 "final elicitation must be embedded input, never a reverse JSON-RPC request"
18686 );
18687 let request_state = initial["requestState"]
18688 .as_str()
18689 .expect("router mints an opaque MRTR request state")
18690 .to_owned();
18691
18692 let mut retry_request =
18693 final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 941_i64);
18694 let retry_params = retry_request
18695 .params
18696 .as_mut()
18697 .expect("tool params are present");
18698 retry_params["_meta"]["io.modelcontextprotocol/clientCapabilities"] = serde_json::json!({
18699 "elicitation": {"form": {}},
18700 });
18701 retry_params["inputResponses"] = serde_json::json!({
18702 "approval": {"action": "accept", "content": {"approved": true}},
18703 });
18704 retry_params["requestState"] = serde_json::Value::String(request_state);
18705 let retry_context = InboundRequestContext::with_modern_connection(
18706 cx,
18707 941,
18708 InboundRequestTransport::Memory,
18709 &connection,
18710 )
18711 .request_context()
18712 .with_client_capabilities(ClientCapabilityInfo::new().with_elicitation(true, false));
18713
18714 let completed = router
18715 .dispatch_stateless(&retry_context, &retry_request)
18716 .expect("the accepted final elicitation resumes the original tools/call");
18717 assert_eq!(completed["resultType"], "complete");
18718 assert_eq!(completed["content"][0]["text"], "approved");
18719 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
18720 assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
18721 }
18722
18723 #[test]
18724 fn final_context_elicitation_without_capability_rejects_before_mrtr_state_mutation() {
18725 let initial_calls = Arc::new(AtomicUsize::new(0));
18726 let resumed_calls = Arc::new(AtomicUsize::new(0));
18727 let mut router = Router::new();
18728 router
18729 .add_tool(ContextElicitationTool {
18730 initial_calls: Arc::clone(&initial_calls),
18731 resumed_calls: Arc::clone(&resumed_calls),
18732 })
18733 .expect("context elicitation tool registers");
18734
18735 let cx = Cx::for_testing();
18736 let connection = ModernConnection::new();
18737 let request =
18738 final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 942_i64);
18739 let context = InboundRequestContext::with_modern_connection(
18740 cx,
18741 942,
18742 InboundRequestTransport::Memory,
18743 &connection,
18744 )
18745 .request_context();
18746
18747 let error = router
18748 .dispatch_stateless(&context, &request)
18749 .expect_err("changing only client elicitation capability must refuse the request");
18750 assert_eq!(error.code, McpErrorCode::InvalidRequest);
18751 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
18752 assert_eq!(resumed_calls.load(Ordering::SeqCst), 0);
18753 assert_eq!(
18754 router.mrtr_exchanges.active_len(),
18755 0,
18756 "capability refusal cannot mint MRTR continuation state"
18757 );
18758 }
18759
18760 #[test]
18761 fn final_sampling_capability_preserves_tool_choice_and_rejection_leaves_registry_unchanged() {
18762 let calls = Arc::new(AtomicUsize::new(0));
18763 let mut router = Router::new();
18764 router
18765 .add_tool(ContextSamplingTool {
18766 calls: Arc::clone(&calls),
18767 })
18768 .expect("context sampling tool registers");
18769
18770 let cx = Cx::for_testing();
18771 let connection = ModernConnection::new();
18772 let request =
18773 final_tools_call_request("context-sampling-tool", serde_json::json!({}), 943_i64);
18774 let admitted_context = InboundRequestContext::with_modern_connection(
18775 cx.clone(),
18776 943,
18777 InboundRequestTransport::Memory,
18778 &connection,
18779 )
18780 .request_context()
18781 .with_client_capabilities(ClientCapabilityInfo::new().with_sampling());
18782 let admitted = router
18783 .dispatch_stateless(&admitted_context, &request)
18784 .expect("sampling capability admits final MRTR sampling");
18785 assert_eq!(admitted["resultType"], "input_required");
18786 assert_eq!(
18787 admitted["inputRequests"]["sample"]["params"]["toolChoice"],
18788 serde_json::json!({"mode": "required"})
18789 );
18790 assert_eq!(
18791 admitted["inputRequests"]["sample"]["params"]["messages"][0]["content"]["type"],
18792 "tool_use"
18793 );
18794 assert_eq!(router.mrtr_exchanges.active_len(), 1);
18795
18796 let removed_capability_context = InboundRequestContext::with_modern_connection(
18797 cx,
18798 944,
18799 InboundRequestTransport::Memory,
18800 &connection,
18801 )
18802 .request_context();
18803 let rejection = router
18804 .dispatch_stateless(&removed_capability_context, &request)
18805 .expect_err("removing only sampling capability rejects the descriptor");
18806 assert_eq!(rejection.code, McpErrorCode::InvalidRequest);
18807 assert_eq!(
18808 router.mrtr_exchanges.active_len(),
18809 1,
18810 "the one-field capability removal cannot mutate the admitted registry"
18811 );
18812 assert_eq!(calls.load(Ordering::SeqCst), 2);
18813 }
18814
18815 #[test]
18816 fn http_admission_raw_sidecar_reaches_tool_resource_and_prompt_mrtr_retries() {
18817 let tool_calls = Arc::new(AtomicUsize::new(0));
18818 let resource_calls = Arc::new(AtomicUsize::new(0));
18819 let prompt_calls = Arc::new(AtomicUsize::new(0));
18820 let mut router = Router::new();
18821 router
18822 .add_tool(InputRequiredTool {
18823 legacy_calls: Arc::new(AtomicUsize::new(0)),
18824 final_calls: Arc::clone(&tool_calls),
18825 })
18826 .expect("tool registers");
18827 router.add_resource(InputRequiredResource {
18828 legacy_calls: Arc::new(AtomicUsize::new(0)),
18829 final_calls: Arc::clone(&resource_calls),
18830 });
18831 router.add_prompt(InputRequiredPrompt {
18832 legacy_calls: Arc::new(AtomicUsize::new(0)),
18833 final_calls: Arc::clone(&prompt_calls),
18834 });
18835
18836 let cx = Cx::for_testing();
18837 let connection = ModernConnection::new();
18838 let inbound = InboundRequestContext::with_modern_connection(
18839 cx,
18840 1438,
18841 InboundRequestTransport::Http,
18842 &connection,
18843 );
18844 let request_ctx = inbound.request_context();
18845 let cancellation = inbound
18846 .mrtr_continuation_cancellation()
18847 .expect("HTTP-bound modern context owns continuations");
18848 let metadata = serde_json::json!({
18849 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18850 "io.modelcontextprotocol/clientCapabilities": {},
18851 });
18852 let roots = serde_json::to_string(&router_roots_response_wire())
18853 .expect("roots response serializes");
18854
18855 let tool_initial_body = serde_json::to_vec(&serde_json::json!({
18856 "jsonrpc": "2.0", "id": 1438, "method": "tools/call",
18857 "params": {"_meta": metadata.clone(), "name": "input-required-tool", "arguments": {}},
18858 }))
18859 .expect("tool body serializes");
18860 let (tool_initial, tool_initial_raw) =
18861 admit_http_wire("tools/call", "input-required-tool", &tool_initial_body);
18862 let tool_issued = router
18863 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
18864 &request_ctx,
18865 &tool_initial,
18866 tool_initial_raw.as_deref(),
18867 &cancellation,
18868 )
18869 .expect("HTTP-admitted tool issues state");
18870 let tool_retry_body = format!(
18871 r#"{{"jsonrpc":"2.0","id":1439,"method":"tools/call","params":{{"_meta":{},"name":"input-required-tool","arguments":{{}},"inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
18872 serde_json::to_string(&metadata).expect("metadata serializes"),
18873 roots,
18874 roots,
18875 serde_json::to_string(&tool_issued["requestState"]).expect("tool state serializes"),
18876 );
18877 let (tool_retry, tool_retry_raw) = admit_http_wire(
18878 "tools/call",
18879 "input-required-tool",
18880 tool_retry_body.as_bytes(),
18881 );
18882 router
18883 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
18884 &request_ctx,
18885 &tool_retry,
18886 tool_retry_raw.as_deref(),
18887 &cancellation,
18888 )
18889 .expect("HTTP-admitted ordered tool retry reaches the handler");
18890
18891 let resource_initial_body = serde_json::to_vec(&serde_json::json!({
18892 "jsonrpc": "2.0", "id": 1440, "method": "resources/read",
18893 "params": {"_meta": metadata.clone(), "uri": "file:///input-required-resource"},
18894 }))
18895 .expect("resource body serializes");
18896 let (resource_initial, resource_initial_raw) = admit_http_wire(
18897 "resources/read",
18898 "file:///input-required-resource",
18899 &resource_initial_body,
18900 );
18901 let resource_issued = router
18902 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
18903 &request_ctx,
18904 &resource_initial,
18905 resource_initial_raw.as_deref(),
18906 &cancellation,
18907 )
18908 .expect("HTTP-admitted resource issues state");
18909 let resource_retry_body = format!(
18910 r#"{{"jsonrpc":"2.0","id":1441,"method":"resources/read","params":{{"_meta":{},"uri":"file:///input-required-resource","inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
18911 serde_json::to_string(&metadata).expect("metadata serializes"),
18912 roots,
18913 roots,
18914 serde_json::to_string(&resource_issued["requestState"])
18915 .expect("resource state serializes"),
18916 );
18917 let (resource_retry, resource_retry_raw) = admit_http_wire(
18918 "resources/read",
18919 "file:///input-required-resource",
18920 resource_retry_body.as_bytes(),
18921 );
18922 let mut sanitized_resource_retry = resource_retry.clone();
18923 sanitized_resource_retry
18924 .params
18925 .as_mut()
18926 .and_then(serde_json::Value::as_object_mut)
18927 .expect("HTTP-admitted resource parameters are an object")
18928 .insert(
18929 "uri".to_owned(),
18930 serde_json::json!("file:///sanitized-resource"),
18931 );
18932 let resource_sidecar_error = router
18933 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
18934 &request_ctx,
18935 &sanitized_resource_retry,
18936 resource_retry_raw.as_deref(),
18937 &cancellation,
18938 )
18939 .expect_err(
18940 "a resource raw sidecar cannot survive a one-field sanitized typed mismatch",
18941 );
18942 assert_eq!(resource_sidecar_error.code, McpErrorCode::InvalidParams);
18943 assert_eq!(
18944 resource_calls.load(Ordering::SeqCst),
18945 1,
18946 "the mismatched resource sidecar is rejected before handler invocation"
18947 );
18948 assert_eq!(
18949 router.mrtr_exchanges.active_len(),
18950 2,
18951 "the rejected resource sidecar leaves its continuation available"
18952 );
18953 router
18954 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
18955 &request_ctx,
18956 &resource_retry,
18957 resource_retry_raw.as_deref(),
18958 &cancellation,
18959 )
18960 .expect("HTTP-admitted ordered resource retry reaches the handler");
18961
18962 let prompt_initial_body = serde_json::to_vec(&serde_json::json!({
18963 "jsonrpc": "2.0", "id": 1442, "method": "prompts/get",
18964 "params": {"_meta": metadata.clone(), "name": "input-required-prompt"},
18965 }))
18966 .expect("prompt body serializes");
18967 let (prompt_initial, prompt_initial_raw) =
18968 admit_http_wire("prompts/get", "input-required-prompt", &prompt_initial_body);
18969 let prompt_issued = router
18970 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
18971 &request_ctx,
18972 &prompt_initial,
18973 prompt_initial_raw.as_deref(),
18974 &cancellation,
18975 )
18976 .expect("HTTP-admitted prompt issues state");
18977 let prompt_retry_body = format!(
18978 r#"{{"jsonrpc":"2.0","id":1443,"method":"prompts/get","params":{{"_meta":{},"name":"input-required-prompt","inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
18979 serde_json::to_string(&metadata).expect("metadata serializes"),
18980 roots,
18981 roots,
18982 serde_json::to_string(&prompt_issued["requestState"]).expect("prompt state serializes"),
18983 );
18984 let (prompt_retry, prompt_retry_raw) = admit_http_wire(
18985 "prompts/get",
18986 "input-required-prompt",
18987 prompt_retry_body.as_bytes(),
18988 );
18989 let mut sanitized_prompt_retry = prompt_retry.clone();
18990 sanitized_prompt_retry
18991 .params
18992 .as_mut()
18993 .and_then(serde_json::Value::as_object_mut)
18994 .expect("HTTP-admitted prompt parameters are an object")
18995 .insert("name".to_owned(), serde_json::json!("sanitized-prompt"));
18996 let prompt_sidecar_error = router
18997 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
18998 &request_ctx,
18999 &sanitized_prompt_retry,
19000 prompt_retry_raw.as_deref(),
19001 &cancellation,
19002 )
19003 .expect_err("a prompt raw sidecar cannot survive a one-field sanitized typed mismatch");
19004 assert_eq!(prompt_sidecar_error.code, McpErrorCode::InvalidParams);
19005 assert_eq!(
19006 prompt_calls.load(Ordering::SeqCst),
19007 1,
19008 "the mismatched prompt sidecar is rejected before handler invocation"
19009 );
19010 assert_eq!(
19011 router.mrtr_exchanges.active_len(),
19012 3,
19013 "the rejected prompt sidecar leaves its continuation available"
19014 );
19015 router
19016 .dispatch_stateless_with_continuation_cancellation_and_raw_params(
19017 &request_ctx,
19018 &prompt_retry,
19019 prompt_retry_raw.as_deref(),
19020 &cancellation,
19021 )
19022 .expect("HTTP-admitted ordered prompt retry reaches the handler");
19023
19024 assert_eq!(tool_calls.load(Ordering::SeqCst), 2);
19025 assert_eq!(resource_calls.load(Ordering::SeqCst), 2);
19026 assert_eq!(prompt_calls.load(Ordering::SeqCst), 2);
19027 }
19028
19029 #[test]
19030 fn stateless_final_dispatch_keeps_complete_only_handlers_available_for_all_families() {
19031 let mut router = Router::new();
19032 router
19033 .add_tool(NamedTool::new("complete-only-tool"))
19034 .expect("complete-only tool registers");
19035 router.add_resource(NamedResource::new("file:///complete-only-resource"));
19036 router.add_prompt(NamedPrompt::new("complete-only-prompt"));
19037
19038 let cx = Cx::for_testing();
19039 let state = SessionState::new();
19040 let request_ctx = request_context(&cx, 1430, Budget::INFINITE, &state);
19041 let metadata = serde_json::json!({
19042 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19043 "io.modelcontextprotocol/clientCapabilities": {},
19044 });
19045
19046 let tool = router
19047 .dispatch_stateless(
19048 &request_ctx,
19049 &final_tools_call_request("complete-only-tool", serde_json::json!({}), 1430_i64),
19050 )
19051 .expect("a complete-only tool remains available without a session partition");
19052 assert_eq!(tool["resultType"], "complete");
19053
19054 let resource = router
19055 .dispatch_stateless(
19056 &request_ctx,
19057 &JsonRpcRequest::new(
19058 "resources/read",
19059 Some(serde_json::json!({
19060 "_meta": metadata.clone(),
19061 "uri": "file:///complete-only-resource",
19062 })),
19063 1431_i64,
19064 ),
19065 )
19066 .expect("a complete-only resource remains available without a session partition");
19067 assert_eq!(resource["resultType"], "complete");
19068
19069 let prompt = router
19070 .dispatch_stateless(
19071 &request_ctx,
19072 &JsonRpcRequest::new(
19073 "prompts/get",
19074 Some(serde_json::json!({
19075 "_meta": metadata,
19076 "name": "complete-only-prompt",
19077 })),
19078 1432_i64,
19079 ),
19080 )
19081 .expect("a complete-only prompt remains available without a session partition");
19082 assert_eq!(prompt["resultType"], "complete");
19083 }
19084
19085 #[test]
19086 fn stateless_mrtr_handlers_are_rejected_before_all_family_invocations() {
19087 let tool_calls = Arc::new(AtomicUsize::new(0));
19088 let resource_calls = Arc::new(AtomicUsize::new(0));
19089 let prompt_calls = Arc::new(AtomicUsize::new(0));
19090 let mut router = Router::new();
19091 router
19092 .add_tool(InputRequiredTool {
19093 legacy_calls: Arc::new(AtomicUsize::new(0)),
19094 final_calls: Arc::clone(&tool_calls),
19095 })
19096 .expect("MRTR tool registers");
19097 router.add_resource(InputRequiredResource {
19098 legacy_calls: Arc::new(AtomicUsize::new(0)),
19099 final_calls: Arc::clone(&resource_calls),
19100 });
19101 router.add_prompt(InputRequiredPrompt {
19102 legacy_calls: Arc::new(AtomicUsize::new(0)),
19103 final_calls: Arc::clone(&prompt_calls),
19104 });
19105
19106 let cx = Cx::for_testing();
19107 let state = SessionState::new();
19108 let request_ctx = request_context(&cx, 1433, Budget::INFINITE, &state);
19109 let metadata = serde_json::json!({
19110 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19111 "io.modelcontextprotocol/clientCapabilities": {},
19112 });
19113
19114 let tool_error = router
19115 .dispatch_stateless(
19116 &request_ctx,
19117 &final_tools_call_request("input-required-tool", serde_json::json!({}), 1433_i64),
19118 )
19119 .expect_err("a stateless MRTR tool is rejected before its handler runs");
19120 assert_eq!(tool_error.code, McpErrorCode::InvalidParams);
19121
19122 let resource_error = router
19123 .dispatch_stateless(
19124 &request_ctx,
19125 &JsonRpcRequest::new(
19126 "resources/read",
19127 Some(serde_json::json!({
19128 "_meta": metadata.clone(),
19129 "uri": "file:///input-required-resource",
19130 })),
19131 1434_i64,
19132 ),
19133 )
19134 .expect_err("a stateless MRTR resource is rejected before its handler runs");
19135 assert_eq!(resource_error.code, McpErrorCode::InvalidParams);
19136
19137 let prompt_error = router
19138 .dispatch_stateless(
19139 &request_ctx,
19140 &JsonRpcRequest::new(
19141 "prompts/get",
19142 Some(serde_json::json!({
19143 "_meta": metadata,
19144 "name": "input-required-prompt",
19145 })),
19146 1435_i64,
19147 ),
19148 )
19149 .expect_err("a stateless MRTR prompt is rejected before its handler runs");
19150 assert_eq!(prompt_error.code, McpErrorCode::InvalidParams);
19151 assert_eq!(tool_calls.load(Ordering::SeqCst), 0);
19152 assert_eq!(resource_calls.load(Ordering::SeqCst), 0);
19153 assert_eq!(prompt_calls.load(Ordering::SeqCst), 0);
19154 assert_eq!(router.mrtr_exchanges.active_len(), 0);
19155 }
19156
19157 #[test]
19158 fn bound_owned_mrtr_retry_rejects_foreign_and_stale_state_without_mutation_then_resumes() {
19159 let calls = Arc::new(AtomicUsize::new(0));
19160 let mut unshared_router = Router::new();
19161 unshared_router
19162 .add_tool(OneShotMrtrTool {
19163 calls: Arc::clone(&calls),
19164 })
19165 .expect("one-shot MRTR tool registers");
19166 let router = Arc::new(unshared_router);
19167 let cx = Cx::for_testing();
19168 let origin_connection = ModernConnection::new();
19169 let origin_inbound = InboundRequestContext::with_modern_connection(
19170 cx.clone(),
19171 1436,
19172 InboundRequestTransport::Memory,
19173 &origin_connection,
19174 );
19175 let origin_ctx = origin_inbound.request_context();
19176 let origin_cancellation = origin_inbound
19177 .mrtr_continuation_cancellation()
19178 .expect("a bound origin supplies continuation ownership");
19179 let initial =
19180 final_tools_call_request("one-shot-mrtr-tool", serde_json::json!({}), 1436_i64);
19181 let issued = block_on(
19182 Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
19183 origin_ctx,
19184 initial,
19185 origin_cancellation.clone(),
19186 ),
19187 )
19188 .expect("the bound owned path issues one continuation");
19189 let request_state = issued["requestState"]
19190 .as_str()
19191 .expect("issued continuation has opaque state")
19192 .to_owned();
19193 assert_eq!(calls.load(Ordering::SeqCst), 1);
19194 assert_eq!(router.mrtr_exchanges.active_len(), 1);
19195
19196 let retry = JsonRpcRequest::new(
19197 "tools/call",
19198 Some(serde_json::json!({
19199 "_meta": {
19200 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19201 "io.modelcontextprotocol/clientCapabilities": {},
19202 },
19203 "name": "one-shot-mrtr-tool",
19204 "arguments": {},
19205 "inputResponses": {"roots": router_roots_response_wire()},
19206 "requestState": request_state,
19207 })),
19208 1437_i64,
19209 );
19210
19211 let mismatched_sidecar = serde_json::to_string(
19212 retry
19213 .params
19214 .as_ref()
19215 .expect("retry has materialized parameters"),
19216 )
19217 .expect("retry parameters serialize")
19218 .replacen("one-shot-mrtr-tool", "other-tool", 1);
19219 let sidecar_error = block_on(
19220 Arc::clone(&router)
19221 .dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
19222 origin_inbound.request_context(),
19223 retry.clone(),
19224 Some(Arc::<str>::from(mismatched_sidecar)),
19225 origin_cancellation.clone(),
19226 ),
19227 )
19228 .expect_err("a mismatched raw sidecar cannot reach continuation admission");
19229 assert_eq!(sidecar_error.code, McpErrorCode::InvalidParams);
19230 assert_eq!(calls.load(Ordering::SeqCst), 1);
19231 assert_eq!(router.mrtr_exchanges.active_len(), 1);
19232
19233 let foreign_connection = ModernConnection::new();
19234 let foreign_inbound = InboundRequestContext::with_modern_connection(
19235 cx.clone(),
19236 1437,
19237 InboundRequestTransport::Memory,
19238 &foreign_connection,
19239 );
19240 let foreign_error = block_on(
19241 Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
19242 foreign_inbound.request_context(),
19243 retry.clone(),
19244 foreign_inbound
19245 .mrtr_continuation_cancellation()
19246 .expect("a foreign connection still supplies its own cancellation"),
19247 ),
19248 )
19249 .expect_err("a foreign connection cannot consume origin continuation state");
19250 assert_eq!(foreign_error.code, McpErrorCode::InvalidParams);
19251 assert_eq!(calls.load(Ordering::SeqCst), 1);
19252 assert_eq!(router.mrtr_exchanges.active_len(), 1);
19253
19254 let mut stale_retry = retry.clone();
19255 stale_retry
19256 .params
19257 .as_mut()
19258 .and_then(serde_json::Value::as_object_mut)
19259 .expect("retry parameters are an object")
19260 .insert(
19261 "requestState".to_owned(),
19262 serde_json::json!("stale-request-state"),
19263 );
19264 let stale_error = block_on(
19265 Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
19266 origin_inbound.request_context(),
19267 stale_retry,
19268 origin_cancellation.clone(),
19269 ),
19270 )
19271 .expect_err("a stale state cannot consume the live continuation");
19272 assert_eq!(stale_error.code, McpErrorCode::InvalidParams);
19273 assert_eq!(calls.load(Ordering::SeqCst), 1);
19274 assert_eq!(router.mrtr_exchanges.active_len(), 1);
19275
19276 let completed = block_on(
19277 Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
19278 origin_inbound.request_context(),
19279 retry,
19280 origin_cancellation,
19281 ),
19282 )
19283 .expect("the unchanged origin retry resumes exactly one continuation");
19284 assert_eq!(completed["resultType"], "complete");
19285 assert_eq!(calls.load(Ordering::SeqCst), 2);
19286 assert_eq!(router.mrtr_exchanges.active_len(), 0);
19287 }
19288
19289 #[test]
19290 fn modern_mrtr_state_only_retry_round_trips_only_when_input_responses_are_absent() {
19291 let initial_calls = Arc::new(AtomicUsize::new(0));
19292 let resumed_calls = Arc::new(AtomicUsize::new(0));
19293 let mut router = Router::new();
19294 router
19295 .add_tool(StateOnlyInputRequiredTool {
19296 initial_calls: Arc::clone(&initial_calls),
19297 resumed_calls: Arc::clone(&resumed_calls),
19298 })
19299 .expect("state-only tool registration succeeds");
19300
19301 let cx = Cx::for_testing();
19302 let connection = ModernConnection::new();
19303 let inbound = InboundRequestContext::with_modern_connection(
19304 cx,
19305 144,
19306 InboundRequestTransport::Memory,
19307 &connection,
19308 );
19309 let request_ctx = inbound.request_context();
19310 let metadata = serde_json::json!({
19311 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19312 "io.modelcontextprotocol/clientCapabilities": {},
19313 });
19314 let initial = JsonRpcRequest::new(
19315 "tools/call",
19316 Some(serde_json::json!({
19317 "_meta": metadata.clone(),
19318 "name": "state-only-input-required-tool",
19319 "arguments": {},
19320 })),
19321 144_i64,
19322 );
19323 let input_required = router
19324 .dispatch_stateless(&request_ctx, &initial)
19325 .expect("state-only handler outcome is framework-bound");
19326 assert_eq!(input_required["resultType"], "input_required");
19327 assert!(
19328 input_required.get("inputRequests").is_none(),
19329 "the framework preserves state-only input_required without an empty request map"
19330 );
19331 let request_state = input_required["requestState"]
19332 .as_str()
19333 .expect("framework output includes opaque state")
19334 .to_owned();
19335
19336 let retry = JsonRpcRequest::new(
19337 "tools/call",
19338 Some(serde_json::json!({
19339 "_meta": metadata,
19340 "name": "state-only-input-required-tool",
19341 "arguments": {},
19342 "requestState": request_state,
19343 })),
19344 145_i64,
19345 );
19346 assert!(
19347 retry
19348 .params
19349 .as_ref()
19350 .and_then(serde_json::Value::as_object)
19351 .is_some_and(|params| !params.contains_key("inputResponses")),
19352 "the admitted retry keeps inputResponses absent"
19353 );
19354 let mut explicit_empty = retry.clone();
19355 explicit_empty
19356 .params
19357 .as_mut()
19358 .and_then(serde_json::Value::as_object_mut)
19359 .expect("retry parameters are an object")
19360 .insert("inputResponses".to_owned(), serde_json::json!({}));
19361 let explicit_empty_raw_params = serde_json::to_string(
19362 explicit_empty
19363 .params
19364 .as_ref()
19365 .expect("explicit-empty retry has parameters"),
19366 )
19367 .expect("explicit-empty retry parameters serialize");
19368 let empty_error = router
19369 .dispatch_stateless_with_raw_params(
19370 &request_ctx,
19371 &explicit_empty,
19372 Some(&explicit_empty_raw_params),
19373 )
19374 .expect_err("an explicit empty inputResponses map cannot impersonate an absent member");
19375 assert_eq!(empty_error.code, McpErrorCode::InvalidParams);
19376 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
19377 assert_eq!(resumed_calls.load(Ordering::SeqCst), 0);
19378
19379 let retry_raw_params = serde_json::to_string(
19380 retry
19381 .params
19382 .as_ref()
19383 .expect("absent-member retry has parameters"),
19384 )
19385 .expect("absent-member retry parameters serialize");
19386 let completed = router
19387 .dispatch_stateless_with_raw_params(&request_ctx, &retry, Some(&retry_raw_params))
19388 .expect("the unchanged absent-member retry resumes exactly once");
19389 assert_eq!(completed["resultType"], "complete");
19390 assert_eq!(completed["content"][0]["text"], "state-only resumed");
19391 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
19392 assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
19393 }
19394
19395 #[test]
19396 fn modern_mrtr_retries_resume_each_method_once_and_refuse_replay_or_kind_mismatch() {
19397 let tool_final_calls = Arc::new(AtomicUsize::new(0));
19398 let resource_final_calls = Arc::new(AtomicUsize::new(0));
19399 let prompt_final_calls = Arc::new(AtomicUsize::new(0));
19400 let mut router = Router::new();
19401 router
19402 .add_tool(InputRequiredTool {
19403 legacy_calls: Arc::new(AtomicUsize::new(0)),
19404 final_calls: Arc::clone(&tool_final_calls),
19405 })
19406 .expect("tool registration succeeds");
19407 router.add_resource(InputRequiredResource {
19408 legacy_calls: Arc::new(AtomicUsize::new(0)),
19409 final_calls: Arc::clone(&resource_final_calls),
19410 });
19411 router.add_prompt(InputRequiredPrompt {
19412 legacy_calls: Arc::new(AtomicUsize::new(0)),
19413 final_calls: Arc::clone(&prompt_final_calls),
19414 });
19415
19416 let cx = Cx::for_testing();
19417 let connection = ModernConnection::new();
19418 let inbound = InboundRequestContext::with_modern_connection(
19419 cx.clone(),
19420 145,
19421 InboundRequestTransport::Memory,
19422 &connection,
19423 );
19424 let request_ctx = inbound.request_context();
19425 let metadata = serde_json::json!({
19426 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19427 "io.modelcontextprotocol/clientCapabilities": {},
19428 });
19429
19430 let tool_initial = JsonRpcRequest::new(
19431 "tools/call",
19432 Some(serde_json::json!({
19433 "_meta": metadata.clone(),
19434 "name": "input-required-tool",
19435 "arguments": {},
19436 })),
19437 145_i64,
19438 );
19439 let tool_initial_result = router
19440 .dispatch_stateless(&request_ctx, &tool_initial)
19441 .expect("normal final dispatch mints the tool request state");
19442 let tool_state = tool_initial_result["requestState"]
19443 .as_str()
19444 .expect("framework result carries opaque tool state")
19445 .to_owned();
19446 let tool_retry = JsonRpcRequest::new(
19447 "tools/call",
19448 Some(serde_json::json!({
19449 "_meta": metadata.clone(),
19450 "name": "input-required-tool",
19451 "arguments": {},
19452 "inputResponses": {"roots": router_roots_response_wire()},
19453 "requestState": tool_state,
19454 })),
19455 145_i64,
19456 );
19457 let raw_params_for = |request: &JsonRpcRequest| {
19458 serde_json::to_string(request.params.as_ref().expect("retry has parameters"))
19459 .expect("retry parameters serialize")
19460 };
19461 let mut kind_mismatch = tool_retry.clone();
19462 kind_mismatch
19463 .params
19464 .as_mut()
19465 .and_then(serde_json::Value::as_object_mut)
19466 .and_then(|params| params.get_mut("inputResponses"))
19467 .and_then(serde_json::Value::as_object_mut)
19468 .expect("tool retry contains an inputResponses object")
19469 .insert(
19470 "roots".to_owned(),
19471 serde_json::to_value(
19472 MrtrInputResponse::sampling(
19473 serde_json::from_value(serde_json::json!({
19474 "content": {"type": "text", "text": "not roots"},
19475 "role": "assistant",
19476 "model": "test-model",
19477 }))
19478 .expect("final sampling response must decode"),
19479 )
19480 .expect("sampling response serializes"),
19481 )
19482 .expect("sampling response converts to a wire value"),
19483 );
19484 let kind_mismatch_raw_params = raw_params_for(&kind_mismatch);
19485 let kind_error = router
19486 .dispatch_stateless_with_raw_params(
19487 &request_ctx,
19488 &kind_mismatch,
19489 Some(&kind_mismatch_raw_params),
19490 )
19491 .expect_err("changing only the response kind is refused before handler invocation");
19492 assert_eq!(kind_error.code, McpErrorCode::InvalidParams);
19493 assert_eq!(
19494 tool_final_calls.load(Ordering::SeqCst),
19495 1,
19496 "a wrong-kind retry must leave the matching state unconsumed"
19497 );
19498
19499 let missing_responses = JsonRpcRequest::new(
19500 "tools/call",
19501 Some(serde_json::json!({
19502 "_meta": metadata.clone(),
19503 "name": "input-required-tool",
19504 "arguments": {},
19505 "inputResponses": {},
19506 "requestState": tool_state,
19507 })),
19508 145_i64,
19509 );
19510 let missing_raw_params = raw_params_for(&missing_responses);
19511 let missing_error = router
19512 .dispatch_stateless_with_raw_params(
19513 &request_ctx,
19514 &missing_responses,
19515 Some(&missing_raw_params),
19516 )
19517 .expect_err("a missing roots response cannot consume a tool continuation");
19518 assert_eq!(missing_error.code, McpErrorCode::InvalidParams);
19519 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19520
19521 let roots_wire = serde_json::to_string(&router_roots_response_wire())
19522 .expect("roots response serializes for duplicate-key raw ingress");
19523 let duplicate_raw_params = format!(
19524 r#"{{"_meta":{},"name":"input-required-tool","arguments":{{}},"inputResponses":{{"roots":{roots_wire},"roots":{roots_wire}}},"requestState":{}}}"#,
19525 serde_json::to_string(&metadata).expect("metadata serializes"),
19526 serde_json::to_string(&tool_state).expect("opaque state serializes"),
19527 );
19528 let duplicate_retry = JsonRpcRequest::new(
19529 "tools/call",
19530 Some(
19531 serde_json::from_str(&duplicate_raw_params)
19532 .expect("duplicate raw parameters materialize for source equality"),
19533 ),
19534 145_i64,
19535 );
19536 let duplicate_error = router
19537 .dispatch_stateless_with_raw_params(
19538 &request_ctx,
19539 &duplicate_retry,
19540 Some(&duplicate_raw_params),
19541 )
19542 .expect_err("duplicate raw response keys cannot collapse before MRTR admission");
19543 assert_eq!(duplicate_error.code, McpErrorCode::InvalidParams);
19544 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19545
19546 let mut unknown_only = tool_retry.clone();
19547 *unknown_only
19548 .params
19549 .as_mut()
19550 .and_then(serde_json::Value::as_object_mut)
19551 .and_then(|params| params.get_mut("inputResponses"))
19552 .expect("tool retry contains inputResponses") = serde_json::json!({"inert": null});
19553 let unknown_only_raw_params = raw_params_for(&unknown_only);
19554 let unknown_error = router
19555 .dispatch_stateless_with_raw_params(
19556 &request_ctx,
19557 &unknown_only,
19558 Some(&unknown_only_raw_params),
19559 )
19560 .expect_err("unknown-only inputResponses cannot consume a tool continuation");
19561 assert_eq!(unknown_error.code, McpErrorCode::InvalidParams);
19562 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19563
19564 let mut oversized_map = tool_retry.clone();
19565 let responses = oversized_map
19566 .params
19567 .as_mut()
19568 .and_then(serde_json::Value::as_object_mut)
19569 .and_then(|params| params.get_mut("inputResponses"))
19570 .and_then(serde_json::Value::as_object_mut)
19571 .expect("tool retry contains an inputResponses object");
19572 for index in 0..crate::bidirectional::DEFAULT_MAX_MRTR_INPUT_REQUESTS_PER_ROUND {
19573 responses.insert(format!("inert-{index}"), serde_json::Value::Null);
19574 }
19575 let oversized_error = router
19576 .dispatch_stateless(&request_ctx, &oversized_map)
19577 .expect_err("an oversized raw response map is refused before retry decoding");
19578 assert_eq!(oversized_error.code, McpErrorCode::InvalidParams);
19579 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19580
19581 let mut tool_oversized_bytes = tool_retry.clone();
19582 tool_oversized_bytes
19583 .params
19584 .as_mut()
19585 .and_then(serde_json::Value::as_object_mut)
19586 .and_then(|params| params.get_mut("inputResponses"))
19587 .and_then(serde_json::Value::as_object_mut)
19588 .expect("tool retry contains an inputResponses object")
19589 .insert(
19590 "roots".to_owned(),
19591 serde_json::Value::String("x".repeat(MAX_MRTR_RAW_INPUT_RESPONSES_BYTES + 1)),
19592 );
19593 let tool_bytes_error = router
19594 .dispatch_stateless(&request_ctx, &tool_oversized_bytes)
19595 .expect_err("only an oversized tool response value is refused before retry decoding");
19596 assert_eq!(tool_bytes_error.code, McpErrorCode::InvalidParams);
19597 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19598
19599 let mut target_mismatch = tool_retry.clone();
19600 target_mismatch
19601 .params
19602 .as_mut()
19603 .and_then(serde_json::Value::as_object_mut)
19604 .expect("tool retry parameters are an object")
19605 .insert("name".to_owned(), serde_json::json!("other-tool"));
19606 let target_error = router
19607 .dispatch_stateless(&request_ctx, &target_mismatch)
19608 .expect_err("changing only the target cannot consume a tool state");
19609 assert_eq!(target_error.code, McpErrorCode::InvalidParams);
19610 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19611
19612 let other_connection = ModernConnection::new();
19613 let other_inbound = InboundRequestContext::with_modern_connection(
19614 cx.clone(),
19615 145,
19616 InboundRequestTransport::Memory,
19617 &other_connection,
19618 );
19619 let other_session_ctx = other_inbound.request_context();
19620 let session_error = router
19621 .dispatch_stateless(&other_session_ctx, &tool_retry)
19622 .expect_err("changing only the session cannot consume a tool state");
19623 assert_eq!(session_error.code, McpErrorCode::InvalidParams);
19624 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19625
19626 let principal_ctx = inbound.request_context();
19627 assert!(principal_ctx.set_auth(fastmcp_core::AuthContext::with_subject("other-user")));
19628 let principal_error = router
19629 .dispatch_stateless(&principal_ctx, &tool_retry)
19630 .expect_err("changing only the principal cannot consume a tool state");
19631 assert_eq!(principal_error.code, McpErrorCode::InvalidParams);
19632 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19633
19634 let tool_retry_raw_params = raw_params_for(&tool_retry);
19635 let tool_response = router
19636 .dispatch_stateless_with_raw_params(
19637 &request_ctx,
19638 &tool_retry,
19639 Some(&tool_retry_raw_params),
19640 )
19641 .expect("a framework-minted tool state resumes through the final handler");
19642 assert_eq!(tool_response["resultType"], "input_required");
19643 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 2);
19644
19645 let resource_initial = JsonRpcRequest::new(
19646 "resources/read",
19647 Some(serde_json::json!({
19648 "_meta": metadata.clone(),
19649 "uri": "file:///input-required-resource",
19650 })),
19651 146_i64,
19652 );
19653 let resource_initial_result = router
19654 .dispatch_stateless(&request_ctx, &resource_initial)
19655 .expect("normal final dispatch mints the resource request state");
19656 let resource_state = resource_initial_result["requestState"]
19657 .as_str()
19658 .expect("framework result carries opaque resource state")
19659 .to_owned();
19660 let resource_retry = JsonRpcRequest::new(
19661 "resources/read",
19662 Some(serde_json::json!({
19663 "_meta": metadata.clone(),
19664 "uri": "file:///input-required-resource",
19665 "inputResponses": {"roots": router_roots_response_wire()},
19666 "requestState": resource_state,
19667 })),
19668 146_i64,
19669 );
19670 let mut resource_unknown_only = resource_retry.clone();
19671 *resource_unknown_only
19672 .params
19673 .as_mut()
19674 .and_then(serde_json::Value::as_object_mut)
19675 .and_then(|params| params.get_mut("inputResponses"))
19676 .expect("resource retry contains inputResponses") = serde_json::json!({"inert": null});
19677 let resource_unknown_error = router
19678 .dispatch_stateless(&request_ctx, &resource_unknown_only)
19679 .expect_err("unknown-only inputResponses cannot consume a resource continuation");
19680 assert_eq!(resource_unknown_error.code, McpErrorCode::InvalidParams);
19681 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
19682
19683 let mut resource_nested_value = serde_json::Value::Null;
19684 for _ in 0..=MAX_MRTR_RAW_JSON_DEPTH {
19685 resource_nested_value = serde_json::Value::Array(vec![resource_nested_value]);
19686 }
19687 let mut resource_oversized_depth = resource_retry.clone();
19688 resource_oversized_depth
19689 .params
19690 .as_mut()
19691 .and_then(serde_json::Value::as_object_mut)
19692 .and_then(|params| params.get_mut("inputResponses"))
19693 .and_then(serde_json::Value::as_object_mut)
19694 .expect("resource retry contains an inputResponses object")
19695 .insert("roots".to_owned(), resource_nested_value);
19696 let resource_depth_error = router
19697 .dispatch_stateless(&request_ctx, &resource_oversized_depth)
19698 .expect_err("only excessive response nesting is refused before retry decoding");
19699 assert_eq!(resource_depth_error.code, McpErrorCode::InvalidParams);
19700 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
19701
19702 let resource_retry_raw_params = raw_params_for(&resource_retry);
19703 let resource_response = router
19704 .dispatch_stateless_with_raw_params(
19705 &request_ctx,
19706 &resource_retry,
19707 Some(&resource_retry_raw_params),
19708 )
19709 .expect("a framework-minted resource state resumes through the final handler");
19710 assert_eq!(resource_response["resultType"], "input_required");
19711 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 2);
19712
19713 let prompt_initial = JsonRpcRequest::new(
19714 "prompts/get",
19715 Some(serde_json::json!({
19716 "_meta": metadata.clone(),
19717 "name": "input-required-prompt",
19718 })),
19719 147_i64,
19720 );
19721 let prompt_initial_result = router
19722 .dispatch_stateless(&request_ctx, &prompt_initial)
19723 .expect("normal final dispatch mints the prompt request state");
19724 let prompt_state = prompt_initial_result["requestState"]
19725 .as_str()
19726 .expect("framework result carries opaque prompt state")
19727 .to_owned();
19728 let prompt_retry = JsonRpcRequest::new(
19729 "prompts/get",
19730 Some(serde_json::json!({
19731 "_meta": metadata.clone(),
19732 "name": "input-required-prompt",
19733 "inputResponses": {"roots": router_roots_response_wire()},
19734 "requestState": prompt_state,
19735 })),
19736 147_i64,
19737 );
19738 let mut prompt_unknown_only = prompt_retry.clone();
19739 *prompt_unknown_only
19740 .params
19741 .as_mut()
19742 .and_then(serde_json::Value::as_object_mut)
19743 .and_then(|params| params.get_mut("inputResponses"))
19744 .expect("prompt retry contains inputResponses") = serde_json::json!({"inert": null});
19745 let prompt_unknown_error = router
19746 .dispatch_stateless(&request_ctx, &prompt_unknown_only)
19747 .expect_err("unknown-only inputResponses cannot consume a prompt continuation");
19748 assert_eq!(prompt_unknown_error.code, McpErrorCode::InvalidParams);
19749 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
19750
19751 let mut prompt_oversized_values = prompt_retry.clone();
19752 prompt_oversized_values
19753 .params
19754 .as_mut()
19755 .and_then(serde_json::Value::as_object_mut)
19756 .and_then(|params| params.get_mut("inputResponses"))
19757 .and_then(serde_json::Value::as_object_mut)
19758 .expect("prompt retry contains an inputResponses object")
19759 .insert(
19760 "roots".to_owned(),
19761 serde_json::Value::Array(vec![serde_json::Value::Null; MAX_MRTR_RAW_JSON_VALUES]),
19762 );
19763 let prompt_values_error = router
19764 .dispatch_stateless(&request_ctx, &prompt_oversized_values)
19765 .expect_err(
19766 "only an oversized prompt response value set is refused before retry decoding",
19767 );
19768 assert_eq!(prompt_values_error.code, McpErrorCode::InvalidParams);
19769 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
19770
19771 let prompt_retry_raw_params = raw_params_for(&prompt_retry);
19772 let prompt_response = router
19773 .dispatch_stateless_with_raw_params(
19774 &request_ctx,
19775 &prompt_retry,
19776 Some(&prompt_retry_raw_params),
19777 )
19778 .expect("a framework-minted prompt state resumes through the final handler");
19779 assert_eq!(prompt_response["resultType"], "input_required");
19780 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 2);
19781
19782 let mut tool_second_retry = tool_retry.clone();
19787 tool_second_retry
19788 .params
19789 .as_mut()
19790 .and_then(serde_json::Value::as_object_mut)
19791 .expect("tool retry parameters are an object")
19792 .insert(
19793 "requestState".to_owned(),
19794 tool_response["requestState"].clone(),
19795 );
19796 let tool_second_response = router
19797 .dispatch_stateless(&request_ctx, &tool_second_retry)
19798 .expect("a second public tools/call round reaches the resumed handler");
19799 assert_eq!(tool_second_response["resultType"], "input_required");
19800
19801 let mut resource_second_retry = resource_retry.clone();
19802 resource_second_retry
19803 .params
19804 .as_mut()
19805 .and_then(serde_json::Value::as_object_mut)
19806 .expect("resource retry parameters are an object")
19807 .insert(
19808 "requestState".to_owned(),
19809 resource_response["requestState"].clone(),
19810 );
19811 let resource_second_response = router
19812 .dispatch_stateless(&request_ctx, &resource_second_retry)
19813 .expect("a second public resources/read round reaches the resumed handler");
19814 assert_eq!(resource_second_response["resultType"], "input_required");
19815
19816 let mut prompt_second_retry = prompt_retry.clone();
19817 prompt_second_retry
19818 .params
19819 .as_mut()
19820 .and_then(serde_json::Value::as_object_mut)
19821 .expect("prompt retry parameters are an object")
19822 .insert(
19823 "requestState".to_owned(),
19824 prompt_response["requestState"].clone(),
19825 );
19826 let prompt_second_response = router
19827 .dispatch_stateless(&request_ctx, &prompt_second_retry)
19828 .expect("a second public prompts/get round reaches the resumed handler");
19829 assert_eq!(prompt_second_response["resultType"], "input_required");
19830 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 3);
19831 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 3);
19832 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 3);
19833
19834 let replay = router
19835 .dispatch_stateless(&request_ctx, &tool_retry)
19836 .expect_err("replaying only the already consumed tool state is refused");
19837 assert_eq!(replay.code, McpErrorCode::InvalidParams);
19838 assert_eq!(
19839 tool_final_calls.load(Ordering::SeqCst),
19840 3,
19841 "replay must fail before the tool handler is invoked again"
19842 );
19843 }
19844
19845 #[test]
19846 fn modern_connection_disablements_block_final_handlers_before_mrtr_state_is_minted() {
19847 let tool_final_calls = Arc::new(AtomicUsize::new(0));
19848 let resource_final_calls = Arc::new(AtomicUsize::new(0));
19849 let prompt_final_calls = Arc::new(AtomicUsize::new(0));
19850 let mut router = Router::new();
19851 router
19852 .add_tool(InputRequiredTool {
19853 legacy_calls: Arc::new(AtomicUsize::new(0)),
19854 final_calls: Arc::clone(&tool_final_calls),
19855 })
19856 .expect("tool registration succeeds");
19857 router.add_resource(InputRequiredResource {
19858 legacy_calls: Arc::new(AtomicUsize::new(0)),
19859 final_calls: Arc::clone(&resource_final_calls),
19860 });
19861 router.add_prompt(InputRequiredPrompt {
19862 legacy_calls: Arc::new(AtomicUsize::new(0)),
19863 final_calls: Arc::clone(&prompt_final_calls),
19864 });
19865
19866 let metadata = serde_json::json!({
19867 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19868 "io.modelcontextprotocol/clientCapabilities": {},
19869 });
19870 let tool_request = JsonRpcRequest::new(
19871 "tools/call",
19872 Some(serde_json::json!({
19873 "_meta": metadata.clone(),
19874 "name": "input-required-tool",
19875 "arguments": {},
19876 })),
19877 611_i64,
19878 );
19879 let resource_request = JsonRpcRequest::new(
19880 "resources/read",
19881 Some(serde_json::json!({
19882 "_meta": metadata.clone(),
19883 "uri": "file:///input-required-resource",
19884 })),
19885 612_i64,
19886 );
19887 let prompt_request = JsonRpcRequest::new(
19888 "prompts/get",
19889 Some(serde_json::json!({
19890 "_meta": metadata,
19891 "name": "input-required-prompt",
19892 })),
19893 613_i64,
19894 );
19895 let request_bytes = [
19896 serde_json::to_vec(&tool_request).expect("tool request serializes"),
19897 serde_json::to_vec(&resource_request).expect("resource request serializes"),
19898 serde_json::to_vec(&prompt_request).expect("prompt request serializes"),
19899 ];
19900
19901 let cx = Cx::for_testing();
19902 let allowed_connection = ModernConnection::new();
19903 let allowed_inbound = InboundRequestContext::with_modern_connection(
19904 cx.clone(),
19905 611,
19906 InboundRequestTransport::Stdio,
19907 &allowed_connection,
19908 );
19909 let allowed_ctx = allowed_inbound.request_context();
19910 let allowed_cancellation = allowed_inbound
19911 .mrtr_continuation_cancellation()
19912 .expect("modern connection supplies continuation ownership");
19913
19914 for request in [&tool_request, &resource_request, &prompt_request] {
19915 let result = router
19916 .dispatch_stateless_with_continuation_cancellation(
19917 &allowed_ctx,
19918 request,
19919 &allowed_cancellation,
19920 )
19921 .expect("enabled final component reaches its handler");
19922 assert_eq!(result["resultType"], "input_required");
19923 assert!(
19924 result["requestState"].is_string(),
19925 "an enabled MRTR-capable handler mints framework-owned state"
19926 );
19927 }
19928 assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19929 assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
19930 assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
19931
19932 let denied_connection = ModernConnection::new();
19933 let denied_inbound = InboundRequestContext::with_modern_connection(
19934 cx,
19935 611,
19936 InboundRequestTransport::Stdio,
19937 &denied_connection,
19938 );
19939 let denied_ctx = denied_inbound.request_context();
19940 let denied_cancellation = denied_inbound
19941 .mrtr_continuation_cancellation()
19942 .expect("modern connection supplies continuation ownership");
19943 assert!(denied_ctx.disable_tool("input-required-tool"));
19944 assert!(denied_ctx.disable_resource("file:///input-required-resource"));
19945 assert!(denied_ctx.disable_prompt("input-required-prompt"));
19946
19947 assert_eq!(
19948 serde_json::to_vec(&tool_request).expect("tool request remains serializable"),
19949 request_bytes[0],
19950 "connection state is the sole tool-request admission difference"
19951 );
19952 let tool_error = router
19953 .dispatch_stateless_with_continuation_cancellation(
19954 &denied_ctx,
19955 &tool_request,
19956 &denied_cancellation,
19957 )
19958 .expect_err("a disabled tool cannot mint final MRTR state");
19959 assert_eq!(tool_error.code, McpErrorCode::MethodNotFound);
19960
19961 assert_eq!(
19962 serde_json::to_vec(&resource_request).expect("resource request remains serializable"),
19963 request_bytes[1],
19964 "connection state is the sole resource-request admission difference"
19965 );
19966 let resource_error = router
19967 .dispatch_stateless_with_continuation_cancellation(
19968 &denied_ctx,
19969 &resource_request,
19970 &denied_cancellation,
19971 )
19972 .expect_err("a disabled resource cannot mint final MRTR state");
19973 assert_eq!(resource_error.code, McpErrorCode::ResourceNotFound);
19974
19975 assert_eq!(
19976 serde_json::to_vec(&prompt_request).expect("prompt request remains serializable"),
19977 request_bytes[2],
19978 "connection state is the sole prompt-request admission difference"
19979 );
19980 let prompt_error = router
19981 .dispatch_stateless_with_continuation_cancellation(
19982 &denied_ctx,
19983 &prompt_request,
19984 &denied_cancellation,
19985 )
19986 .expect_err("a disabled prompt cannot mint final MRTR state");
19987 assert_eq!(prompt_error.code, McpErrorCode::PromptNotFound);
19988
19989 assert_eq!(
19990 tool_final_calls.load(Ordering::SeqCst),
19991 1,
19992 "refused tool dispatch must not invoke the MRTR-capable handler"
19993 );
19994 assert_eq!(
19995 resource_final_calls.load(Ordering::SeqCst),
19996 1,
19997 "refused resource dispatch must not invoke the MRTR-capable handler"
19998 );
19999 assert_eq!(
20000 prompt_final_calls.load(Ordering::SeqCst),
20001 1,
20002 "refused prompt dispatch must not invoke the MRTR-capable handler"
20003 );
20004 }
20005
20006 #[test]
20007 fn modern_final_catalogs_ignore_connection_disablements_while_exact_legacy_lists_remain_stateful()
20008 {
20009 let template_uri = "file:///input-required-template/{id}";
20010 let mut router = Router::new();
20011 router
20012 .add_tool(NamedTool::new("connection-scoped-tool"))
20013 .expect("tool registration succeeds");
20014 router.add_resource(NamedResource::new("file:///connection-scoped-resource"));
20015 router
20016 .add_resource_template_with_behavior(
20017 ResourceTemplate {
20018 uri_template: template_uri.to_owned(),
20019 name: "connection-scoped-template".to_owned(),
20020 description: None,
20021 mime_type: None,
20022 icon: None,
20023 version: None,
20024 tags: Vec::new(),
20025 },
20026 crate::DuplicateBehavior::Replace,
20027 )
20028 .expect("resource-template registration succeeds");
20029 router.add_prompt(NamedPrompt::new("connection-scoped-prompt"));
20030
20031 let cx = Cx::for_testing();
20032 let connection = ModernConnection::new();
20033 let inbound = InboundRequestContext::with_modern_connection(
20034 cx,
20035 614,
20036 InboundRequestTransport::Stdio,
20037 &connection,
20038 );
20039 let request_ctx = inbound.request_context();
20040 let metadata = serde_json::json!({
20041 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20042 "io.modelcontextprotocol/clientCapabilities": {},
20043 });
20044 let list_request = |method, id| {
20045 JsonRpcRequest::new(
20046 method,
20047 Some(serde_json::json!({"_meta": metadata.clone()})),
20048 id,
20049 )
20050 };
20051
20052 let tools = router
20053 .dispatch_stateless(&request_ctx, &list_request("tools/list", 614_i64))
20054 .expect("enabled tool is discovered");
20055 let resources = router
20056 .dispatch_stateless(&request_ctx, &list_request("resources/list", 615_i64))
20057 .expect("enabled resource is discovered");
20058 let templates = router
20059 .dispatch_stateless(
20060 &request_ctx,
20061 &list_request("resources/templates/list", 616_i64),
20062 )
20063 .expect("enabled resource template is discovered");
20064 let prompts = router
20065 .dispatch_stateless(&request_ctx, &list_request("prompts/list", 617_i64))
20066 .expect("enabled prompt is discovered");
20067 assert_eq!(tools["tools"][0]["name"], "connection-scoped-tool");
20068 assert_eq!(
20069 resources["resources"][0]["uri"],
20070 "file:///connection-scoped-resource"
20071 );
20072 assert_eq!(
20073 templates["resourceTemplates"][0]["uriTemplate"],
20074 template_uri
20075 );
20076 assert_eq!(prompts["prompts"][0]["name"], "connection-scoped-prompt");
20077
20078 assert!(request_ctx.disable_tool("connection-scoped-tool"));
20079 assert!(request_ctx.disable_resource("file:///connection-scoped-resource"));
20080 assert!(request_ctx.disable_resource(template_uri));
20081 assert!(request_ctx.disable_prompt("connection-scoped-prompt"));
20082
20083 let discovered_tools = router
20084 .dispatch_stateless(&request_ctx, &list_request("tools/list", 618_i64))
20085 .expect("disabled tool remains in the immutable final catalog");
20086 let discovered_resources = router
20087 .dispatch_stateless(&request_ctx, &list_request("resources/list", 619_i64))
20088 .expect("disabled resource remains in the immutable final catalog");
20089 let discovered_templates = router
20090 .dispatch_stateless(
20091 &request_ctx,
20092 &list_request("resources/templates/list", 620_i64),
20093 )
20094 .expect("disabled template remains in the immutable final catalog");
20095 let discovered_prompts = router
20096 .dispatch_stateless(&request_ctx, &list_request("prompts/list", 621_i64))
20097 .expect("disabled prompt remains in the immutable final catalog");
20098 assert_eq!(
20099 discovered_tools["tools"][0]["name"],
20100 "connection-scoped-tool"
20101 );
20102 assert_eq!(
20103 discovered_resources["resources"][0]["uri"],
20104 "file:///connection-scoped-resource"
20105 );
20106 assert_eq!(
20107 discovered_templates["resourceTemplates"][0]["uriTemplate"],
20108 template_uri
20109 );
20110 assert_eq!(
20111 discovered_prompts["prompts"][0]["name"],
20112 "connection-scoped-prompt"
20113 );
20114
20115 let legacy_state = SessionState::new();
20116 let legacy_request_ctx =
20117 request_context(request_ctx.cx(), 622, Budget::INFINITE, &legacy_state);
20118 assert!(legacy_request_ctx.disable_tool("connection-scoped-tool"));
20119 assert!(legacy_request_ctx.disable_resource("file:///connection-scoped-resource"));
20120 assert!(legacy_request_ctx.disable_resource(template_uri));
20121 assert!(legacy_request_ctx.disable_prompt("connection-scoped-prompt"));
20122
20123 let legacy_tools = router
20124 .handle_tools_list(
20125 &legacy_request_ctx,
20126 ListToolsParams {
20127 cursor: None,
20128 include_tags: None,
20129 exclude_tags: None,
20130 },
20131 Some(&legacy_state),
20132 )
20133 .expect("exact legacy tools/list remains session-stateful");
20134 let legacy_resources = router
20135 .handle_resources_list(
20136 &legacy_request_ctx,
20137 ListResourcesParams {
20138 cursor: None,
20139 include_tags: None,
20140 exclude_tags: None,
20141 },
20142 Some(&legacy_state),
20143 )
20144 .expect("exact legacy resources/list remains session-stateful");
20145 let legacy_templates = router
20146 .handle_resource_templates_list(
20147 &legacy_request_ctx,
20148 ListResourceTemplatesParams {
20149 cursor: None,
20150 include_tags: None,
20151 exclude_tags: None,
20152 },
20153 Some(&legacy_state),
20154 )
20155 .expect("exact legacy resources/templates/list remains session-stateful");
20156 let legacy_prompts = router
20157 .handle_prompts_list(
20158 &legacy_request_ctx,
20159 ListPromptsParams {
20160 cursor: None,
20161 include_tags: None,
20162 exclude_tags: None,
20163 },
20164 Some(&legacy_state),
20165 )
20166 .expect("exact legacy prompts/list remains session-stateful");
20167 assert!(legacy_tools.tools.is_empty());
20168 assert!(legacy_resources.resources.is_empty());
20169 assert!(legacy_templates.resource_templates.is_empty());
20170 assert!(legacy_prompts.prompts.is_empty());
20171
20172 let later_inbound = InboundRequestContext::with_modern_connection(
20173 request_ctx.cx().clone(),
20174 630,
20175 InboundRequestTransport::Stdio,
20176 &connection,
20177 );
20178 let later_ctx = later_inbound.request_context();
20179 let read_error = router
20180 .dispatch_stateless(
20181 &later_ctx,
20182 &JsonRpcRequest::new(
20183 "resources/read",
20184 Some(serde_json::json!({
20185 "_meta": metadata,
20186 "uri": "file:///connection-scoped-resource",
20187 })),
20188 631_i64,
20189 ),
20190 )
20191 .expect_err(
20192 "a later inbound on the same modern connection must refuse the disabled resource",
20193 );
20194 assert_eq!(read_error.code, McpErrorCode::ResourceNotFound);
20195 assert!(
20196 read_error.message.contains("disabled"),
20197 "the refused resource read must keep the session-disabled message: {read_error:?}"
20198 );
20199 let prompt_error = router
20200 .dispatch_stateless(
20201 &later_ctx,
20202 &JsonRpcRequest::new(
20203 "prompts/get",
20204 Some(serde_json::json!({
20205 "_meta": metadata,
20206 "name": "connection-scoped-prompt",
20207 })),
20208 632_i64,
20209 ),
20210 )
20211 .expect_err(
20212 "a later inbound on the same modern connection must refuse the disabled prompt",
20213 );
20214 assert_eq!(prompt_error.code, McpErrorCode::PromptNotFound);
20215 assert!(
20216 prompt_error.message.contains("disabled"),
20217 "the refused prompt get must keep the session-disabled message: {prompt_error:?}"
20218 );
20219 let tool_error = router
20220 .dispatch_stateless(
20221 &later_ctx,
20222 &JsonRpcRequest::new(
20223 "tools/call",
20224 Some(serde_json::json!({
20225 "_meta": metadata,
20226 "name": "connection-scoped-tool",
20227 "arguments": {},
20228 })),
20229 633_i64,
20230 ),
20231 )
20232 .expect_err(
20233 "a later inbound on the same modern connection must refuse the disabled tool",
20234 );
20235 assert_eq!(tool_error.code, McpErrorCode::MethodNotFound);
20236 assert!(
20237 tool_error.message.contains("disabled"),
20238 "the refused tool call must keep the session-disabled message: {tool_error:?}"
20239 );
20240 }
20241
20242 #[test]
20243 fn final_catalog_cursor_survives_a_changed_session_state() {
20244 let mut router = Router::new();
20245 router.set_list_page_size(Some(1));
20246 router
20247 .add_tool(NamedTool::new("first-connection-cursor-tool"))
20248 .expect("first tool registration succeeds");
20249 router
20250 .add_tool(NamedTool::new("second-connection-cursor-tool"))
20251 .expect("second tool registration succeeds");
20252
20253 let cx = Cx::for_testing();
20254 let state = SessionState::new();
20255 let request_ctx = request_context(&cx, 622, Budget::INFINITE, &state);
20256 let initial = final_tools_list_request(None, None, None, 622_i64);
20257 let initial_bytes = serde_json::to_vec(&initial).expect("initial request serializes");
20258 let first_page = router
20259 .dispatch_stateless(&request_ctx, &initial)
20260 .expect("the initial final catalog page is admitted");
20261 let cursor = first_page["nextCursor"]
20262 .as_str()
20263 .expect("the first page has a continuation")
20264 .to_owned();
20265
20266 assert!(request_ctx.disable_tool("first-connection-cursor-tool"));
20267 assert_eq!(
20268 serde_json::to_vec(&initial).expect("initial request remains serializable"),
20269 initial_bytes,
20270 "connection state cannot alter the request or its catalog continuation"
20271 );
20272 let continued = router
20273 .dispatch_stateless(
20274 &request_ctx,
20275 &final_tools_list_request(Some(&cursor), None, None, 623_i64),
20276 )
20277 .expect("a disabled-component change cannot invalidate an immutable final cursor");
20278 assert_eq!(
20279 continued["tools"][0]["name"],
20280 "second-connection-cursor-tool"
20281 );
20282 assert!(continued.get("nextCursor").is_none());
20283
20284 let refreshed = router
20285 .dispatch_stateless(
20286 &request_ctx,
20287 &final_tools_list_request(None, None, None, 624_i64),
20288 )
20289 .expect("a cursor-free request keeps the immutable final catalog");
20290 assert_eq!(
20291 refreshed["tools"][0]["name"],
20292 "first-connection-cursor-tool"
20293 );
20294 }
20295
20296 #[test]
20297 fn final_catalog_cursor_continues_across_modern_connection_partitions() {
20298 let mut router = Router::new();
20299 router.set_list_page_size(Some(1));
20300 router
20301 .add_tool(NamedTool::new("first-cross-connection-cursor-tool"))
20302 .expect("first tool registration succeeds");
20303 router
20304 .add_tool(NamedTool::new("second-cross-connection-cursor-tool"))
20305 .expect("second tool registration succeeds");
20306
20307 let cx = Cx::for_testing();
20308 let origin_connection = ModernConnection::new();
20309 let origin_inbound = InboundRequestContext::with_modern_connection(
20310 cx.clone(),
20311 625,
20312 InboundRequestTransport::Stdio,
20313 &origin_connection,
20314 );
20315 let origin_context = origin_inbound.request_context();
20316 let continuation_connection = ModernConnection::new();
20317 let continuation_inbound = InboundRequestContext::with_modern_connection(
20318 cx,
20319 626,
20320 InboundRequestTransport::Stdio,
20321 &continuation_connection,
20322 );
20323 let continuation_context = continuation_inbound.request_context();
20324 let origin_partition = origin_context
20325 .session_cache_partition()
20326 .expect("a modern origin context has a durable partition")
20327 .0;
20328 let continuation_partition = continuation_context
20329 .session_cache_partition()
20330 .expect("a modern continuation context has a durable partition")
20331 .0;
20332 assert_ne!(
20333 origin_partition, continuation_partition,
20334 "distinct modern connections have distinct durable partitions"
20335 );
20336
20337 let first_page = router
20338 .dispatch_stateless(
20339 &origin_context,
20340 &final_tools_list_request(None, None, None, 625_i64),
20341 )
20342 .expect("the origin connection receives the first final catalog page");
20343 let cursor = first_page["nextCursor"]
20344 .as_str()
20345 .expect("the first page has a continuation")
20346 .to_owned();
20347
20348 let continued = router
20349 .dispatch_stateless(
20350 &continuation_context,
20351 &final_tools_list_request(Some(&cursor), None, None, 626_i64),
20352 )
20353 .expect("a final catalog cursor is not bound to its origin connection partition");
20354 assert_eq!(
20355 continued["tools"][0]["name"],
20356 "second-cross-connection-cursor-tool"
20357 );
20358 assert!(continued.get("nextCursor").is_none());
20359 }
20360
20361 #[test]
20362 fn modern_connection_disconnect_is_the_only_changed_retry_dimension_and_cancels_state() {
20363 let final_calls = Arc::new(AtomicUsize::new(0));
20364 let mut router = Router::new();
20365 router
20366 .add_tool(InputRequiredTool {
20367 legacy_calls: Arc::new(AtomicUsize::new(0)),
20368 final_calls: Arc::clone(&final_calls),
20369 })
20370 .expect("tool registration succeeds");
20371
20372 let cx = Cx::for_testing();
20373 let connection = ModernConnection::new();
20374 let inbound = InboundRequestContext::with_modern_connection(
20375 cx.clone(),
20376 301,
20377 InboundRequestTransport::Stdio,
20378 &connection,
20379 );
20380 let request_ctx = inbound.request_context();
20381 let continuation_cancellation = inbound
20382 .mrtr_continuation_cancellation()
20383 .expect("a modern connection supplies continuation ownership");
20384 let metadata = serde_json::json!({
20385 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20386 "io.modelcontextprotocol/clientCapabilities": {},
20387 });
20388 let initial = JsonRpcRequest::new(
20389 "tools/call",
20390 Some(serde_json::json!({
20391 "_meta": metadata.clone(),
20392 "name": "input-required-tool",
20393 "arguments": {},
20394 })),
20395 301_i64,
20396 );
20397 let initial_result = router
20398 .dispatch_stateless_with_continuation_cancellation(
20399 &request_ctx,
20400 &initial,
20401 &continuation_cancellation,
20402 )
20403 .expect("the connected request mints a continuation");
20404 let retry = JsonRpcRequest::new(
20405 "tools/call",
20406 Some(serde_json::json!({
20407 "_meta": metadata,
20408 "name": "input-required-tool",
20409 "arguments": {},
20410 "inputResponses": {"roots": router_roots_response_wire()},
20411 "requestState": initial_result["requestState"].clone(),
20412 })),
20413 302_i64,
20414 );
20415 let retry_before_disconnect = serde_json::to_vec(&retry).expect("retry serializes");
20416
20417 connection.disconnect();
20420 let error = router
20421 .dispatch_stateless_with_continuation_cancellation(
20422 &request_ctx,
20423 &retry,
20424 &continuation_cancellation,
20425 )
20426 .expect_err("disconnect must cancel the retained continuation");
20427 assert_eq!(error.code, McpErrorCode::RequestCancelled);
20428 assert_eq!(
20429 serde_json::to_vec(&retry).expect("retry serializes"),
20430 retry_before_disconnect,
20431 "disconnect cancellation cannot mutate client retry state"
20432 );
20433 assert_eq!(
20434 final_calls.load(Ordering::SeqCst),
20435 1,
20436 "a disconnected continuation cannot reach the resumed handler"
20437 );
20438 }
20439
20440 #[cfg(feature = "tasks")]
20441 #[test]
20442 fn task_capable_input_required_retry_does_not_require_tasks_capability() {
20443 let final_calls = Arc::new(AtomicUsize::new(0));
20444 let mut router = Router::new();
20445 router
20446 .add_tool(TaskCapableInputRequiredTool {
20447 final_calls: Arc::clone(&final_calls),
20448 })
20449 .expect("task-capable input-required tool registration succeeds");
20450 let cx = Cx::for_testing();
20451 let connection = ModernConnection::new();
20452 let inbound = InboundRequestContext::with_modern_connection(
20453 cx,
20454 148,
20455 InboundRequestTransport::Memory,
20456 &connection,
20457 );
20458 let request_ctx = inbound.request_context();
20459 let metadata_without_tasks = serde_json::json!({
20460 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20461 "io.modelcontextprotocol/clientCapabilities": {},
20462 });
20463
20464 let initial = JsonRpcRequest::new(
20465 "tools/call",
20466 Some(serde_json::json!({
20467 "_meta": metadata_without_tasks.clone(),
20468 "name": "task-capable-input-required-tool",
20469 "arguments": {},
20470 })),
20471 148_i64,
20472 );
20473 let initial_result = router
20474 .dispatch_stateless(&request_ctx, &initial)
20475 .expect("a task-capable tool may return input_required without Tasks negotiation");
20476 let request_state = initial_result["requestState"]
20477 .as_str()
20478 .expect("framework result carries opaque task-capable state")
20479 .to_owned();
20480 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
20481
20482 let retry = JsonRpcRequest::new(
20483 "tools/call",
20484 Some(serde_json::json!({
20485 "_meta": metadata_without_tasks,
20486 "name": "task-capable-input-required-tool",
20487 "arguments": {},
20488 "inputResponses": {"roots": router_roots_response_wire()},
20489 "requestState": request_state,
20490 })),
20491 149_i64,
20492 );
20493 let resumed = router
20494 .dispatch_stateless(&request_ctx, &retry)
20495 .expect("a task-capable input-required retry remains an ordinary MRTR operation");
20496 assert_eq!(resumed["resultType"], "input_required");
20497 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
20498 }
20499
20500 #[test]
20501 fn final_input_required_does_not_fallback_without_negotiation_metadata() {
20502 let legacy_calls = Arc::new(AtomicUsize::new(0));
20503 let final_calls = Arc::new(AtomicUsize::new(0));
20504 let mut router = Router::new();
20505 router
20506 .add_tool(InputRequiredTool {
20507 legacy_calls: Arc::clone(&legacy_calls),
20508 final_calls: Arc::clone(&final_calls),
20509 })
20510 .expect("tool registration succeeds");
20511 let cx = Cx::for_testing();
20512 let connection = ModernConnection::new();
20513 let inbound = InboundRequestContext::with_modern_connection(
20514 cx,
20515 144,
20516 InboundRequestTransport::Memory,
20517 &connection,
20518 );
20519 let request_ctx = inbound.request_context();
20520 let baseline = JsonRpcRequest::new(
20521 "tools/call",
20522 Some(serde_json::json!({
20523 "_meta": {
20524 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20525 "io.modelcontextprotocol/clientCapabilities": {},
20526 },
20527 "name": "input-required-tool",
20528 "arguments": {},
20529 })),
20530 144_i64,
20531 );
20532 let mut planted = baseline.clone();
20533 planted
20534 .params
20535 .as_mut()
20536 .and_then(serde_json::Value::as_object_mut)
20537 .expect("tools/call parameters are an object")
20538 .remove("_meta");
20539
20540 assert_eq!(baseline.method, planted.method);
20541 assert_eq!(baseline.id, planted.id);
20542 assert_eq!(
20543 baseline
20544 .params
20545 .as_ref()
20546 .and_then(|params| params.get("name")),
20547 planted
20548 .params
20549 .as_ref()
20550 .and_then(|params| params.get("name")),
20551 "negotiation metadata is the sole planted dimension"
20552 );
20553 let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
20554 let planted_before = serde_json::to_vec(&planted).expect("request serializes");
20555
20556 let accepted = router
20557 .dispatch_stateless(&request_ctx, &baseline)
20558 .expect("negotiated final request is accepted");
20559 assert_eq!(accepted["resultType"], "input_required");
20560 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
20561 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
20562
20563 let error = router
20564 .dispatch_stateless(&request_ctx, &planted)
20565 .expect_err("one-field no-negotiation request is rejected instead of falling back");
20566 assert_eq!(error.code, McpErrorCode::InvalidParams);
20567 assert_eq!(
20568 serde_json::to_vec(&planted).expect("rejected request serializes"),
20569 planted_before,
20570 "rejection cannot mutate caller-owned no-negotiation parameters"
20571 );
20572 assert_eq!(
20573 serde_json::to_vec(&router.tools()).expect("catalog serializes"),
20574 catalog_before,
20575 "rejection cannot mutate the installed handler catalog"
20576 );
20577 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
20578 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
20579 let retried = router
20580 .dispatch_stateless(&request_ctx, &baseline)
20581 .expect("the negotiated baseline remains accepted after rejection");
20582 assert_eq!(retried["resultType"], "input_required");
20583 assert_ne!(
20584 retried["requestState"], accepted["requestState"],
20585 "every framework-issued MRTR continuation has fresh opaque state"
20586 );
20587 assert_eq!(final_calls.load(Ordering::SeqCst), 2);
20588 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
20589 }
20590
20591 #[test]
20592 fn final_prompts_get_dispatches_direct_request_owned_handler() {
20593 let final_calls = Arc::new(AtomicUsize::new(0));
20594 let mut router = Router::new();
20595 router.add_prompt(DirectFinalPrompt {
20596 final_calls: Arc::clone(&final_calls),
20597 });
20598 let cx = Cx::for_testing();
20599 let state = SessionState::new();
20600 let request_ctx = request_context(&cx, 96, Budget::INFINITE, &state);
20601 let request = direct_final_prompt_request(96);
20602 let typed_request = CoreRequest::decode(
20603 ProtocolEra::Modern2026,
20604 "prompts/get",
20605 request.params.as_ref(),
20606 )
20607 .expect("final prompts/get request decodes through the public core surface");
20608
20609 let response = router
20610 .dispatch_stateless(&request_ctx, &request)
20611 .expect("final prompts/get reaches the direct final handler");
20612
20613 assert_eq!(
20614 response.get("resultType"),
20615 Some(&serde_json::json!("complete"))
20616 );
20617 assert_eq!(
20618 response["description"],
20619 serde_json::json!("direct final prompt description")
20620 );
20621 assert_eq!(response["messages"][0]["content"]["type"], "audio");
20622 assert_eq!(
20623 response["messages"][0]["content"]["_meta"]["com.example/direct-prompt"]["source"],
20624 "final-handler"
20625 );
20626 assert_eq!(
20627 response["messages"][0]["content"]["com.example/direct-field"],
20628 true
20629 );
20630 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
20631
20632 let wire = serde_json::to_string(&response).expect("final prompt response serializes");
20633 let CoreResult::Final(FinalCoreResult::PromptsGet { result, .. }) = typed_request
20634 .decode_result(&wire)
20635 .expect("final prompts/get result decodes through the public core surface")
20636 else {
20637 panic!("prompts/get selects the exact final result");
20638 };
20639 assert_eq!(
20640 result.payload.description.as_deref(),
20641 Some("direct final prompt description")
20642 );
20643 assert!(matches!(
20644 result.payload.messages.as_slice(),
20645 [FinalPromptMessage {
20646 content: ContentBlock::Audio { data, mime_type, .. },
20647 ..
20648 }] if data == "aGVsbG8=" && mime_type == "audio/mpeg"
20649 ));
20650 }
20651
20652 #[test]
20653 fn public_final_resource_uri_policy_admits_client_direct_https_and_rejects_only_policy_change_without_mutation()
20654 {
20655 let client_direct = HttpsCatalogResource {
20656 client_direct_https: true,
20657 };
20658 let server_mediated = HttpsCatalogResource {
20659 client_direct_https: false,
20660 };
20661 assert_eq!(
20662 client_direct.definition(),
20663 server_mediated.definition(),
20664 "the URI-use policy is the sole registration difference"
20665 );
20666
20667 let cx = Cx::for_testing();
20668 let state = SessionState::new();
20669 let request_ctx = request_context(&cx, 971, Budget::INFINITE, &state);
20670 let final_metadata = serde_json::json!({
20671 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20672 "io.modelcontextprotocol/clientCapabilities": {},
20673 });
20674
20675 let mut accepted_router = Router::new();
20676 accepted_router
20677 .add_resource_with_behavior(client_direct, crate::DuplicateBehavior::Replace)
20678 .expect("an explicitly client-direct HTTPS catalog resource is admitted");
20679 let accepted = accepted_router
20680 .dispatch_stateless(
20681 &request_ctx,
20682 &JsonRpcRequest::new(
20683 "resources/list",
20684 Some(serde_json::json!({"_meta": final_metadata.clone()})),
20685 971_i64,
20686 ),
20687 )
20688 .expect("public final resource listing succeeds");
20689 assert_eq!(
20690 accepted["resources"][0]["uri"],
20691 "https://client.example.test/catalog.txt"
20692 );
20693 let direct_read = accepted_router
20694 .dispatch_stateless(
20695 &request_ctx,
20696 &JsonRpcRequest::new(
20697 "resources/read",
20698 Some(serde_json::json!({
20699 "_meta": final_metadata.clone(),
20700 "uri": "https://client.example.test/catalog.txt",
20701 })),
20702 972_i64,
20703 ),
20704 )
20705 .expect_err("client-direct HTTPS catalog entries are not MCP-read identities");
20706 assert_eq!(direct_read.code, McpErrorCode::InvalidParams);
20707
20708 let mut rejected_router = Router::new();
20709 rejected_router.add_resource(NamedResource::new("mcp://uri-policy/unchanged"));
20710 let catalog_before =
20711 serde_json::to_vec(&rejected_router.resources()).expect("existing catalog serializes");
20712 let count_before = rejected_router.resources_count();
20713 let error = rejected_router
20714 .add_resource_with_behavior(server_mediated, crate::DuplicateBehavior::Replace)
20715 .expect_err("changing only to server-mediated policy rejects HTTPS registration");
20716 assert_eq!(error.code, McpErrorCode::InvalidParams);
20717 assert_eq!(
20718 rejected_router.resources_count(),
20719 count_before,
20720 "rejected final admission cannot add or replace a resource handler"
20721 );
20722 assert_eq!(
20723 serde_json::to_vec(&rejected_router.resources()).expect("catalog remains serializable"),
20724 catalog_before,
20725 "rejected URI-use admission leaves the prior catalog unchanged"
20726 );
20727 let unchanged = rejected_router
20728 .dispatch_stateless(
20729 &request_ctx,
20730 &JsonRpcRequest::new(
20731 "resources/list",
20732 Some(serde_json::json!({"_meta": final_metadata})),
20733 972_i64,
20734 ),
20735 )
20736 .expect("rejection leaves public final dispatch usable");
20737 assert_eq!(unchanged["resources"].as_array().map(Vec::len), Some(1));
20738 assert_eq!(
20739 unchanged["resources"][0]["uri"],
20740 "mcp://uri-policy/unchanged"
20741 );
20742 }
20743
20744 #[test]
20745 fn resource_uri_use_policy_leaves_exact_2024_resource_registration_unchanged() {
20746 let mut router = Router::new();
20747 router
20748 .add_legacy_resource_with_behavior(
20749 HttpsCatalogResource {
20750 client_direct_https: false,
20751 },
20752 crate::DuplicateBehavior::Replace,
20753 )
20754 .expect("exact-2024 registration does not consult final URI-use admission");
20755
20756 let cx = Cx::for_testing();
20757 let request_ctx = McpContext::new(cx, 976);
20758 let listed = router
20759 .handle_resources_list(&request_ctx, ListResourcesParams::default(), None)
20760 .expect("exact-2024 listing remains available");
20761 assert_eq!(listed.resources.len(), 1);
20762 assert_eq!(
20763 listed.resources[0].uri,
20764 "https://client.example.test/catalog.txt"
20765 );
20766 }
20767
20768 #[test]
20769 fn public_final_uri_policy_rechecks_prompt_and_mrtr_resource_emissions() {
20770 let initial_calls = Arc::new(AtomicUsize::new(0));
20771 let resumed_calls = Arc::new(AtomicUsize::new(0));
20772 let mut router = Router::new();
20773 router.add_prompt(ClientDirectHttpsPrompt);
20774 router.add_resource(MrtrHttpsEmbeddedResource {
20775 initial_calls: Arc::clone(&initial_calls),
20776 resumed_calls: Arc::clone(&resumed_calls),
20777 });
20778
20779 let cx = Cx::for_testing();
20780 let state = SessionState::new();
20781 let request_ctx = request_context(&cx, 973, Budget::INFINITE, &state);
20782 let metadata = serde_json::json!({
20783 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20784 "io.modelcontextprotocol/clientCapabilities": {},
20785 });
20786
20787 let prompt = router
20788 .dispatch_stateless(
20789 &request_ctx,
20790 &JsonRpcRequest::new(
20791 "prompts/get",
20792 Some(serde_json::json!({
20793 "_meta": metadata.clone(),
20794 "name": "client-direct-https-prompt",
20795 })),
20796 973_i64,
20797 ),
20798 )
20799 .expect("a client-direct HTTPS resource link is emitted from the public prompt path");
20800 assert_eq!(
20801 prompt["messages"][0]["content"]["uri"],
20802 "https://client.example.test/prompt-link"
20803 );
20804
20805 let initial = router
20806 .dispatch_stateless(
20807 &request_ctx,
20808 &JsonRpcRequest::new(
20809 "resources/read",
20810 Some(serde_json::json!({
20811 "_meta": metadata.clone(),
20812 "uri": "mcp://uri-policy/mrtr",
20813 })),
20814 974_i64,
20815 ),
20816 )
20817 .expect("initial public resource request mints MRTR state");
20818 assert_eq!(initial["resultType"], "input_required");
20819 let request_state = initial["requestState"]
20820 .as_str()
20821 .expect("framework minted MRTR state")
20822 .to_owned();
20823 let catalog_before = serde_json::to_vec(&router.resources())
20824 .expect("catalog serializes before the resumed rejection");
20825 let retry = JsonRpcRequest::new(
20826 "resources/read",
20827 Some(serde_json::json!({
20828 "_meta": metadata,
20829 "uri": "mcp://uri-policy/mrtr",
20830 "inputResponses": {"roots": router_roots_response_wire()},
20831 "requestState": request_state,
20832 })),
20833 975_i64,
20834 );
20835 let error = router.dispatch_stateless(&request_ctx, &retry).expect_err(
20836 "an MRTR-resumed HTTPS embedded resource remains server-mediated and is refused",
20837 );
20838 assert_eq!(error.code, McpErrorCode::InternalError);
20839 assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20840 assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
20841 assert_eq!(
20842 serde_json::to_vec(&router.resources()).expect("catalog remains serializable"),
20843 catalog_before,
20844 "rejected dynamic MRTR output cannot mutate registered resource state"
20845 );
20846 }
20847
20848 #[test]
20849 fn final_prompt_arguments_are_validated_before_handler_and_legacy_is_unchanged() {
20850 let final_calls = Arc::new(AtomicUsize::new(0));
20851 let legacy_calls = Arc::new(AtomicUsize::new(0));
20852 let mut router = Router::new();
20853 router.add_prompt(PromptArgumentBoundary {
20854 final_calls: Arc::clone(&final_calls),
20855 legacy_calls: Arc::clone(&legacy_calls),
20856 });
20857 let cx = Cx::for_testing();
20858 let state = SessionState::new();
20859 let request_ctx = request_context(&cx, 155, Budget::INFINITE, &state);
20860 let baseline = final_prompt_get_request(
20861 "prompt-argument-boundary",
20862 serde_json::json!({"topic": "release"}),
20863 155_i64,
20864 );
20865 let mut missing_required = baseline.clone();
20866 let removed_topic = missing_required
20867 .params
20868 .as_mut()
20869 .and_then(serde_json::Value::as_object_mut)
20870 .and_then(|params| params.get_mut("arguments"))
20871 .and_then(serde_json::Value::as_object_mut)
20872 .expect("prompt arguments are an object")
20873 .remove("topic");
20874 assert_eq!(removed_topic, Some(serde_json::json!("release")));
20875
20876 assert_eq!(baseline.method, missing_required.method);
20877 assert_eq!(baseline.id, missing_required.id);
20878 assert_eq!(
20879 baseline
20880 .params
20881 .as_ref()
20882 .and_then(|params| params.get("_meta")),
20883 missing_required
20884 .params
20885 .as_ref()
20886 .and_then(|params| params.get("_meta")),
20887 "the required argument is the sole planted dimension"
20888 );
20889 assert_eq!(
20890 baseline
20891 .params
20892 .as_ref()
20893 .and_then(|params| params.get("name")),
20894 missing_required
20895 .params
20896 .as_ref()
20897 .and_then(|params| params.get("name")),
20898 "the required argument is the sole planted dimension"
20899 );
20900
20901 let accepted = router
20902 .dispatch_stateless(&request_ctx, &baseline)
20903 .expect("the complete required-argument request is accepted");
20904 assert_eq!(accepted["resultType"], "complete");
20905 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
20906 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
20907
20908 let missing_error = router
20909 .dispatch_stateless(&request_ctx, &missing_required)
20910 .expect_err("removing only a required prompt argument is rejected");
20911 assert_eq!(missing_error.code, McpErrorCode::InvalidParams);
20912 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
20913 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
20914
20915 let unknown_error = router
20916 .dispatch_stateless(
20917 &request_ctx,
20918 &final_prompt_get_request("unknown-prompt", serde_json::json!({}), 156_i64),
20919 )
20920 .expect_err("an unknown final prompt is an invalid-params protocol error");
20921 assert_eq!(unknown_error.code, McpErrorCode::InvalidParams);
20922 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
20923
20924 let legacy = router
20925 .handle_prompts_get(
20926 &request_ctx,
20927 GetPromptParams {
20928 name: "prompt-argument-boundary".to_owned(),
20929 arguments: None,
20930 meta: None,
20931 },
20932 state,
20933 None,
20934 None,
20935 )
20936 .expect("the exact legacy prompt path retains its existing argument behavior");
20937 let legacy_wire = serde_json::to_value(&legacy).expect("legacy prompt result serializes");
20938 assert_eq!(
20939 legacy_wire["messages"][0]["content"]["text"],
20940 "legacy prompt-argument-boundary result"
20941 );
20942 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
20943 }
20944
20945 #[test]
20946 fn final_prompt_argument_snapshot_is_immutable_after_registration() {
20947 let expose_admitted_argument = Arc::new(AtomicBool::new(true));
20948 let final_calls = Arc::new(AtomicUsize::new(0));
20949 let mut router = Router::new();
20950 router.add_prompt(MutablePromptDefinition {
20951 expose_admitted_argument: Arc::clone(&expose_admitted_argument),
20952 final_calls: Arc::clone(&final_calls),
20953 });
20954 expose_admitted_argument.store(false, Ordering::SeqCst);
20955
20956 let cx = Cx::for_testing();
20957 let state = SessionState::new();
20958 let request_ctx = request_context(&cx, 156, Budget::INFINITE, &state);
20959 let accepted = final_prompt_get_request(
20960 "mutable-prompt-definition",
20961 serde_json::json!({"topic": "release"}),
20962 156_i64,
20963 );
20964 let mut missing_required = accepted.clone();
20965 let removed = missing_required
20966 .params
20967 .as_mut()
20968 .and_then(serde_json::Value::as_object_mut)
20969 .and_then(|params| params.get_mut("arguments"))
20970 .and_then(serde_json::Value::as_object_mut)
20971 .expect("prompt arguments are an object")
20972 .remove("topic");
20973 assert_eq!(removed, Some(serde_json::json!("release")));
20974 assert_eq!(accepted.method, missing_required.method);
20975 assert_eq!(accepted.id, missing_required.id);
20976 assert_eq!(
20977 accepted
20978 .params
20979 .as_ref()
20980 .and_then(|params| params.get("_meta")),
20981 missing_required
20982 .params
20983 .as_ref()
20984 .and_then(|params| params.get("_meta")),
20985 "the required argument is the sole planted dimension"
20986 );
20987 assert_eq!(
20988 accepted
20989 .params
20990 .as_ref()
20991 .and_then(|params| params.get("name")),
20992 missing_required
20993 .params
20994 .as_ref()
20995 .and_then(|params| params.get("name")),
20996 "the required argument is the sole planted dimension"
20997 );
20998
20999 let catalog = router
21000 .dispatch_stateless(
21001 &request_ctx,
21002 &JsonRpcRequest::new(
21003 "prompts/list",
21004 Some(serde_json::json!({
21005 "_meta": {
21006 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21007 "io.modelcontextprotocol/clientCapabilities": {},
21008 },
21009 })),
21010 157_i64,
21011 ),
21012 )
21013 .expect("the final prompt catalog retains its admitted argument metadata");
21014 assert_eq!(catalog["prompts"][0]["arguments"][0]["name"], "topic");
21015 assert_eq!(catalog["prompts"][0]["arguments"][0]["required"], true);
21016
21017 let response = router
21018 .dispatch_stateless(&request_ctx, &accepted)
21019 .expect("the admitted final argument remains accepted after the legacy hook changes");
21020 assert_eq!(response["resultType"], "complete");
21021 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
21022
21023 let error = router
21024 .dispatch_stateless(&request_ctx, &missing_required)
21025 .expect_err("removing only the admitted required argument is rejected");
21026 assert_eq!(error.code, McpErrorCode::InvalidParams);
21027 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
21028 }
21029
21030 #[test]
21031 fn final_prompts_get_rejects_one_field_incompatible_result_type() {
21032 let final_calls = Arc::new(AtomicUsize::new(0));
21033 let mut router = Router::new();
21034 router.add_prompt(DirectFinalPrompt {
21035 final_calls: Arc::clone(&final_calls),
21036 });
21037 let cx = Cx::for_testing();
21038 let state = SessionState::new();
21039 let request_ctx = request_context(&cx, 97, Budget::INFINITE, &state);
21040 let request = direct_final_prompt_request(97);
21041 let typed_request = CoreRequest::decode(
21042 ProtocolEra::Modern2026,
21043 "prompts/get",
21044 request.params.as_ref(),
21045 )
21046 .expect("final prompts/get request decodes through the public core surface");
21047 let accepted = router
21048 .dispatch_stateless(&request_ctx, &request)
21049 .expect("the direct final prompt response is accepted");
21050 let accepted_wire = serde_json::to_string(&accepted).expect("accepted result serializes");
21051 let mut incompatible = accepted.clone();
21052 incompatible["resultType"] = serde_json::json!("input_required");
21053
21054 let mut accepted_without_type = accepted.clone();
21055 let mut incompatible_without_type = incompatible.clone();
21056 let accepted_type = accepted_without_type
21057 .as_object_mut()
21058 .and_then(|object| object.remove("resultType"));
21059 let incompatible_type = incompatible_without_type
21060 .as_object_mut()
21061 .and_then(|object| object.remove("resultType"));
21062 assert_eq!(accepted_type, Some(serde_json::json!("complete")));
21063 assert_eq!(incompatible_type, Some(serde_json::json!("input_required")));
21064 assert_eq!(
21065 incompatible_without_type, accepted_without_type,
21066 "resultType is the sole incompatible result dimension"
21067 );
21068
21069 let incompatible_wire =
21070 serde_json::to_string(&incompatible).expect("incompatible result serializes");
21071 assert!(matches!(
21072 typed_request.decode_result(&incompatible_wire),
21073 Err(fastmcp_protocol::CoreDispatchError::ResultCodec(_))
21074 ));
21075 assert_eq!(
21076 serde_json::to_string(&accepted).expect("accepted result remains serializable"),
21077 accepted_wire,
21078 "rejecting the one-field incompatible result cannot mutate the accepted response"
21079 );
21080 assert_eq!(
21081 final_calls.load(Ordering::SeqCst),
21082 1,
21083 "local result admission cannot invoke or mutate the direct prompt handler"
21084 );
21085 let reaccepted = typed_request
21086 .decode_result(&accepted_wire)
21087 .expect("the original direct final result remains accepted");
21088 assert_eq!(
21089 serde_json::from_str::<serde_json::Value>(
21090 &reaccepted.encode().expect("reaccepted result encodes"),
21091 )
21092 .expect("reaccepted result is JSON"),
21093 accepted,
21094 "the incompatible result cannot alter the accepted final prompt contract"
21095 );
21096 }
21097
21098 #[test]
21099 fn final_resources_read_dispatches_direct_handler_without_legacy_projection() {
21100 let legacy_calls = Arc::new(AtomicUsize::new(0));
21101 let final_calls = Arc::new(AtomicUsize::new(0));
21102 let mut router = Router::new();
21103 router.add_resource(DirectFinalResource {
21104 legacy_calls: Arc::clone(&legacy_calls),
21105 final_calls: Arc::clone(&final_calls),
21106 });
21107 let cx = Cx::for_testing();
21108 let state = SessionState::new();
21109 let request_ctx = request_context(&cx, 98, Budget::INFINITE, &state);
21110
21111 let legacy = router
21112 .handle_resources_read(
21113 &request_ctx,
21114 &ReadResourceParams {
21115 uri: "file:///direct-final-resource".to_owned(),
21116 meta: None,
21117 },
21118 state.clone(),
21119 None,
21120 None,
21121 )
21122 .expect("legacy resource requests retain their exact handler path");
21123 let [LegacyResourceContent::Text { text, .. }] = legacy.contents.as_slice() else {
21124 panic!("legacy resource result retains its exact text variant");
21125 };
21126 assert_eq!(text, "legacy resource result");
21127 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
21128 assert_eq!(final_calls.load(Ordering::SeqCst), 0);
21129
21130 let request = direct_final_resource_request(98);
21131 let typed_request = CoreRequest::decode(
21132 ProtocolEra::Modern2026,
21133 "resources/read",
21134 request.params.as_ref(),
21135 )
21136 .expect("final resources/read request decodes through the public core surface");
21137 let response = router
21138 .dispatch_stateless(&request_ctx, &request)
21139 .expect("final resources/read reaches the direct final handler");
21140
21141 assert_eq!(response["resultType"], "complete");
21142 assert_eq!(response["ttlMs"], 321);
21143 assert_eq!(response["cacheScope"], "public");
21144 assert_eq!(
21145 response["contents"][0]["text"],
21146 "direct final resource result"
21147 );
21148 assert_eq!(response["contents"][0]["mimeType"], "text/markdown");
21149 assert_eq!(
21150 response["contents"][0]["_meta"]["com.example/direct-resource"]["source"],
21151 "final-handler"
21152 );
21153 assert_eq!(response["contents"][0]["com.example/direct-field"], true);
21154 assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
21155 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
21156
21157 let wire = serde_json::to_string(&response).expect("final resource response serializes");
21158 let CoreResult::Final(FinalCoreResult::ResourcesRead { result, .. }) = typed_request
21159 .decode_result(&wire)
21160 .expect("final resource result decodes through the public core surface")
21161 else {
21162 panic!("resources/read selects the exact final result");
21163 };
21164 assert_eq!(result.payload.ttl_ms.as_str(), "321");
21165 assert_eq!(result.payload.cache_scope, CacheScope::Public);
21166 assert!(matches!(
21167 result.payload.contents.as_slice(),
21168 [EmbeddedResourceContents::Text { text, mime_type, .. }]
21169 if text == "direct final resource result"
21170 && mime_type.as_deref() == Some("text/markdown")
21171 ));
21172 }
21173
21174 #[test]
21175 fn resource_read_cache_hint_provenance_controls_router_policy_not_wire_value() {
21176 let cx = Cx::for_testing();
21177 let state = SessionState::new();
21178 let request_ctx = request_context(&cx, 981, Budget::INFINITE, &state);
21179 let request = JsonRpcRequest::new(
21180 "resources/read",
21181 Some(serde_json::json!({
21182 "_meta": {
21183 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21184 "io.modelcontextprotocol/clientCapabilities": {},
21185 },
21186 "uri": "file:///sentinel-hint-resource",
21187 })),
21188 981_i64,
21189 );
21190
21191 for (provenance, expected_ttl, expected_scope) in [
21192 (
21193 FinalResourceReadCacheHintProvenance::Explicit,
21194 DEFAULT_FINAL_RESOURCE_TTL_MS,
21195 "private",
21196 ),
21197 (
21198 FinalResourceReadCacheHintProvenance::RouterPolicy,
21199 23,
21200 "public",
21201 ),
21202 ] {
21203 let mut router = Router::new();
21204 router.set_final_cache_hint_policy(
21205 CacheTtl::milliseconds(17),
21206 CacheTtl::milliseconds(23),
21207 CacheScope::Public,
21208 );
21209 router.add_resource(SentinelHintResource { provenance });
21210
21211 let response = router
21212 .dispatch_stateless(&request_ctx, &request)
21213 .expect("final resource result dispatches");
21214 assert_eq!(response["ttlMs"], expected_ttl);
21215 assert_eq!(response["cacheScope"], expected_scope);
21216 }
21217 }
21218
21219 #[test]
21220 fn final_unknown_resource_is_invalid_params_with_exact_uri_and_legacy_is_unchanged() {
21221 let legacy_calls = Arc::new(AtomicUsize::new(0));
21222 let final_calls = Arc::new(AtomicUsize::new(0));
21223 let mut router = Router::new();
21224 router.add_resource(DirectFinalResource {
21225 legacy_calls: Arc::clone(&legacy_calls),
21226 final_calls: Arc::clone(&final_calls),
21227 });
21228 let cx = Cx::for_testing();
21229 let state = SessionState::new();
21230 let request_ctx = request_context(&cx, 157, Budget::INFINITE, &state);
21231 let baseline = direct_final_resource_request(157);
21232 let mut unknown = baseline.clone();
21233 let replaced_uri = unknown
21234 .params
21235 .as_mut()
21236 .and_then(serde_json::Value::as_object_mut)
21237 .expect("resource parameters are an object")
21238 .insert(
21239 "uri".to_owned(),
21240 serde_json::json!("file:///unknown-final-resource"),
21241 );
21242 assert_eq!(
21243 replaced_uri,
21244 Some(serde_json::json!("file:///direct-final-resource"))
21245 );
21246
21247 assert_eq!(baseline.method, unknown.method);
21248 assert_eq!(baseline.id, unknown.id);
21249 assert_eq!(
21250 baseline
21251 .params
21252 .as_ref()
21253 .and_then(|params| params.get("_meta")),
21254 unknown
21255 .params
21256 .as_ref()
21257 .and_then(|params| params.get("_meta")),
21258 "the resource URI is the sole planted dimension"
21259 );
21260
21261 let accepted = router
21262 .dispatch_stateless(&request_ctx, &baseline)
21263 .expect("the registered final resource is accepted");
21264 assert_eq!(accepted["resultType"], "complete");
21265 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
21266 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
21267
21268 let error = router
21269 .dispatch_stateless(&request_ctx, &unknown)
21270 .expect_err("changing only the URI to an unknown resource is rejected");
21271 assert_eq!(error.code, McpErrorCode::InvalidParams);
21272 assert_eq!(error.message, "Resource not found");
21273 assert_eq!(
21274 error.data,
21275 Some(serde_json::json!({"uri": "file:///unknown-final-resource"}))
21276 );
21277 assert_eq!(final_calls.load(Ordering::SeqCst), 1);
21278 assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
21279
21280 let legacy_error = router
21281 .handle_resources_read(
21282 &request_ctx,
21283 &ReadResourceParams {
21284 uri: "file:///unknown-final-resource".to_owned(),
21285 meta: None,
21286 },
21287 state,
21288 None,
21289 None,
21290 )
21291 .expect_err("the exact legacy missing-resource error remains unchanged");
21292 assert_eq!(legacy_error.code, McpErrorCode::ResourceNotFound);
21293 assert_eq!(
21294 legacy_error.message,
21295 "Resource not found: file:///unknown-final-resource"
21296 );
21297 }
21298
21299 #[test]
21300 fn final_resources_read_rejects_one_field_incompatible_result_type() {
21301 let legacy_calls = Arc::new(AtomicUsize::new(0));
21302 let final_calls = Arc::new(AtomicUsize::new(0));
21303 let mut router = Router::new();
21304 router.add_resource(DirectFinalResource {
21305 legacy_calls,
21306 final_calls: Arc::clone(&final_calls),
21307 });
21308 let cx = Cx::for_testing();
21309 let state = SessionState::new();
21310 let request_ctx = request_context(&cx, 99, Budget::INFINITE, &state);
21311 let request = direct_final_resource_request(99);
21312 let typed_request = CoreRequest::decode(
21313 ProtocolEra::Modern2026,
21314 "resources/read",
21315 request.params.as_ref(),
21316 )
21317 .expect("final resources/read request decodes through the public core surface");
21318 let accepted = router
21319 .dispatch_stateless(&request_ctx, &request)
21320 .expect("the direct final resource response is accepted");
21321 let accepted_wire = serde_json::to_string(&accepted).expect("accepted result serializes");
21322 let mut incompatible = accepted.clone();
21323 incompatible["resultType"] = serde_json::json!("input_required");
21324
21325 let mut accepted_without_type = accepted.clone();
21326 let mut incompatible_without_type = incompatible.clone();
21327 let accepted_type = accepted_without_type
21328 .as_object_mut()
21329 .and_then(|object| object.remove("resultType"));
21330 let incompatible_type = incompatible_without_type
21331 .as_object_mut()
21332 .and_then(|object| object.remove("resultType"));
21333 assert_eq!(accepted_type, Some(serde_json::json!("complete")));
21334 assert_eq!(incompatible_type, Some(serde_json::json!("input_required")));
21335 assert_eq!(
21336 incompatible_without_type, accepted_without_type,
21337 "resultType is the sole incompatible result dimension"
21338 );
21339
21340 let incompatible_wire =
21341 serde_json::to_string(&incompatible).expect("incompatible result serializes");
21342 assert!(matches!(
21343 typed_request.decode_result(&incompatible_wire),
21344 Err(fastmcp_protocol::CoreDispatchError::ResultCodec(_))
21345 ));
21346 assert_eq!(
21347 serde_json::to_string(&accepted).expect("accepted result remains serializable"),
21348 accepted_wire,
21349 "rejecting the one-field incompatible result cannot mutate the accepted response"
21350 );
21351 assert_eq!(
21352 final_calls.load(Ordering::SeqCst),
21353 1,
21354 "local result admission cannot invoke or mutate the direct resource handler"
21355 );
21356 let reaccepted = typed_request
21357 .decode_result(&accepted_wire)
21358 .expect("the original direct final result remains accepted");
21359 assert_eq!(
21360 serde_json::from_str::<serde_json::Value>(
21361 &reaccepted.encode().expect("reaccepted result encodes"),
21362 )
21363 .expect("reaccepted result is JSON"),
21364 accepted,
21365 "the incompatible result cannot alter the accepted final resource contract"
21366 );
21367 }
21368
21369 #[test]
21370 fn final_catalog_preserves_resource_template_and_prompt_fields() {
21371 let resource_metadata = OpenMetadata::try_from_entries([(
21372 "com.example/resource".to_owned(),
21373 serde_json::json!({"source": "resource-handler"}),
21374 )])
21375 .expect("resource metadata is valid");
21376 let template_metadata = OpenMetadata::try_from_entries([(
21377 "com.example/template".to_owned(),
21378 serde_json::json!({"source": "template-handler"}),
21379 )])
21380 .expect("template metadata is valid");
21381 let prompt_metadata = OpenMetadata::try_from_entries([(
21382 "com.example/prompt".to_owned(),
21383 serde_json::json!({"source": "prompt-handler"}),
21384 )])
21385 .expect("prompt metadata is valid");
21386 let resource_annotations = Annotations {
21387 audience: None,
21388 priority: Some(0.25),
21389 last_modified: Some("2026-08-08T00:00:00Z".to_owned()),
21390 additional: BTreeMap::new(),
21391 };
21392 let template_annotations = Annotations {
21393 audience: None,
21394 priority: Some(0.75),
21395 last_modified: Some("2026-08-08T00:00:01Z".to_owned()),
21396 additional: BTreeMap::new(),
21397 };
21398 let resource_icon = RawIcon::try_with_details(
21399 "https://example.test/resource.png",
21400 Some("image/png".to_owned()),
21401 Some(vec!["32x32".to_owned()]),
21402 None,
21403 )
21404 .expect("resource icon is valid");
21405 let template_icon = RawIcon::try_with_details(
21406 "https://example.test/template.png",
21407 Some("image/png".to_owned()),
21408 Some(vec!["48x48".to_owned()]),
21409 None,
21410 )
21411 .expect("template icon is valid");
21412 let prompt_icon = RawIcon::try_with_details(
21413 "https://example.test/prompt.png",
21414 Some("image/png".to_owned()),
21415 Some(vec!["64x64".to_owned()]),
21416 None,
21417 )
21418 .expect("prompt icon is valid");
21419
21420 let mut router = Router::new();
21421 router.add_resource(FinalCatalogResource {
21422 metadata: resource_metadata,
21423 icons: vec![resource_icon],
21424 annotations: resource_annotations,
21425 });
21426 router.add_resource(FinalCatalogResourceTemplate {
21427 metadata: template_metadata,
21428 icons: vec![template_icon],
21429 annotations: template_annotations,
21430 });
21431 router.add_prompt(FinalCatalogPrompt {
21432 metadata: prompt_metadata,
21433 icons: vec![prompt_icon],
21434 });
21435
21436 let cx = Cx::for_testing();
21437 let state = SessionState::new();
21438 let request_ctx = request_context(&cx, 96, Budget::INFINITE, &state);
21439 let final_metadata = serde_json::json!({
21440 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21441 "io.modelcontextprotocol/clientCapabilities": {},
21442 });
21443
21444 let resources = router
21445 .dispatch_stateless(
21446 &request_ctx,
21447 &JsonRpcRequest::new(
21448 "resources/list",
21449 Some(serde_json::json!({"_meta": final_metadata.clone()})),
21450 96_i64,
21451 ),
21452 )
21453 .expect("final resource catalog is encoded");
21454 assert_eq!(resources["resources"][0]["title"], "Final Catalog Resource");
21455 assert_eq!(
21456 resources["resources"][0]["icons"][0]["src"],
21457 "https://example.test/resource.png"
21458 );
21459 assert_eq!(resources["resources"][0]["annotations"]["priority"], 0.25);
21460 assert_eq!(
21461 resources["resources"][0]["_meta"]["com.example/resource"]["source"],
21462 "resource-handler"
21463 );
21464
21465 let templates = router
21466 .dispatch_stateless(
21467 &request_ctx,
21468 &JsonRpcRequest::new(
21469 "resources/templates/list",
21470 Some(serde_json::json!({"_meta": final_metadata.clone()})),
21471 97_i64,
21472 ),
21473 )
21474 .expect("final resource-template catalog is encoded");
21475 assert_eq!(
21476 templates["resourceTemplates"][0]["title"],
21477 "Final Catalog Template"
21478 );
21479 assert_eq!(
21480 templates["resourceTemplates"][0]["icons"][0]["src"],
21481 "https://example.test/template.png"
21482 );
21483 assert_eq!(
21484 templates["resourceTemplates"][0]["annotations"]["priority"],
21485 0.75
21486 );
21487 assert_eq!(
21488 templates["resourceTemplates"][0]["_meta"]["com.example/template"]["source"],
21489 "template-handler"
21490 );
21491
21492 let prompts = router
21493 .dispatch_stateless(
21494 &request_ctx,
21495 &JsonRpcRequest::new(
21496 "prompts/list",
21497 Some(serde_json::json!({"_meta": final_metadata})),
21498 98_i64,
21499 ),
21500 )
21501 .expect("final prompt catalog is encoded");
21502 assert_eq!(prompts["prompts"][0]["title"], "Final Catalog Prompt");
21503 assert_eq!(
21504 prompts["prompts"][0]["icons"][0]["src"],
21505 "https://example.test/prompt.png"
21506 );
21507 assert_eq!(
21508 prompts["prompts"][0]["_meta"]["com.example/prompt"]["source"],
21509 "prompt-handler"
21510 );
21511 assert_eq!(prompts["prompts"][0]["arguments"][0]["required"], false);
21512 }
21513
21514 #[test]
21515 fn final_resource_catalog_missing_metadata_is_non_mutating() {
21516 let metadata = OpenMetadata::try_from_entries([(
21517 "com.example/resource".to_owned(),
21518 serde_json::json!({"source": "resource-handler"}),
21519 )])
21520 .expect("resource metadata is valid");
21521 let icon = RawIcon::try_with_details(
21522 "https://example.test/resource.png",
21523 Some("image/png".to_owned()),
21524 None,
21525 None,
21526 )
21527 .expect("resource icon is valid");
21528 let mut router = Router::new();
21529 router.add_resource(FinalCatalogResource {
21530 metadata,
21531 icons: vec![icon],
21532 annotations: Annotations::default(),
21533 });
21534 let cx = Cx::for_testing();
21535 let state = SessionState::new();
21536 let request_ctx = request_context(&cx, 99, Budget::INFINITE, &state);
21537 let baseline = JsonRpcRequest::new(
21538 "resources/list",
21539 Some(serde_json::json!({
21540 "_meta": {
21541 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21542 "io.modelcontextprotocol/clientCapabilities": {},
21543 },
21544 })),
21545 99_i64,
21546 );
21547 let mut planted = baseline.clone();
21548 planted
21549 .params
21550 .as_mut()
21551 .and_then(serde_json::Value::as_object_mut)
21552 .expect("final resource-list parameters are an object")
21553 .remove("_meta");
21554
21555 assert_eq!(baseline.method, planted.method);
21556 assert_eq!(baseline.id, planted.id);
21557 assert_eq!(
21558 baseline
21559 .params
21560 .as_ref()
21561 .and_then(serde_json::Value::as_object)
21562 .map(serde_json::Map::len),
21563 Some(1),
21564 "the final metadata object is the sole baseline parameter"
21565 );
21566 assert_eq!(
21567 planted
21568 .params
21569 .as_ref()
21570 .and_then(serde_json::Value::as_object)
21571 .map(serde_json::Map::len),
21572 Some(0),
21573 "the planted request differs only by final metadata removal"
21574 );
21575 let catalog_before = serde_json::to_vec(&router.resources()).expect("catalog serializes");
21576 let planted_before = serde_json::to_vec(&planted).expect("request serializes");
21577 let baseline_result = router
21578 .dispatch_stateless(&request_ctx, &baseline)
21579 .expect("final baseline is accepted");
21580 assert_eq!(
21581 baseline_result["resources"][0]["title"],
21582 "Final Catalog Resource"
21583 );
21584
21585 let error = router
21586 .dispatch_stateless(&request_ctx, &planted)
21587 .expect_err("only final request metadata is refused");
21588 assert_eq!(error.code, McpErrorCode::InvalidParams);
21589 assert_eq!(
21590 serde_json::to_vec(&planted).expect("rejected request serializes"),
21591 planted_before,
21592 "the one-field rejection cannot mutate caller-owned input"
21593 );
21594 assert_eq!(
21595 serde_json::to_vec(&router.resources()).expect("catalog serializes"),
21596 catalog_before,
21597 "the one-field rejection cannot mutate the resource catalog"
21598 );
21599 assert_eq!(
21600 router
21601 .dispatch_stateless(&request_ctx, &baseline)
21602 .expect("the baseline remains accepted after rejection"),
21603 baseline_result,
21604 "the one-field rejection cannot alter final field preservation"
21605 );
21606 }
21607
21608 #[test]
21609 fn core_request_decode_result_round_trips_final_catalog_and_read_cache_hints() {
21610 let metadata = OpenMetadata::try_from_entries([(
21611 "com.example/catalog".to_owned(),
21612 serde_json::json!({"source": "handler"}),
21613 )])
21614 .expect("valid final catalog metadata");
21615 let icon = RawIcon::try_with_details(
21616 "https://example.test/tool.png",
21617 Some("image/png".to_owned()),
21618 Some(vec!["48x48".to_owned()]),
21619 None,
21620 )
21621 .expect("valid final icon");
21622 let alternate_icon = RawIcon::try_with_details(
21623 "https://example.test/tool-dark.svg",
21624 Some("image/svg+xml".to_owned()),
21625 Some(vec!["any".to_owned()]),
21626 Some(fastmcp_protocol::common_types::IconTheme::Dark),
21627 )
21628 .expect("valid alternate final icon");
21629 let mut router = Router::new();
21630 let list_ttl: CacheTtl = serde_json::from_str("922337203685477580812345678901234567890")
21631 .expect("an arbitrary-width final list TTL is valid");
21632 let resource_read_ttl: CacheTtl =
21633 serde_json::from_str("184467440737095516160000000000000000000")
21634 .expect("an arbitrary-width final resource-read TTL is valid");
21635 router.set_final_cache_hint_policy(
21636 list_ttl.clone(),
21637 resource_read_ttl.clone(),
21638 CacheScope::Private,
21639 );
21640 router
21641 .add_tool(FinalCatalogTool {
21642 metadata,
21643 icons: vec![icon, alternate_icon],
21644 })
21645 .expect("final catalog tool registration succeeds");
21646 router.add_resource(NamedResource::new("file:///catalog-resource"));
21647 let cx = Cx::for_testing();
21648 let state = SessionState::new();
21649 let request_ctx = request_context(&cx, 93, Budget::INFINITE, &state);
21650
21651 let legacy = router
21652 .handle_tools_list(&request_ctx, ListToolsParams::default(), None)
21653 .expect("legacy catalog remains available");
21654 let legacy_wire = serde_json::to_value(&legacy).expect("legacy catalog serializes");
21655 assert!(legacy_wire.get("ttlMs").is_none());
21656 assert!(legacy_wire.get("cacheScope").is_none());
21657 assert!(legacy_wire["tools"][0].get("icon").is_some());
21658 assert!(legacy_wire["tools"][0].get("version").is_some());
21659 assert!(legacy_wire["tools"][0].get("tags").is_some());
21660
21661 let final_list_params = serde_json::json!({
21662 "_meta": {
21663 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21664 "io.modelcontextprotocol/clientCapabilities": {},
21665 },
21666 });
21667 let final_list_request = CoreRequest::decode(
21668 ProtocolEra::Modern2026,
21669 "tools/list",
21670 Some(&final_list_params),
21671 )
21672 .expect("final catalog request decodes through the public core surface");
21673 let modern_list = router
21674 .dispatch_stateless(
21675 &request_ctx,
21676 &JsonRpcRequest::new("tools/list", Some(final_list_params), 93_i64),
21677 )
21678 .expect("final catalog is projected through the exact model");
21679 assert_eq!(modern_list["resultType"], "complete");
21680 assert_eq!(modern_list["ttlMs"].to_string(), list_ttl.as_str());
21681 assert_eq!(modern_list["cacheScope"], "private");
21682 assert_eq!(modern_list["tools"][0]["title"], "Exact Final Catalog Tool");
21683 assert_eq!(
21684 modern_list["tools"][0]["annotations"]["title"],
21685 "Exact annotation title"
21686 );
21687 assert_eq!(
21688 modern_list["tools"][0]["icons"].as_array().map(Vec::len),
21689 Some(2)
21690 );
21691 assert_eq!(
21692 modern_list["tools"][0]["icons"][0]["sizes"],
21693 serde_json::json!(["48x48"])
21694 );
21695 assert_eq!(
21696 modern_list["tools"][0]["_meta"]["com.example/catalog"]["source"],
21697 "handler"
21698 );
21699 assert_eq!(modern_list["tools"][0]["outputSchema"]["type"], "object");
21700 assert!(modern_list["tools"][0].get("icon").is_none());
21701 assert!(modern_list["tools"][0].get("version").is_none());
21702 assert!(modern_list["tools"][0].get("tags").is_none());
21703 let modern_list_wire =
21704 serde_json::to_string(&modern_list).expect("final catalog response serializes");
21705 let CoreResult::Final(FinalCoreResult::ToolsList { result, .. }) = final_list_request
21706 .decode_result(&modern_list_wire)
21707 .expect("final catalog response decodes through the public core surface")
21708 else {
21709 panic!("tools/list selects the exact final catalog result");
21710 };
21711 assert_eq!(result.payload.ttl_ms.as_str(), list_ttl.as_str());
21712 assert_eq!(result.payload.cache_scope, CacheScope::Private);
21713 let final_tool = result
21714 .payload
21715 .tools
21716 .first()
21717 .expect("final catalog contains the registered tool");
21718 assert_eq!(
21719 final_tool.title.as_deref(),
21720 Some("Exact Final Catalog Tool")
21721 );
21722 assert_eq!(
21723 final_tool
21724 .annotations
21725 .as_ref()
21726 .and_then(|annotations| annotations.title.as_deref()),
21727 Some("Exact annotation title")
21728 );
21729 assert_eq!(final_tool.icons.as_ref().map(Vec::len), Some(2));
21730 assert_eq!(
21731 final_tool
21732 .icons
21733 .as_ref()
21734 .and_then(|icons| icons.first())
21735 .map(|icon| icon.src.as_str()),
21736 Some("https://example.test/tool.png")
21737 );
21738 assert_eq!(
21739 final_tool
21740 .meta
21741 .as_ref()
21742 .and_then(|metadata| metadata.get("com.example/catalog"))
21743 .and_then(|value| value.get("source"))
21744 .and_then(serde_json::Value::as_str),
21745 Some("handler")
21746 );
21747 assert_eq!(
21748 final_tool
21749 .output_schema
21750 .as_ref()
21751 .and_then(|schema| schema.get("type"))
21752 .and_then(serde_json::Value::as_str),
21753 Some("object")
21754 );
21755
21756 let final_read_params = serde_json::json!({
21757 "_meta": {
21758 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21759 "io.modelcontextprotocol/clientCapabilities": {},
21760 },
21761 "uri": "file:///catalog-resource",
21762 });
21763 let final_read_request = CoreRequest::decode(
21764 ProtocolEra::Modern2026,
21765 "resources/read",
21766 Some(&final_read_params),
21767 )
21768 .expect("final resource-read request decodes through the public core surface");
21769 let modern_read = router
21770 .dispatch_stateless(
21771 &request_ctx,
21772 &JsonRpcRequest::new("resources/read", Some(final_read_params), 94_i64),
21773 )
21774 .expect("final resource content is projected through the final model");
21775 assert_eq!(modern_read["ttlMs"].to_string(), resource_read_ttl.as_str());
21776 assert_eq!(modern_read["cacheScope"], "private");
21777 assert_eq!(
21778 modern_read["contents"][0]["uri"],
21779 "file:///catalog-resource"
21780 );
21781 assert_eq!(modern_read["contents"][0]["text"], "content");
21782 let modern_read_wire =
21783 serde_json::to_string(&modern_read).expect("final resource-read response serializes");
21784 let CoreResult::Final(FinalCoreResult::ResourcesRead { result, .. }) = final_read_request
21785 .decode_result(&modern_read_wire)
21786 .expect("final resource-read response decodes through the public core surface")
21787 else {
21788 panic!("resources/read selects the exact final read result");
21789 };
21790 assert_eq!(result.payload.ttl_ms.as_str(), resource_read_ttl.as_str());
21791 assert_eq!(result.payload.cache_scope, CacheScope::Private);
21792 assert!(matches!(
21793 result.payload.contents.as_slice(),
21794 [EmbeddedResourceContents::Text { text, .. }] if text == "content"
21795 ));
21796 }
21797
21798 #[test]
21799 fn final_catalog_missing_metadata_is_non_mutating() {
21800 let mut router = Router::new();
21801 router
21802 .add_tool(NamedTool::new("metadata-guarded-tool"))
21803 .expect("tool registration succeeds");
21804 let cx = Cx::for_testing();
21805 let state = SessionState::new();
21806 let request_ctx = request_context(&cx, 95, Budget::INFINITE, &state);
21807 let baseline = JsonRpcRequest::new(
21808 "tools/list",
21809 Some(serde_json::json!({
21810 "_meta": {
21811 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21812 "io.modelcontextprotocol/clientCapabilities": {},
21813 },
21814 })),
21815 95_i64,
21816 );
21817 let mut planted = baseline.clone();
21818 planted
21819 .params
21820 .as_mut()
21821 .and_then(serde_json::Value::as_object_mut)
21822 .expect("final list parameters are an object")
21823 .remove("_meta");
21824 assert_eq!(baseline.method, planted.method);
21825 assert_eq!(baseline.id, planted.id);
21826 let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
21827 let planted_before = serde_json::to_vec(&planted).expect("request serializes");
21828
21829 let baseline_result = router
21830 .dispatch_stateless(&request_ctx, &baseline)
21831 .expect("the final baseline is accepted");
21832 let error = router
21833 .dispatch_stateless(&request_ctx, &planted)
21834 .expect_err("only missing final metadata is refused");
21835 assert_eq!(error.code, McpErrorCode::InvalidParams);
21836 assert_eq!(
21837 serde_json::to_vec(&planted).expect("rejected request serializes"),
21838 planted_before,
21839 "the rejected one-field request remains unchanged"
21840 );
21841 assert_eq!(
21842 serde_json::to_vec(&router.tools()).expect("catalog serializes"),
21843 catalog_before,
21844 "the rejected one-field request cannot mutate the catalog"
21845 );
21846 assert_eq!(
21847 router
21848 .dispatch_stateless(&request_ctx, &baseline)
21849 .expect("the unchanged final baseline remains accepted"),
21850 baseline_result,
21851 "the rejection cannot alter final cache hints or catalog projection"
21852 );
21853 }
21854
21855 #[test]
21856 fn srv_04_modern_owned_dispatch_runs_requests_concurrently() {
21857 let started = Arc::new(AtomicUsize::new(0));
21858 let completed = Arc::new(Mutex::new(Vec::new()));
21859 let mut router = Router::new();
21860 router
21861 .add_tool(ConcurrentModernTool::new(
21862 Arc::clone(&started),
21863 Arc::clone(&completed),
21864 ))
21865 .expect("tool registration succeeds");
21866 let router = Arc::new(router);
21867 let runtime = RuntimeBuilder::current_thread()
21868 .build()
21869 .expect("the test runtime is available");
21870 let runtime_handle = runtime.handle();
21871 let mut first = spawn_owned_modern_request(
21872 &runtime_handle,
21873 Arc::clone(&router),
21874 401,
21875 "modern-one",
21876 "one",
21877 None,
21878 );
21879 let mut second =
21880 spawn_owned_modern_request(&runtime_handle, router, 402, "modern-two", "two", None);
21881
21882 let (first, second) = runtime.block_on(async {
21883 let cx = Cx::current().expect("block_on installs an observer context");
21884 let first = first
21885 .recv(&cx)
21886 .await
21887 .expect("the first owner reports a terminal result");
21888 let second = second
21889 .recv(&cx)
21890 .await
21891 .expect("the second owner reports a terminal result");
21892 (first, second)
21893 });
21894 let first = first.expect("the first modern request completes");
21895 let second = second.expect("the second modern request completes");
21896
21897 assert_eq!(started.load(Ordering::SeqCst), 2);
21898 assert_eq!(
21899 first.get("resultType"),
21900 Some(&serde_json::json!("complete"))
21901 );
21902 assert_eq!(
21903 second.get("resultType"),
21904 Some(&serde_json::json!("complete"))
21905 );
21906 let mut completed = completed
21907 .lock()
21908 .expect("completion probe lock is not poisoned")
21909 .clone();
21910 completed.sort();
21911 assert_eq!(completed, vec!["one".to_string(), "two".to_string()]);
21912 }
21913
21914 #[test]
21915 fn srv_04_modern_owned_cancellation_does_not_change_sibling() {
21916 let started = Arc::new(AtomicUsize::new(0));
21917 let completed = Arc::new(Mutex::new(Vec::new()));
21918 let mut router = Router::new();
21919 router
21920 .add_tool(ConcurrentModernTool::new(
21921 Arc::clone(&started),
21922 Arc::clone(&completed),
21923 ))
21924 .expect("tool registration succeeds");
21925 let router = Arc::new(router);
21926 let runtime = RuntimeBuilder::current_thread()
21927 .build()
21928 .expect("the test runtime is available");
21929 let runtime_handle = runtime.handle();
21930 let (cancel_control_sender, mut cancel_control) = oneshot::channel();
21931 let mut cancelled = spawn_owned_modern_request(
21932 &runtime_handle,
21933 Arc::clone(&router),
21934 403,
21935 "modern-cancelled",
21936 "cancelled",
21937 Some(cancel_control_sender),
21938 );
21939 let mut sibling = spawn_owned_modern_request(
21940 &runtime_handle,
21941 router,
21942 404,
21943 "modern-sibling",
21944 "sibling",
21945 None,
21946 );
21947
21948 let (cancelled, sibling) = runtime.block_on(async {
21949 let observer_cx = Cx::current().expect("block_on installs an observer context");
21950 let cancelled_cx = cancel_control
21951 .recv(&observer_cx)
21952 .await
21953 .expect("the request owner exposes its cancellation context");
21954 let admission_deadline = std::time::Instant::now() + Duration::from_secs(10);
21957 while started.load(Ordering::SeqCst) < 2 {
21958 assert!(
21959 std::time::Instant::now() < admission_deadline,
21960 "both owned modern requests must start before the admission deadline"
21961 );
21962 yield_once().await;
21963 }
21964 cancelled_cx.cancel_with(CancelKind::User, Some("test single-request cancellation"));
21965 let cancelled = cancelled
21966 .recv(&observer_cx)
21967 .await
21968 .expect("the cancelled owner reports a terminal result");
21969 let sibling = sibling
21970 .recv(&observer_cx)
21971 .await
21972 .expect("the sibling owner reports a terminal result");
21973 (cancelled, sibling)
21974 });
21975
21976 let cancelled = cancelled.expect_err("only the selected request is cancelled");
21977 assert_eq!(cancelled.code, McpErrorCode::RequestCancelled);
21978 let sibling = sibling.expect("the sibling completes despite peer cancellation");
21979 assert_eq!(
21980 sibling.get("resultType"),
21981 Some(&serde_json::json!("complete"))
21982 );
21983 assert_eq!(
21984 completed
21985 .lock()
21986 .expect("completion probe lock is not poisoned")
21987 .as_slice(),
21988 ["sibling"],
21989 "cancelling one request cannot add, remove, or alter sibling completion"
21990 );
21991 }
21992
21993 #[test]
21994 fn mount_result_with_warning_and_no_error_is_successful() {
21995 let result = MountResult {
21996 tools: 0,
21997 resources: 0,
21998 resource_templates: 0,
21999 prompts: 0,
22000 warnings: vec!["something".to_string()],
22001 errors: vec![],
22002 };
22003 assert!(result.is_success());
22004 assert!(!result.has_components());
22005 }
22006
22007 struct AppsLinkedTool;
22008
22009 impl ToolHandler for AppsLinkedTool {
22010 fn definition(&self) -> Tool {
22011 Tool {
22012 name: "apps-linked-tool".to_owned(),
22013 description: None,
22014 input_schema: serde_json::json!({"type": "object"}),
22015 output_schema: None,
22016 icon: None,
22017 version: None,
22018 tags: Vec::new(),
22019 annotations: None,
22020 }
22021 }
22022
22023 fn final_definition(&self) -> Option<FinalTool> {
22024 let metadata = fastmcp_protocol::McpAppsToolMetadata::try_new(
22025 Some(
22026 AbsoluteUri::parse("ui://weather/dashboard").expect("fixed Apps URI is valid"),
22027 ),
22028 Some(vec![fastmcp_protocol::McpAppsToolVisibility::App]),
22029 )
22030 .expect("fixed Apps metadata is valid")
22031 .to_open_metadata()
22032 .expect("fixed Apps metadata serializes");
22033 Some(FinalTool {
22034 name: "apps-linked-tool".to_owned(),
22035 title: None,
22036 description: None,
22037 input_schema: serde_json::json!({"type": "object"}),
22038 output_schema: None,
22039 annotations: None,
22040 icons: None,
22041 meta: Some(metadata),
22042 })
22043 }
22044
22045 fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
22046 Ok(Vec::new())
22047 }
22048 }
22049
22050 struct AppsBoundResource {
22051 mime_type: &'static str,
22052 }
22053
22054 impl ResourceHandler for AppsBoundResource {
22055 fn definition(&self) -> Resource {
22056 Resource {
22057 uri: "ui://weather/dashboard".to_owned(),
22058 name: "weather-dashboard".to_owned(),
22059 description: None,
22060 mime_type: Some(self.mime_type.to_owned()),
22061 icon: None,
22062 version: None,
22063 tags: Vec::new(),
22064 }
22065 }
22066
22067 fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
22068 Ok(Vec::new())
22069 }
22070 }
22071
22072 #[test]
22073 fn apps_tool_registration_requires_a_registered_html_ui_resource() {
22074 let mut router = Router::new();
22075 router
22076 .add_mcp_apps_ui_resource_with_behavior(
22077 AppsBoundResource {
22078 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
22079 },
22080 crate::DuplicateBehavior::Error,
22081 )
22082 .expect("matching Apps HTML resource registers first");
22083 router
22084 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
22085 .expect("tool binding to the registered Apps HTML resource is admitted");
22086 assert!(router.tools().is_empty(), "Apps tools are final-only");
22087 assert!(
22088 router
22089 .resolve_tool_for_era("apps-linked-tool", Some(ProtocolEra::Legacy2024),)
22090 .is_none()
22091 );
22092 assert!(
22093 router
22094 .resolve_tool_for_era("apps-linked-tool", Some(ProtocolEra::Modern2026),)
22095 .is_some()
22096 );
22097 }
22098
22099 #[test]
22100 fn apps_tool_registration_rejects_only_a_non_html_ui_resource_without_mutation() {
22101 let mut router = Router::new();
22102 router
22103 .add_final_resource_with_behavior(
22104 AppsBoundResource {
22105 mime_type: "text/plain",
22106 },
22107 crate::DuplicateBehavior::Error,
22108 )
22109 .expect("the one-field MIME variant remains a valid final resource");
22110
22111 let error = router
22112 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
22113 .expect_err("only replacing the Apps HTML MIME type rejects the linked tool");
22114 assert_eq!(error.code, McpErrorCode::InvalidRequest);
22115 assert!(
22116 router.tools().is_empty(),
22117 "rejected Apps linkage cannot add the tool to the legacy or final catalog"
22118 );
22119 }
22120
22121 #[test]
22122 fn generic_registration_rejects_apps_ui_resources_and_tools_without_mutation() {
22123 let mut router = Router::new();
22124 let resource_error = router
22125 .add_resource_with_behavior(
22126 AppsBoundResource {
22127 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
22128 },
22129 crate::DuplicateBehavior::Error,
22130 )
22131 .expect_err("generic registration must not expose an Apps View to exact 2024");
22132 assert_eq!(resource_error.code, McpErrorCode::InvalidRequest);
22133 assert!(router.resources().is_empty());
22134
22135 router
22136 .add_mcp_apps_ui_resource_with_behavior(
22137 AppsBoundResource {
22138 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
22139 },
22140 crate::DuplicateBehavior::Error,
22141 )
22142 .expect("the negotiated final-only Apps resource registers");
22143 let tool_error = router
22144 .add_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
22145 .expect_err("generic registration must not publish Apps metadata without opt-in");
22146 assert_eq!(tool_error.code, McpErrorCode::InvalidRequest);
22147 assert!(router.tools().is_empty());
22148 }
22149
22150 #[test]
22151 fn apps_binding_mounts_reject_dangling_tools_replacements_and_prefixes_atomically() {
22152 let mut source = Router::new();
22153 source
22154 .add_mcp_apps_ui_resource_with_behavior(
22155 AppsBoundResource {
22156 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
22157 },
22158 crate::DuplicateBehavior::Error,
22159 )
22160 .expect("source Apps resource registers");
22161 source
22162 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
22163 .expect("source Apps tool registers");
22164
22165 let mut tools_only_destination = Router::new();
22166 let tools_only = tools_only_destination.mount_tools(source, None);
22167 assert!(!tools_only.is_success());
22168 assert!(tools_only_destination.tools().is_empty());
22169
22170 let mut destination = Router::new();
22171 destination
22172 .add_mcp_apps_ui_resource_with_behavior(
22173 AppsBoundResource {
22174 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
22175 },
22176 crate::DuplicateBehavior::Error,
22177 )
22178 .expect("destination Apps resource registers");
22179 destination
22180 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
22181 .expect("destination Apps tool registers");
22182
22183 let mut incompatible_resource = Router::new();
22184 incompatible_resource
22185 .add_final_resource_with_behavior(
22186 AppsBoundResource {
22187 mime_type: "text/plain",
22188 },
22189 crate::DuplicateBehavior::Error,
22190 )
22191 .expect("non-Apps final resource is independently admissible");
22192 let replacement = destination.mount_resources_with_behavior(
22193 incompatible_resource,
22194 None,
22195 crate::DuplicateBehavior::Replace,
22196 );
22197 assert!(!replacement.is_success());
22198 assert_eq!(
22199 destination
22200 .final_resources
22201 .get("ui://weather/dashboard")
22202 .expect("rejected replacement retains the HTML resource")
22203 .definition
22204 .mime_type
22205 .as_deref(),
22206 Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE)
22207 );
22208
22209 let mut prefixed_source = Router::new();
22210 prefixed_source
22211 .add_mcp_apps_ui_resource_with_behavior(
22212 AppsBoundResource {
22213 mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
22214 },
22215 crate::DuplicateBehavior::Error,
22216 )
22217 .expect("prefixed source Apps resource registers");
22218 prefixed_source
22219 .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
22220 .expect("prefixed source Apps tool registers");
22221 let mut prefixed_destination = Router::new();
22222 let prefixed = prefixed_destination.mount(prefixed_source, Some("peer"));
22223 assert!(!prefixed.is_success());
22224 assert!(prefixed_destination.tools().is_empty());
22225 assert!(prefixed_destination.resources().is_empty());
22226 }
22227}