Skip to main content

fastmcp_server/
router.rs

1//! Request router for MCP servers.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::io::{self, Write};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Weak};
7use std::task::Poll;
8use std::time::Duration;
9
10#[cfg(feature = "tasks")]
11use crate::FinalTaskRuntime;
12use crate::Session;
13use crate::auth::AuthRequest;
14use crate::bidirectional::{
15    MrtrCompletedInputs, MrtrExchangeBinding, MrtrExchangeRegistry, MrtrInputKind,
16    MrtrInputRequest, MrtrInputRequests, MrtrInputRequired, MrtrRetry,
17};
18use crate::handler::{
19    BidirectionalSenders, BoxFuture, FinalMethodOutcome, FinalResourceReadCacheHintProvenance,
20    FinalResourceUriUse, FinalToolOutcome, ProgressNotificationSender, ResourceUriUsePolicy,
21    UriParams, empty_final_result_meta, encode_final_complete_result,
22};
23use crate::handler::{
24    BoxedCompletionHandler, BoxedPromptHandler, BoxedResourceHandler, BoxedToolHandler,
25    CompletionHandler, PromptHandler, ResourceHandler, ToolErrorKind, ToolHandler,
26};
27#[cfg(all(feature = "proxy", feature = "tasks"))]
28use crate::proxy::ProxyFinalTaskRelay;
29use crate::session::SessionPrincipalBinding;
30#[cfg(test)]
31use asupersync::time::wall_now;
32use asupersync::types::Time;
33use asupersync::{Budget, Cx, Outcome};
34use base64::Engine as _;
35use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
36use fastmcp_core::logging::{debug, targets, trace};
37use fastmcp_core::{
38    McpContext, McpError, McpErrorCode, McpOutcome, McpResult, PromptCaller, PromptGetResult,
39    PromptMessageItem, PromptMessageRole, SessionState, block_on, sha256_bounded,
40};
41#[cfg(feature = "tasks")]
42use fastmcp_protocol::MissingRequiredClientCapabilityError;
43use fastmcp_protocol::common_types::{
44    AbsoluteUri, Annotations, ContentBlock, EmbeddedResourceContents, OpenMetadata, RawIcon,
45};
46#[cfg(feature = "tasks")]
47use fastmcp_protocol::extensions::OFFICIAL_TASKS_EXTENSION_ID;
48use fastmcp_protocol::methods::COMPLETION_COMPLETE;
49use fastmcp_protocol::protocol_policy::ProtocolEra;
50use fastmcp_protocol::uri_template::{ReversibleResourceTemplate, UriTemplatePart};
51use fastmcp_protocol::{
52    AdmittedSchema, CacheScope, CacheTtl, CallToolParams, CallToolResult, CompleteResult, Content,
53    CoreRequest, CoreResult, FinalCallToolParams, FinalCallToolResult, FinalCompletionParams,
54    FinalCompletionReference, FinalCompletionResult, FinalCoreRequest, FinalCoreResult,
55    FinalGetPromptParams, FinalGetPromptResult, FinalInputResponses, FinalListParams,
56    FinalListPromptsResult, FinalListResourceTemplatesResult, FinalListResourcesResult,
57    FinalListToolsResult, FinalPrompt, FinalPromptArgument, FinalReadResourceParams,
58    FinalReadResourceResult, FinalResource, FinalResourceTemplate, FinalTool, FinalToolAnnotations,
59    GetPromptParams, GetPromptResult, InitializeParams, InitializeResult, InputRequiredResult,
60    JsonRpcRequest, LegacyCompletionParams, LegacyCompletionResult, LegacyContent,
61    LegacyCoreRequest, LegacyPromptMessage, LegacyResourceContent, ListPromptsParams,
62    ListPromptsResult, ListResourceTemplatesParams, ListResourceTemplatesResult,
63    ListResourcesParams, ListResourcesResult, ListToolsParams, ListToolsResult, PROTOCOL_VERSION,
64    ProgressMarker, Prompt, PromptMessage, ReadResourceParams, ReadResourceResult, Resource,
65    ResourceContent, ResourceTemplate, Role, ServerBehavior, ServerBehaviorRegistry, TemplateValue,
66    Tool, admit_final_schema, exact_json_to_serde, validate, validate_strict,
67};
68
69/// Type alias for a notification sender callback.
70///
71/// This callback is used to send notifications (like progress updates) back to the client
72/// during request handling. The callback receives a JSON-RPC request (notification format).
73pub type NotificationSender = Arc<dyn Fn(JsonRpcRequest) + Send + Sync>;
74
75/// Allowlisted transport provenance attached to a sanitized inbound request.
76///
77/// This deliberately contains no peer address, headers, cookies, or
78/// credentials. Transport implementations retain those raw values inside their
79/// authentication boundary and pass only one of these validated facts to the
80/// server dispatch layer.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum InboundRequestTransport {
83    /// Standard input/output framing.
84    Stdio,
85    /// Streamable HTTP request handling.
86    Http,
87    /// Server-sent events transport.
88    Sse,
89    /// WebSocket transport.
90    WebSocket,
91    /// In-process transport used by embeddings and tests.
92    Memory,
93}
94
95/// Opaque custody for one transport's singleton `Authorization` field.
96///
97/// This deliberately has no `Debug` implementation and no raw-value getter.
98/// Only server admission can convert it into an [`AuthRequest`], so extension
99/// middleware and handlers cannot observe a native transport credential.
100#[derive(Clone, Default)]
101pub(crate) struct TransportAuthorization(Option<String>);
102
103impl TransportAuthorization {
104    /// Captures an already cardinality-validated native authorization field.
105    #[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
125/// Sanitized, immutable ingress facts for one server dispatch.
126///
127/// The type intentionally has no `Clone`, `Serialize`, or `Debug`
128/// implementation. In particular, it offers no channel for raw headers or
129/// credentials. A native transport may retain its singleton authorization
130/// field in crate-private custody for server admission, but it never exposes
131/// that field through this public context. The server creates a fresh
132/// request-scoped [`McpContext`] from these facts for every dispatch.
133pub 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    /// Creates sanitized facts after transport metadata validation has
145    /// completed.
146    #[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    /// Creates sanitized facts for a request that belongs to one live modern
160    /// transport connection. The connection owns both the durable partition
161    /// used to bind MRTR retries and the cancellation authority that makes
162    /// retained continuations unusable after peer disconnect.
163    #[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    /// Creates sanitized modern connection facts while retaining the native
180    /// authorization field solely for server admission.
181    #[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    /// Returns the transport's allowlisted provenance fact.
218    #[must_use]
219    pub const fn transport(&self) -> InboundRequestTransport {
220        self.transport
221    }
222
223    /// Returns the request identity selected by the transport.
224    #[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        // Anonymous modern HTTP POSTs are request-local. Give each one a
231        // fresh SessionState so disable_*/enable_* can mutate and publish
232        // list_changed to live subscriptions/listen streams without inventing
233        // a durable Mcp-Session-Id.
234        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
267/// Durable state and retained-continuation ownership for one modern stdio
268/// connection or retained modern HTTP session.
269///
270/// Dropping the owner is a terminal peer-disconnect event: it cancels every
271/// MRTR continuation minted by requests on this connection or session.
272/// Request contexts retain only clones of its state and cancellation capability, so a retained
273/// continuation cannot outlive the connection or session that issued it.
274pub(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    /// One modern HTTP POST. The bag is request-local: disable/enable can
293    /// publish `list_changed` without a durable `Mcp-Session-Id`, and the
294    /// response cache must not treat it as a partition identity.
295    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/// Tag filtering parameters for list operations.
327#[derive(Debug, Clone, Default)]
328pub struct TagFilters<'a> {
329    /// Only include components with ALL of these tags (AND logic).
330    pub include: Option<&'a [String]>,
331    /// Exclude components with ANY of these tags (OR logic).
332    pub exclude: Option<&'a [String]>,
333}
334
335impl<'a> TagFilters<'a> {
336    /// Creates tag filters from include and exclude vectors.
337    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    /// Returns true if the given component tags pass the filter.
345    ///
346    /// - Include filter: component must have ALL include tags (AND logic)
347    /// - Exclude filter: component is rejected if it has ANY exclude tag (OR logic)
348    /// - Tag matching is case-insensitive
349    pub fn matches(&self, component_tags: &[String]) -> bool {
350        // Normalize component tags to lowercase for comparison
351        let component_tags_lower: Vec<String> =
352            component_tags.iter().map(|t| t.to_lowercase()).collect();
353
354        // Include filter: must have ALL specified tags
355        if let Some(include) = self.include {
356            // Empty include array means no filter (all pass)
357            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        // Exclude filter: rejected if has ANY specified tag
368        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/// The catalog a continuation cursor was minted for.
401///
402/// Paged list cursors bind the offset to this catalog discriminator, the
403/// router catalog revision, the normalized query filters, and—for exact-2024
404/// lists that filter session-disabled members—the visible-member digest so a
405/// continuation cannot cross list methods, observe a changed catalog, replay
406/// under different tag filters, or silently skip after a session disable.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
408#[serde(rename_all = "snake_case")]
409enum FinalCatalogKind {
410    Tools,
411    Resources,
412    ResourceTemplates,
413    Prompts,
414}
415
416/// Canonical identity for the final list filters whose semantics affect a page.
417///
418/// Tag matching is case-insensitive and ignores duplicate/order differences,
419/// so cursors bind those normalized semantics rather than incidental input
420/// spelling. `None` and an explicitly empty tag array consequently identify
421/// the same unfiltered catalog.
422#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
423#[serde(deny_unknown_fields)]
424struct FinalCatalogQuery {
425    include_tags: Vec<String>,
426    exclude_tags: Vec<String>,
427}
428
429impl FinalCatalogQuery {
430    fn from_final_list_params(params: &FinalListParams) -> Self {
431        Self::from_tag_filters(
432            params.include_tags.as_deref(),
433            params.exclude_tags.as_deref(),
434        )
435    }
436
437    fn from_tag_filters(include_tags: Option<&[String]>, exclude_tags: Option<&[String]>) -> Self {
438        Self {
439            include_tags: canonical_final_catalog_tags(include_tags),
440            exclude_tags: canonical_final_catalog_tags(exclude_tags),
441        }
442    }
443}
444
445fn canonical_final_catalog_tags(tags: Option<&[String]>) -> Vec<String> {
446    let Some(tags) = tags else {
447        return Vec::new();
448    };
449    let mut canonical = tags
450        .iter()
451        .map(|tag| tag.to_lowercase())
452        .collect::<Vec<_>>();
453    canonical.sort_unstable();
454    canonical.dedup();
455    canonical
456}
457
458#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
459#[serde(deny_unknown_fields)]
460struct FinalCatalogCursor {
461    catalog: FinalCatalogKind,
462    revision: u64,
463    query: FinalCatalogQuery,
464    offset: u64,
465    /// Digest of the exact-2024 visible member keys after session filtering.
466    ///
467    /// Final catalogs are connection-independent and omit this member. A
468    /// session-filtered list binds it so a later disable cannot reuse an
469    /// offset into a shorter snapshot.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    visibility_digest: Option<String>,
472}
473
474fn decode_final_catalog_cursor_offset(
475    cursor: Option<&str>,
476    expected_catalog: FinalCatalogKind,
477    expected_revision: u64,
478    expected_query: &FinalCatalogQuery,
479    expected_visibility_digest: Option<&str>,
480    catalog_length: usize,
481) -> McpResult<usize> {
482    let Some(cursor) = cursor else {
483        return Ok(0);
484    };
485
486    let decoded = BASE64_STANDARD.decode(cursor).map_err(|_| {
487        McpError::invalid_params("Invalid final catalog cursor (base64 decode failed)")
488    })?;
489    let cursor = serde_json::from_slice::<FinalCatalogCursor>(&decoded).map_err(|_| {
490        McpError::invalid_params("Invalid final catalog cursor (JSON parse failed)")
491    })?;
492    if cursor.catalog != expected_catalog {
493        return Err(McpError::invalid_params(
494            "final catalog cursor belongs to another list method",
495        ));
496    }
497    if cursor.revision != expected_revision {
498        return Err(McpError::invalid_params(
499            "final catalog cursor references a stale catalog revision",
500        ));
501    }
502    if &cursor.query != expected_query {
503        return Err(McpError::invalid_params(
504            "final catalog cursor does not match the requested query filters",
505        ));
506    }
507    if cursor.visibility_digest.as_deref() != expected_visibility_digest {
508        return Err(McpError::invalid_params(
509            "final catalog cursor does not match the requested session visibility",
510        ));
511    }
512    let offset = usize::try_from(cursor.offset)
513        .map_err(|_| McpError::invalid_params("Invalid final catalog cursor (offset too large)"))?;
514    if offset >= catalog_length {
515        return Err(McpError::invalid_params(
516            "final catalog cursor offset is outside the requested catalog page",
517        ));
518    }
519    Ok(offset)
520}
521
522fn parse_stateless_params<T: serde::de::DeserializeOwned>(
523    params: Option<serde_json::Value>,
524) -> McpResult<T> {
525    let value = params.ok_or_else(|| McpError::invalid_params("Missing required parameters"))?;
526    serde_json::from_value(value).map_err(|error| McpError::invalid_params(error.to_string()))
527}
528
529fn parse_stateless_params_or_default<T: serde::de::DeserializeOwned + Default>(
530    params: Option<serde_json::Value>,
531) -> McpResult<T> {
532    match params {
533        Some(value) => serde_json::from_value(value)
534            .map_err(|error| McpError::invalid_params(error.to_string())),
535        None => Ok(T::default()),
536    }
537}
538
539/// Converts a completed stateless handler result through the final result
540/// contract while preserving the original typed [`McpError`] on refusal.
541fn encode_stateless_handler_result<T: serde::Serialize>(
542    result: McpResult<T>,
543) -> McpResult<serde_json::Value> {
544    encode_final_complete_result(result?)
545}
546
547/// Encodes a handler-authored final tool result without reprojecting it through
548/// the legacy result surface. This preserves the complete result's metadata
549/// and inert open members under the protocol-owned final codec.
550fn encode_final_tools_call_result(
551    result: McpResult<CompleteResult<FinalCallToolResult>>,
552) -> McpResult<serde_json::Value> {
553    let result = result?;
554    let encoded = CoreResult::Final(FinalCoreResult::ToolsCall {
555        result,
556        diagnostic: None,
557    })
558    .encode()
559    .map_err(|error| McpError::internal_error(error.to_string()))?;
560    serde_json::from_str(&encoded).map_err(McpError::from)
561}
562
563/// Encodes a framework-minted MRTR continuation without allowing a handler to
564/// select or replay its opaque request state.
565fn encode_mrtr_input_required_result(result: MrtrInputRequired) -> McpResult<serde_json::Value> {
566    serde_json::to_value(result)
567        .map_err(|_| McpError::internal_error("failed to encode MRTR input_required result"))
568}
569
570const MAX_MRTR_BINDING_BYTES: usize = 64 * 1024;
571const MAX_MRTR_RAW_PARAMS_BYTES: usize = 256 * 1024;
572const MAX_MRTR_RAW_INPUT_RESPONSES_BYTES: usize = 192 * 1024;
573const MAX_MRTR_RAW_JSON_DEPTH: usize = 32;
574const MAX_MRTR_RAW_JSON_VALUES: usize = 4_096;
575const MRTR_REQUIRES_BOUND_MODERN_CONNECTION: &str =
576    "MRTR-capable handlers require a bound modern connection";
577
578struct MrtrRawJsonCounter {
579    max_bytes: usize,
580    bytes: usize,
581}
582
583impl Write for MrtrRawJsonCounter {
584    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
585        let remaining = self.max_bytes.saturating_sub(self.bytes);
586        if buffer.len() > remaining {
587            return Err(io::Error::other("MRTR JSON byte limit exceeded"));
588        }
589        self.bytes += buffer.len();
590        Ok(buffer.len())
591    }
592
593    fn flush(&mut self) -> io::Result<()> {
594        Ok(())
595    }
596}
597
598fn admit_mrtr_raw_json_value(value: &serde_json::Value, max_bytes: usize) -> McpResult<()> {
599    fn count_values(value: &serde_json::Value, depth: usize, values: &mut usize) -> McpResult<()> {
600        if depth > MAX_MRTR_RAW_JSON_DEPTH {
601            return Err(McpError::invalid_params(
602                "MRTR JSON exceeds its nesting limit",
603            ));
604        }
605        *values = values.saturating_add(1);
606        if *values > MAX_MRTR_RAW_JSON_VALUES {
607            return Err(McpError::invalid_params(
608                "MRTR JSON exceeds its value limit",
609            ));
610        }
611        match value {
612            serde_json::Value::Array(items) => {
613                for value in items {
614                    count_values(value, depth + 1, values)?;
615                }
616            }
617            serde_json::Value::Object(members) => {
618                for value in members.values() {
619                    count_values(value, depth + 1, values)?;
620                }
621            }
622            serde_json::Value::Null
623            | serde_json::Value::Bool(_)
624            | serde_json::Value::Number(_)
625            | serde_json::Value::String(_) => {}
626        }
627        Ok(())
628    }
629
630    let mut values = 0;
631    count_values(value, 0, &mut values)?;
632    let mut counter = MrtrRawJsonCounter {
633        max_bytes,
634        bytes: 0,
635    };
636    serde_json::to_writer(&mut counter, value)
637        .map_err(|_| McpError::invalid_params("MRTR JSON exceeds its byte limit"))
638}
639
640enum FinalMrtrDispatch {
641    Fresh,
642    Resume(MrtrCompletedInputs),
643    InputRequired(serde_json::Value),
644}
645
646fn mrtr_digest(value: &impl serde::Serialize) -> McpResult<[u8; 32]> {
647    let bytes = serde_json::to_vec(value)
648        .map_err(|_| McpError::invalid_params("invalid MRTR operation binding"))?;
649    let digest = sha256_bounded(&bytes, MAX_MRTR_BINDING_BYTES)
650        .map_err(|_| McpError::invalid_params("MRTR operation binding exceeds its limit"))?;
651    Ok(*digest.as_bytes())
652}
653
654fn final_mrtr_binding(
655    request_ctx: &McpContext,
656    method: &'static str,
657    target: String,
658    arguments: &impl serde::Serialize,
659) -> McpResult<Option<MrtrExchangeBinding>> {
660    if target.len() > MAX_MRTR_BINDING_BYTES {
661        return Err(McpError::invalid_params("MRTR target exceeds its limit"));
662    }
663    // A binding is only consumable where retry state can live. Fresh
664    // dispatches on a session-less context stay valid; the binding is
665    // demanded again at the points that actually need it (retry resolution
666    // and input_required issuance).
667    let Some((session_partition, _revision)) = request_ctx.session_cache_partition() else {
668        return Ok(None);
669    };
670    let principal_digest = request_ctx
671        .auth()
672        .map(|auth| {
673            auth.session_owner()
674                .map(|owner| Ok(*owner.as_bytes()))
675                .unwrap_or_else(|| mrtr_digest(&auth))
676        })
677        .transpose()?;
678    Ok(Some(MrtrExchangeBinding::new(
679        method,
680        target,
681        mrtr_digest(arguments)?,
682        session_partition,
683        principal_digest,
684    )))
685}
686
687fn handler_mrtr_input_requests(
688    request_ctx: &McpContext,
689    result: &InputRequiredResult,
690) -> McpResult<MrtrInputRequests> {
691    let Some(input_requests) = result.input_requests() else {
692        return Ok(MrtrInputRequests::default());
693    };
694    if input_requests.members().is_empty() {
695        return Err(McpError::invalid_params(
696            "MRTR inputRequests must not be empty",
697        ));
698    }
699    MrtrInputRequests::new(
700        input_requests
701            .members()
702            .iter()
703            .map(|member| {
704                let value = exact_json_to_serde(&member.value)
705                    .map_err(|_| McpError::invalid_params("invalid MRTR input request"))?;
706                let request = MrtrInputRequest::from_wire(&value)?;
707                if request.kind() == MrtrInputKind::Sampling
708                    && !request_ctx.client_supports_sampling()
709                {
710                    return Err(McpError::invalid_request(
711                        "Final sampling is not advertised by the client",
712                    ));
713                }
714                Ok((member.name.clone(), request))
715            })
716            .collect::<McpResult<Vec<_>>>()?,
717    )
718}
719
720#[cfg(feature = "tasks")]
721fn encode_final_task_result(
722    result: fastmcp_protocol::tasks_extension::CreateTaskResult,
723) -> McpResult<serde_json::Value> {
724    let encoded = CoreResult::Final(FinalCoreResult::ToolsCallTask { result })
725        .encode()
726        .map_err(|error| McpError::internal_error(error.to_string()))?;
727    serde_json::from_str(&encoded).map_err(McpError::from)
728}
729
730#[cfg(feature = "tasks")]
731fn require_final_tasks_capability(metadata: &OpenMetadata) -> McpResult<()> {
732    let declared = metadata
733        .client_capabilities()
734        .map_err(|_| McpError::invalid_params("invalid final client capabilities"))?
735        .and_then(|capabilities| capabilities.get("extensions"))
736        .and_then(serde_json::Value::as_object)
737        .and_then(|extensions| extensions.get(OFFICIAL_TASKS_EXTENSION_ID))
738        .and_then(serde_json::Value::as_object)
739        .is_some_and(serde_json::Map::is_empty);
740    if declared {
741        return Ok(());
742    }
743
744    let mut extensions = serde_json::Map::new();
745    extensions.insert(
746        OFFICIAL_TASKS_EXTENSION_ID.to_owned(),
747        serde_json::json!({}),
748    );
749    let missing = MissingRequiredClientCapabilityError::new(serde_json::json!({
750        "extensions": serde_json::Value::Object(extensions)
751    }))
752    .map_err(|_| McpError::internal_error("failed to encode required Tasks capability"))?;
753    Err(McpError::with_data(
754        McpErrorCode::Custom(missing.jsonrpc_error_code()),
755        "Required client capability is missing",
756        missing.canonical_error_data(),
757    ))
758}
759
760/// Admits the schemas a local tool declares for a final-dialect call.
761///
762/// The returned values own immutable copies of the declarations so one exact
763/// admitted pair is used for both the pre-handler input check and the
764/// post-handler output check. Legacy dispatch intentionally retains its raw
765/// compatibility validators.
766#[derive(Clone)]
767struct FinalToolSchemas {
768    input: Option<AdmittedSchema>,
769    output: Option<AdmittedSchema>,
770    errors: Option<FinalToolErrorStructuredContent>,
771}
772
773#[derive(Clone)]
774struct FinalToolErrorStructuredContent {
775    input_validation: serde_json::Value,
776    handler: serde_json::Value,
777}
778
779const MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES: usize = 16 * 1024;
780
781fn admit_final_tool_error_structured_content<H: ToolHandler + ?Sized>(
782    handler: &H,
783    output: &AdmittedSchema,
784    kind: ToolErrorKind,
785) -> McpResult<serde_json::Value> {
786    let mapped = crate::catch_extension_unwind(|| {
787        handler.final_tool_error_structured_content(kind)
788    })
789    .map_err(|_payload| {
790        McpError::internal_error("tool error structured-content mapper panicked during admission")
791    })?
792    .ok_or_else(|| {
793        McpError::internal_error(
794            "tool declares outputSchema without a complete tool-error structured-content mapper",
795        )
796    })?;
797    let encoded = serde_json::to_vec(&mapped).map_err(|_error| {
798        McpError::internal_error("tool error structured-content mapper returned invalid JSON")
799    })?;
800    if encoded.len() > MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES {
801        return Err(McpError::internal_error(
802            "tool error structured-content mapper exceeded the registration limit",
803        ));
804    }
805    if output.validate(&mapped).is_err() {
806        return Err(McpError::internal_error(
807            "tool error structured-content mapper does not satisfy outputSchema",
808        ));
809    }
810    Ok(mapped)
811}
812
813fn admit_final_tool_schemas<H: ToolHandler + ?Sized>(
814    upstream_schema_registered: bool,
815    input_schema: &serde_json::Value,
816    output_schema: Option<&serde_json::Value>,
817    handler: &H,
818) -> McpResult<FinalToolSchemas> {
819    if upstream_schema_registered {
820        // The upstream selected and already admitted this exact schema. In
821        // particular, a proxy must retain a valid non-object JSON Schema
822        // rather than treating it as a local framework schema or inventing a
823        // `{}` error payload to satisfy it.
824        return Ok(FinalToolSchemas {
825            input: None,
826            output: None,
827            errors: None,
828        });
829    }
830    if !input_schema.is_object() {
831        return Err(McpError::internal_error(
832            "tool declares a final input schema that is not an object",
833        ));
834    }
835    if input_schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
836        return Err(McpError::internal_error(
837            "tool declares a final input schema without type object",
838        ));
839    }
840    let input = admit_final_schema(input_schema.clone()).map_err(|_error| {
841        McpError::internal_error("tool declares an invalid final input schema")
842    })?;
843    let output = output_schema
844        .cloned()
845        .map(|schema| {
846            if !schema.is_object() {
847                return Err(McpError::internal_error(
848                    "tool declares a final output schema that is not an object",
849                ));
850            }
851            admit_final_schema(schema).map_err(|_error| {
852                McpError::internal_error("tool declares an invalid final output schema")
853            })
854        })
855        .transpose()?;
856    let errors = output
857        .as_ref()
858        .map(|output| -> McpResult<FinalToolErrorStructuredContent> {
859            Ok(FinalToolErrorStructuredContent {
860                input_validation: admit_final_tool_error_structured_content(
861                    handler,
862                    output,
863                    ToolErrorKind::InputValidation,
864                )?,
865                handler: admit_final_tool_error_structured_content(
866                    handler,
867                    output,
868                    ToolErrorKind::Handler,
869                )?,
870            })
871        })
872        .transpose()?;
873    Ok(FinalToolSchemas {
874        input: Some(input),
875        output,
876        errors,
877    })
878}
879
880/// One immutable catalog snapshot committed together with its dispatch target.
881/// No list or validation path re-invokes the handler's definition hooks.
882struct AdmittedToolRegistration {
883    handler: BoxedToolHandler,
884    definition: Tool,
885    final_registration: Option<AdmittedFinalToolRegistration>,
886    legacy_enabled: bool,
887}
888
889struct AdmittedFinalToolRegistration {
890    final_definition: FinalTool,
891    schemas: FinalToolSchemas,
892    declares_final_tasks: bool,
893}
894
895/// Immutable final resource catalog data, including the legacy tag snapshot
896/// used only for server-side list filtering.
897struct AdmittedFinalResourceRegistration {
898    definition: FinalResource,
899    tags: Vec<String>,
900    uri_use_policy: ResourceUriUsePolicy,
901}
902
903/// Immutable final prompt catalog data, including the legacy tag snapshot
904/// used only for server-side list filtering.
905struct AdmittedFinalPromptRegistration {
906    definition: FinalPrompt,
907    tags: Vec<String>,
908    uri_use_policy: ResourceUriUsePolicy,
909}
910
911impl AdmittedToolRegistration {
912    fn admit<H: ToolHandler + 'static>(
913        handler: H,
914        definition: Tool,
915        legacy_enabled: bool,
916    ) -> McpResult<Self> {
917        let (exact_final_definition, declares_final_tasks, upstream_schema_registered) =
918            crate::catch_extension_unwind(|| {
919                (
920                    handler.final_definition(),
921                    handler.declares_final_tasks(),
922                    handler.upstream_final_tool_schema_registration().is_some(),
923                )
924            })
925            .map_err(|_payload| {
926                McpError::internal_error("tool metadata hook panicked during admission")
927            })?;
928        let final_definition = match exact_final_definition {
929            Some(definition) => definition,
930            None => {
931                let (title, icons, metadata) = crate::catch_extension_unwind(|| {
932                    (
933                        handler.final_title().map(str::to_owned),
934                        handler.final_icons().map(|icons| icons.to_vec()),
935                        handler.final_metadata().cloned(),
936                    )
937                })
938                .map_err(|_payload| {
939                    McpError::internal_error("tool metadata hook panicked during admission")
940                })?;
941                FinalTool {
942                    name: definition.name.clone(),
943                    title,
944                    description: definition.description.clone(),
945                    input_schema: definition.input_schema.clone(),
946                    output_schema: definition.output_schema.clone(),
947                    annotations: definition
948                        .annotations
949                        .clone()
950                        .map(project_final_tool_annotations),
951                    icons,
952                    meta: metadata,
953                }
954            }
955        };
956        if final_definition.name != definition.name {
957            return Err(McpError::internal_error(
958                "tool's exact final definition name differs from its legacy definition name",
959            ));
960        }
961        let schemas = admit_final_tool_schemas(
962            upstream_schema_registered,
963            &final_definition.input_schema,
964            final_definition.output_schema.as_ref(),
965            &handler,
966        )?;
967        Ok(Self {
968            handler: Box::new(handler),
969            definition,
970            final_registration: Some(AdmittedFinalToolRegistration {
971                final_definition,
972                schemas,
973                declares_final_tasks,
974            }),
975            legacy_enabled,
976        })
977    }
978
979    fn legacy_only<H: ToolHandler + 'static>(handler: H, definition: Tool) -> Self {
980        Self {
981            handler: Box::new(handler),
982            definition,
983            final_registration: None,
984            legacy_enabled: true,
985        }
986    }
987
988    fn with_mounted_name(self, mounted_name: String) -> Self {
989        use crate::handler::MountedToolHandler;
990
991        let Self {
992            handler,
993            mut definition,
994            mut final_registration,
995            legacy_enabled,
996        } = self;
997        definition.name.clone_from(&mounted_name);
998        if let Some(final_registration) = final_registration.as_mut() {
999            final_registration
1000                .final_definition
1001                .name
1002                .clone_from(&mounted_name);
1003        }
1004        Self {
1005            handler: Box::new(MountedToolHandler::new(handler, mounted_name)),
1006            definition,
1007            final_registration,
1008            legacy_enabled,
1009        }
1010    }
1011}
1012
1013/// Encodes a handler-authored final resource result without reprojecting it
1014/// through the legacy resource result surface. This preserves final embedded
1015/// resource fields, cache policy, metadata, and open members selected by the
1016/// handler.
1017fn encode_final_resources_read_result(
1018    result: McpResult<CompleteResult<FinalReadResourceResult>>,
1019) -> McpResult<serde_json::Value> {
1020    let result = result?;
1021    let encoded = CoreResult::Final(FinalCoreResult::ResourcesRead {
1022        result,
1023        diagnostic: None,
1024    })
1025    .encode()
1026    .map_err(|error| McpError::internal_error(error.to_string()))?;
1027    serde_json::from_str(&encoded).map_err(McpError::from)
1028}
1029
1030/// Encodes a handler-authored final prompt result without reprojecting it
1031/// through the legacy prompt surface. This preserves final common content and
1032/// result metadata selected by the handler.
1033fn encode_final_prompts_get_result(
1034    result: McpResult<CompleteResult<FinalGetPromptResult>>,
1035) -> McpResult<serde_json::Value> {
1036    let result = result?;
1037    let encoded = CoreResult::Final(FinalCoreResult::PromptsGet {
1038        result,
1039        diagnostic: None,
1040    })
1041    .encode()
1042    .map_err(|error| McpError::internal_error(error.to_string()))?;
1043    serde_json::from_str(&encoded).map_err(McpError::from)
1044}
1045
1046#[derive(Clone, Debug, PartialEq, Eq)]
1047struct FinalCacheHintPolicy {
1048    list_ttl_ms: CacheTtl,
1049    resource_read_ttl_ms: CacheTtl,
1050    scope: CacheScope,
1051}
1052
1053impl Default for FinalCacheHintPolicy {
1054    fn default() -> Self {
1055        Self {
1056            list_ttl_ms: CacheTtl::milliseconds(5 * 60 * 1_000),
1057            resource_read_ttl_ms: CacheTtl::milliseconds(60 * 60 * 1_000),
1058            scope: CacheScope::Private,
1059        }
1060    }
1061}
1062
1063/// Encodes a server-authored final payload through the selected method's exact
1064/// `FinalCoreResult` composition. This preserves typed catalog and cache
1065/// fields instead of inserting unvalidated JSON after serialization.
1066fn encode_final_core_result<T>(
1067    result: McpResult<T>,
1068    select: impl FnOnce(CompleteResult<T>) -> FinalCoreResult,
1069) -> McpResult<serde_json::Value> {
1070    let result = CompleteResult::new(result?, empty_final_result_meta()?);
1071    let encoded = CoreResult::Final(select(result))
1072        .encode()
1073        .map_err(|error| McpError::internal_error(error.to_string()))?;
1074    serde_json::from_str(&encoded).map_err(McpError::from)
1075}
1076
1077fn project_final_tool_annotations(
1078    annotations: fastmcp_protocol::ToolAnnotations,
1079) -> FinalToolAnnotations {
1080    FinalToolAnnotations {
1081        title: None,
1082        destructive: annotations.destructive,
1083        idempotent: annotations.idempotent,
1084        read_only: annotations.read_only,
1085        open_world_hint: annotations.open_world_hint,
1086    }
1087}
1088
1089fn project_final_resource_catalog_entry(
1090    resource: Resource,
1091    title: Option<String>,
1092    icons: Option<Vec<RawIcon>>,
1093    annotations: Option<Annotations>,
1094    meta: Option<OpenMetadata>,
1095) -> McpResult<FinalResource> {
1096    let uri = AbsoluteUri::parse(resource.uri).map_err(|error| {
1097        McpError::internal_error(format!(
1098            "legacy resource URI cannot be projected into the final catalog: {error}",
1099        ))
1100    })?;
1101    Ok(FinalResource {
1102        uri,
1103        name: resource.name,
1104        title,
1105        description: resource.description,
1106        icons,
1107        mime_type: resource.mime_type,
1108        size: None,
1109        annotations,
1110        meta,
1111    })
1112}
1113
1114const FINAL_RESOURCE_URI_USE_REJECTED: &str =
1115    "final resource URI is not admitted for this local use site";
1116const FINAL_RESOURCE_URI_USE_EMISSION_REJECTED: &str =
1117    "handler emitted a final resource URI that is not admitted for this local use site";
1118
1119fn admit_final_resource_uri(
1120    policy: ResourceUriUsePolicy,
1121    uri: &AbsoluteUri,
1122    use_site: FinalResourceUriUse,
1123) -> McpResult<()> {
1124    policy
1125        .admits(uri, use_site)
1126        .then_some(())
1127        .ok_or_else(|| McpError::invalid_params(FINAL_RESOURCE_URI_USE_REJECTED))
1128}
1129
1130fn admit_final_resource_template_uri(
1131    policy: ResourceUriUsePolicy,
1132    uri_template: &str,
1133) -> McpResult<()> {
1134    policy
1135        .admits_template(uri_template)
1136        .then_some(())
1137        .ok_or_else(|| McpError::invalid_params(FINAL_RESOURCE_URI_USE_REJECTED))
1138}
1139
1140fn enforce_final_resource_uri_emission(
1141    policy: ResourceUriUsePolicy,
1142    uri: &AbsoluteUri,
1143    use_site: FinalResourceUriUse,
1144) -> McpResult<()> {
1145    policy
1146        .admits(uri, use_site)
1147        .then_some(())
1148        .ok_or_else(|| McpError::internal_error(FINAL_RESOURCE_URI_USE_EMISSION_REJECTED))
1149}
1150
1151fn embedded_resource_uri(contents: &EmbeddedResourceContents) -> &AbsoluteUri {
1152    match contents {
1153        EmbeddedResourceContents::Text { uri, .. } | EmbeddedResourceContents::Blob { uri, .. } => {
1154            uri
1155        }
1156    }
1157}
1158
1159fn admit_final_resource_read_outcome(
1160    policy: ResourceUriUsePolicy,
1161    outcome: &FinalMethodOutcome<FinalReadResourceResult>,
1162) -> McpResult<()> {
1163    let FinalMethodOutcome::Complete(result) = outcome else {
1164        return Ok(());
1165    };
1166    for contents in &result.payload.contents {
1167        enforce_final_resource_uri_emission(
1168            policy,
1169            embedded_resource_uri(contents),
1170            FinalResourceUriUse::ResourceReadContents,
1171        )?;
1172    }
1173    Ok(())
1174}
1175
1176fn admit_final_prompt_content(
1177    policy: ResourceUriUsePolicy,
1178    content: &fastmcp_protocol::common_types::ContentBlock,
1179) -> McpResult<()> {
1180    match content {
1181        fastmcp_protocol::common_types::ContentBlock::ResourceLink { uri, .. } => {
1182            enforce_final_resource_uri_emission(
1183                policy,
1184                uri,
1185                FinalResourceUriUse::PromptResourceLink,
1186            )
1187        }
1188        fastmcp_protocol::common_types::ContentBlock::Resource { resource, .. } => {
1189            enforce_final_resource_uri_emission(
1190                policy,
1191                embedded_resource_uri(resource),
1192                FinalResourceUriUse::PromptEmbeddedResource,
1193            )
1194        }
1195        fastmcp_protocol::common_types::ContentBlock::Text { .. }
1196        | fastmcp_protocol::common_types::ContentBlock::Image { .. }
1197        | fastmcp_protocol::common_types::ContentBlock::Audio { .. } => Ok(()),
1198    }
1199}
1200
1201fn admit_declared_final_prompt_arguments<S: AsRef<str>>(
1202    declared: &[FinalPromptArgument],
1203    supplied: impl IntoIterator<Item = S>,
1204) -> McpResult<()> {
1205    let supplied: HashSet<String> = supplied
1206        .into_iter()
1207        .map(|name| name.as_ref().to_owned())
1208        .collect();
1209    if declared
1210        .iter()
1211        .any(|argument| argument.required == Some(true) && !supplied.contains(&argument.name))
1212    {
1213        return Err(McpError::invalid_params("Missing required prompt argument"));
1214    }
1215    if supplied
1216        .iter()
1217        .any(|name| !declared.iter().any(|argument| &argument.name == name))
1218    {
1219        return Err(McpError::invalid_params("Unknown prompt argument"));
1220    }
1221    Ok(())
1222}
1223
1224fn admit_final_prompt_outcome(
1225    policy: ResourceUriUsePolicy,
1226    outcome: &FinalMethodOutcome<FinalGetPromptResult>,
1227) -> McpResult<()> {
1228    let FinalMethodOutcome::Complete(result) = outcome else {
1229        return Ok(());
1230    };
1231    for message in &result.payload.messages {
1232        admit_final_prompt_content(policy, &message.content)?;
1233    }
1234    Ok(())
1235}
1236
1237/// Freezes one resource's modern catalog entry during registration.
1238///
1239/// Discovery must not call application hooks: a catalog observed by a final
1240/// peer has to remain the one that was admitted alongside its dispatch target.
1241fn admit_final_resource_definition<H: ResourceHandler + ?Sized>(
1242    handler: &H,
1243    resource: &Resource,
1244    uri_use_policy: ResourceUriUsePolicy,
1245) -> McpResult<FinalResource> {
1246    if let Some(definition) =
1247        crate::catch_extension_unwind(|| handler.final_definition()).map_err(|_payload| {
1248            McpError::internal_error("resource final-definition hook panicked during admission")
1249        })?
1250    {
1251        if definition.uri.as_str() != resource.uri {
1252            return Err(McpError::internal_error(
1253                "resource exact final URI differs from its legacy definition URI",
1254            ));
1255        }
1256        admit_final_resource_uri(
1257            uri_use_policy,
1258            &definition.uri,
1259            FinalResourceUriUse::CatalogResource,
1260        )?;
1261        return Ok(definition);
1262    }
1263    let (title, icons, annotations, meta) = crate::catch_extension_unwind(|| {
1264        (
1265            handler.final_title().map(str::to_owned),
1266            handler.final_icons().map(|icons| icons.to_vec()),
1267            handler.final_annotations().cloned(),
1268            handler.final_metadata().cloned(),
1269        )
1270    })
1271    .map_err(|_payload| {
1272        McpError::internal_error("resource metadata hook panicked during admission")
1273    })?;
1274    let definition =
1275        project_final_resource_catalog_entry(resource.clone(), title, icons, annotations, meta)?;
1276    admit_final_resource_uri(
1277        uri_use_policy,
1278        &definition.uri,
1279        FinalResourceUriUse::CatalogResource,
1280    )?;
1281    Ok(definition)
1282}
1283
1284/// Freezes one resource template's final catalog entry during registration.
1285fn admit_final_resource_template_definition<H: ResourceHandler + ?Sized>(
1286    handler: Option<&H>,
1287    template: &ResourceTemplate,
1288    uri_use_policy: ResourceUriUsePolicy,
1289) -> McpResult<FinalResourceTemplate> {
1290    if let Some(handler) = handler {
1291        if let Some(definition) =
1292            crate::catch_extension_unwind(|| handler.final_template_definition()).map_err(
1293                |_payload| {
1294                    McpError::internal_error(
1295                        "resource final-template-definition hook panicked during admission",
1296                    )
1297                },
1298            )?
1299        {
1300            if definition.uri_template != template.uri_template {
1301                return Err(McpError::internal_error(
1302                    "resource exact final template differs from its legacy template URI",
1303                ));
1304            }
1305            admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
1306            return Ok(definition);
1307        }
1308    }
1309    let (title, icons, annotations, meta) = match handler {
1310        Some(handler) => crate::catch_extension_unwind(|| {
1311            (
1312                handler.final_template_title().map(str::to_owned),
1313                handler.final_template_icons().map(|icons| icons.to_vec()),
1314                handler.final_template_annotations().cloned(),
1315                handler.final_template_metadata().cloned(),
1316            )
1317        })
1318        .map_err(|_payload| {
1319            McpError::internal_error("resource template metadata hook panicked during admission")
1320        })?,
1321        None => (None, None, None, None),
1322    };
1323    let definition = FinalResourceTemplate {
1324        uri_template: template.uri_template.clone(),
1325        name: template.name.clone(),
1326        title,
1327        description: template.description.clone(),
1328        icons,
1329        mime_type: template.mime_type.clone(),
1330        annotations,
1331        meta,
1332    };
1333    admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
1334    Ok(definition)
1335}
1336
1337/// Freezes one prompt's final catalog entry during registration.
1338fn admit_final_prompt_definition<H: PromptHandler + ?Sized>(
1339    handler: &H,
1340    prompt: &Prompt,
1341) -> McpResult<FinalPrompt> {
1342    if let Some(definition) =
1343        crate::catch_extension_unwind(|| handler.final_definition()).map_err(|_payload| {
1344            McpError::internal_error("prompt final-definition hook panicked during admission")
1345        })?
1346    {
1347        if definition.name != prompt.name {
1348            return Err(McpError::internal_error(
1349                "prompt exact final name differs from its legacy definition name",
1350            ));
1351        }
1352        return Ok(definition);
1353    }
1354    let (title, icons, meta) = crate::catch_extension_unwind(|| {
1355        (
1356            handler.final_title().map(str::to_owned),
1357            handler.final_icons().map(|icons| icons.to_vec()),
1358            handler.final_metadata().cloned(),
1359        )
1360    })
1361    .map_err(|_payload| {
1362        McpError::internal_error("prompt metadata hook panicked during admission")
1363    })?;
1364    let arguments = (!prompt.arguments.is_empty()).then(|| {
1365        prompt
1366            .arguments
1367            .iter()
1368            .map(|argument| FinalPromptArgument {
1369                name: argument.name.clone(),
1370                title: None,
1371                description: argument.description.clone(),
1372                required: Some(argument.required),
1373            })
1374            .collect()
1375    });
1376    Ok(FinalPrompt {
1377        name: prompt.name.clone(),
1378        title,
1379        description: prompt.description.clone(),
1380        icons,
1381        arguments,
1382        meta,
1383    })
1384}
1385
1386/// Converts handler-owned content into the exact legacy result union.
1387///
1388/// Audio is valid only in the broader handler surface, never in the exact
1389/// 2024-11-05 content union. Refuse it rather than emitting an invalid legacy
1390/// response or silently changing the content type.
1391fn legacy_content_from_handler(content: Content) -> McpResult<LegacyContent> {
1392    match content {
1393        Content::Text { text } => Ok(LegacyContent::Text {
1394            text,
1395            annotations: None,
1396            additional: BTreeMap::new(),
1397        }),
1398        Content::Image { data, mime_type } => Ok(LegacyContent::Image {
1399            data,
1400            mime_type,
1401            annotations: None,
1402            additional: BTreeMap::new(),
1403        }),
1404        Content::Resource { resource } => Ok(LegacyContent::Resource {
1405            resource: legacy_resource_content_from_handler(resource)?,
1406            annotations: None,
1407            additional: BTreeMap::new(),
1408        }),
1409        Content::Audio { .. } => Err(McpError::internal_error(
1410            "legacy 2024 result content does not support audio",
1411        )),
1412    }
1413}
1414
1415fn legacy_contents_from_handler(content: Vec<Content>) -> McpResult<Vec<LegacyContent>> {
1416    content
1417        .into_iter()
1418        .map(legacy_content_from_handler)
1419        .collect()
1420}
1421
1422/// Converts handler-owned resource content into an exact legacy result item.
1423fn legacy_resource_content_from_handler(
1424    resource: ResourceContent,
1425) -> McpResult<LegacyResourceContent> {
1426    match (resource.text, resource.blob) {
1427        (Some(text), None) => Ok(LegacyResourceContent::Text {
1428            uri: resource.uri,
1429            text,
1430            mime_type: resource.mime_type,
1431            additional: BTreeMap::new(),
1432        }),
1433        (None, Some(blob)) => Ok(LegacyResourceContent::Blob {
1434            uri: resource.uri,
1435            blob,
1436            mime_type: resource.mime_type,
1437            additional: BTreeMap::new(),
1438        }),
1439        _ => Err(McpError::internal_error(
1440            "legacy 2024 resource content requires exactly one text or blob payload",
1441        )),
1442    }
1443}
1444
1445fn legacy_resource_contents_from_handler(
1446    contents: Vec<ResourceContent>,
1447) -> McpResult<Vec<LegacyResourceContent>> {
1448    contents
1449        .into_iter()
1450        .map(legacy_resource_content_from_handler)
1451        .collect()
1452}
1453
1454fn legacy_prompt_messages_from_handler(
1455    messages: Vec<PromptMessage>,
1456) -> McpResult<Vec<LegacyPromptMessage>> {
1457    messages
1458        .into_iter()
1459        .map(|PromptMessage { role, content }| {
1460            Ok(LegacyPromptMessage {
1461                role,
1462                content: legacy_content_from_handler(content)?,
1463                additional: BTreeMap::new(),
1464            })
1465        })
1466        .collect()
1467}
1468
1469/// Promotes exact legacy resource content into a final resource result.
1470///
1471/// Legacy open members remain inert but are retained verbatim. In particular,
1472/// an untyped legacy `_meta` value stays in `additional` rather than acquiring
1473/// final-era metadata authority during the cross-era projection.
1474fn promote_legacy_resource_content(
1475    resource: LegacyResourceContent,
1476) -> McpResult<EmbeddedResourceContents> {
1477    let (uri, content, mime_type, additional) = match resource {
1478        LegacyResourceContent::Text {
1479            uri,
1480            text,
1481            mime_type,
1482            additional,
1483        } => (
1484            uri,
1485            LegacyEmbeddedContent::Text(text),
1486            mime_type,
1487            additional,
1488        ),
1489        LegacyResourceContent::Blob {
1490            uri,
1491            blob,
1492            mime_type,
1493            additional,
1494        } => (
1495            uri,
1496            LegacyEmbeddedContent::Blob(blob),
1497            mime_type,
1498            additional,
1499        ),
1500    };
1501    let uri = AbsoluteUri::parse(uri).map_err(|error| {
1502        McpError::internal_error(format!(
1503            "legacy resource content cannot be projected into the final result: {error}",
1504        ))
1505    })?;
1506
1507    match content {
1508        LegacyEmbeddedContent::Text(text) => Ok(EmbeddedResourceContents::Text {
1509            uri,
1510            text,
1511            mime_type,
1512            meta: None,
1513            additional,
1514        }),
1515        LegacyEmbeddedContent::Blob(blob) => Ok(EmbeddedResourceContents::Blob {
1516            uri,
1517            blob,
1518            mime_type,
1519            meta: None,
1520            additional,
1521        }),
1522    }
1523}
1524
1525enum LegacyEmbeddedContent {
1526    Text(String),
1527    Blob(String),
1528}
1529
1530fn legacy_read_resource_params(params: FinalReadResourceParams) -> ReadResourceParams {
1531    ReadResourceParams {
1532        uri: params.uri.as_str().to_owned(),
1533        meta: None,
1534    }
1535}
1536
1537fn encode_cursor_offset(offset: usize) -> String {
1538    let payload = serde_json::json!({ "offset": offset });
1539    let bytes = serde_json::to_vec(&payload).expect("cursor state must serialize");
1540    BASE64_STANDARD.encode(bytes)
1541}
1542
1543fn encode_final_catalog_cursor(
1544    catalog: FinalCatalogKind,
1545    revision: u64,
1546    query: &FinalCatalogQuery,
1547    offset: usize,
1548    visibility_digest: Option<&str>,
1549) -> String {
1550    let offset = u64::try_from(offset).expect("usize cursor offsets always fit u64");
1551    let payload = FinalCatalogCursor {
1552        catalog,
1553        revision,
1554        query: query.clone(),
1555        offset,
1556        visibility_digest: visibility_digest.map(str::to_owned),
1557    };
1558    let bytes = serde_json::to_vec(&payload).expect("final catalog cursor must serialize");
1559    BASE64_STANDARD.encode(bytes)
1560}
1561
1562const MAX_CATALOG_VISIBILITY_DIGEST_BYTES: usize = 256 * 1024;
1563
1564fn catalog_visibility_digest<'a, I>(keys: I) -> McpResult<String>
1565where
1566    I: IntoIterator<Item = &'a str>,
1567{
1568    let mut material = Vec::new();
1569    for key in keys {
1570        let bytes = key.as_bytes();
1571        let len = u64::try_from(bytes.len()).map_err(|_| {
1572            McpError::internal_error("catalog visibility key exceeds digest length encoding")
1573        })?;
1574        material.extend_from_slice(&len.to_le_bytes());
1575        material.extend_from_slice(bytes);
1576    }
1577    let digest = sha256_bounded(&material, MAX_CATALOG_VISIBILITY_DIGEST_BYTES)
1578        .map_err(|_| McpError::internal_error("catalog visibility digest input too large"))?;
1579    Ok(BASE64_STANDARD.encode(digest.as_bytes()))
1580}
1581
1582/// Pages an already-filtered catalog snapshot.
1583///
1584/// Filtering must precede cursor arithmetic so a legacy-only or tag-filtered
1585/// entry cannot create an empty modern page or shift a final peer's cursor.
1586/// Exact-2024 session-disabled filtering additionally binds
1587/// `visibility_digest` so a later disable cannot reuse an offset into a
1588/// shorter snapshot.
1589fn page_final_catalog<T: Clone>(
1590    items: Vec<T>,
1591    cursor: Option<&str>,
1592    page_size: Option<usize>,
1593    catalog: FinalCatalogKind,
1594    revision: u64,
1595    query: &FinalCatalogQuery,
1596    visibility_digest: Option<&str>,
1597) -> McpResult<(Vec<T>, Option<String>)> {
1598    let offset = decode_final_catalog_cursor_offset(
1599        cursor,
1600        catalog,
1601        revision,
1602        query,
1603        visibility_digest,
1604        items.len(),
1605    )?;
1606    let Some(page_size) = page_size else {
1607        return Ok((items, None));
1608    };
1609    let end = offset.saturating_add(page_size).min(items.len());
1610    Ok((
1611        items.get(offset..end).unwrap_or_default().to_vec(),
1612        (end < items.len())
1613            .then(|| encode_final_catalog_cursor(catalog, revision, query, end, visibility_digest)),
1614    ))
1615}
1616
1617fn page_session_filtered_catalog<T: Clone>(
1618    items: Vec<T>,
1619    cursor: Option<&str>,
1620    page_size: Option<usize>,
1621    catalog: FinalCatalogKind,
1622    revision: u64,
1623    query: &FinalCatalogQuery,
1624    bind_session_visibility: bool,
1625    key: impl Fn(&T) -> &str,
1626) -> McpResult<(Vec<T>, Option<String>)> {
1627    let visibility_digest = bind_session_visibility
1628        .then(|| catalog_visibility_digest(items.iter().map(key)))
1629        .transpose()?;
1630    page_final_catalog(
1631        items,
1632        cursor,
1633        page_size,
1634        catalog,
1635        revision,
1636        query,
1637        visibility_digest.as_deref(),
1638    )
1639}
1640
1641const SANITIZED_HANDLER_PANIC_MESSAGE: &str = "Internal server error";
1642static NEXT_HANDLER_INCIDENT_ID: AtomicU64 = AtomicU64::new(1);
1643
1644/// Maximum number of peer-controlled label bytes admitted to the log-key hash.
1645///
1646/// Labels longer than this retain their exact byte length in logs, but their
1647/// correlation key covers only this bounded prefix. This keeps observability
1648/// useful without allowing an attacker to turn debug logging into unbounded
1649/// hashing work.
1650const LOG_LABEL_HASH_INPUT_LIMIT: usize = 4 * 1024;
1651const LOG_LABEL_DIGEST_PREFIX_BYTES: usize = 8;
1652
1653#[derive(Clone, Copy)]
1654struct SafeLogLabel {
1655    byte_len: usize,
1656    hashed_bytes: usize,
1657    digest_prefix: [u8; LOG_LABEL_DIGEST_PREFIX_BYTES],
1658}
1659
1660impl std::fmt::Display for SafeLogLabel {
1661    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1662        write!(f, "bytes={},sha256_prefix=", self.byte_len)?;
1663        for byte in self.digest_prefix {
1664            write!(f, "{byte:02x}")?;
1665        }
1666        if self.hashed_bytes < self.byte_len {
1667            write!(f, ",hashed_prefix_bytes={}", self.hashed_bytes)?;
1668        }
1669        Ok(())
1670    }
1671}
1672
1673impl std::fmt::Debug for SafeLogLabel {
1674    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1675        std::fmt::Display::fmt(self, f)
1676    }
1677}
1678
1679fn safe_log_label(value: &str) -> SafeLogLabel {
1680    let bytes = value.as_bytes();
1681    let hashed_bytes = bytes.len().min(LOG_LABEL_HASH_INPUT_LIMIT);
1682    let bounded_prefix = &bytes[..hashed_bytes];
1683    let mut digest_prefix = [0_u8; LOG_LABEL_DIGEST_PREFIX_BYTES];
1684    if let Ok(digest) = sha256_bounded(bounded_prefix, LOG_LABEL_HASH_INPUT_LIMIT) {
1685        digest_prefix.copy_from_slice(&digest.as_bytes()[..LOG_LABEL_DIGEST_PREFIX_BYTES]);
1686    }
1687
1688    SafeLogLabel {
1689        byte_len: bytes.len(),
1690        hashed_bytes,
1691        digest_prefix,
1692    }
1693}
1694
1695fn duplicate_registration_error(component: &'static str, key: &str) -> McpError {
1696    McpError::invalid_request(format!(
1697        "{component} already exists; component_key={}",
1698        safe_log_label(key)
1699    ))
1700}
1701
1702/// Returns whether `error` is a duplicate-registration refusal from
1703/// [`duplicate_registration_error`].
1704///
1705/// Prefixed `as_proxy_typed` may skip one colliding catalog member under
1706/// [`crate::DuplicateBehavior::Error`]. Other admission failures (schema,
1707/// metadata, panic-during-definition) must still fail the install.
1708pub(crate) fn is_duplicate_registration_error(error: &McpError) -> bool {
1709    error.code == McpErrorCode::InvalidRequest
1710        && error.message.contains(" already exists; component_key=")
1711}
1712
1713fn compose_handler_budget(
1714    ambient: Budget,
1715    server_or_request: Budget,
1716    handler_timeout: Option<Duration>,
1717    now: Time,
1718) -> Budget {
1719    let inherited = ambient.meet(server_or_request);
1720    match handler_timeout {
1721        Some(timeout) if !timeout.is_zero() => inherited.tightened_by_timeout(now, timeout),
1722        Some(_) | None => inherited,
1723    }
1724}
1725
1726fn budget_error(ctx: &McpContext) -> Option<McpError> {
1727    if ctx.ensure_live().is_err() {
1728        return Some(McpError::request_cancelled());
1729    }
1730    None
1731}
1732
1733fn sanitized_handler_panic(_request_lifetime: &Cx, handler_class: &'static str) -> McpError {
1734    let incident_id = NEXT_HANDLER_INCIDENT_ID.fetch_add(1, Ordering::Relaxed);
1735    log::error!(
1736        target: "fastmcp_rust::handler",
1737        "handler terminated unexpectedly; incident_id={incident_id}; class={handler_class}; detail=panic_payload_redacted"
1738    );
1739    McpError::internal_error(SANITIZED_HANDLER_PANIC_MESSAGE)
1740}
1741
1742fn sanitized_handler_internal_error(
1743    _request_lifetime: &Cx,
1744    handler_class: &'static str,
1745) -> McpError {
1746    let incident_id = NEXT_HANDLER_INCIDENT_ID.fetch_add(1, Ordering::Relaxed);
1747    log::error!(
1748        target: "fastmcp_rust::handler",
1749        "handler returned an opaque internal failure; incident_id={incident_id}; class={handler_class}; detail=internal_error_redacted"
1750    );
1751    McpError::internal_error(SANITIZED_HANDLER_PANIC_MESSAGE)
1752}
1753
1754fn sanitize_handler_error(cx: &Cx, handler_class: &'static str, error: McpError) -> McpError {
1755    if error.code == McpErrorCode::InternalError {
1756        sanitized_handler_internal_error(cx, handler_class)
1757    } else {
1758        error
1759    }
1760}
1761
1762const fn is_framework_terminal_tool_error(code: McpErrorCode) -> bool {
1763    matches!(
1764        code,
1765        McpErrorCode::InternalError | McpErrorCode::RequestCancelled
1766    )
1767}
1768
1769fn read_handler_timeout(
1770    cx: &Cx,
1771    handler_class: &'static str,
1772    read: impl FnOnce() -> Option<Duration>,
1773) -> McpResult<Option<Duration>> {
1774    crate::catch_extension_unwind(read)
1775        .map_err(|_payload| sanitized_handler_panic(cx, handler_class))
1776}
1777
1778#[allow(
1779    dead_code,
1780    reason = "retained as the blocking dispatcher if a remaining session entry cannot yet take a request-owned child Cx"
1781)]
1782fn run_handler<'a, T>(
1783    ctx: &McpContext,
1784    budget: Budget,
1785    handler_class: &'static str,
1786    make_future: impl FnOnce() -> BoxFuture<'a, McpOutcome<T>>,
1787) -> McpResult<McpOutcome<T>> {
1788    if let Some(error) = budget_error(ctx) {
1789        return Err(error);
1790    }
1791
1792    let execution = crate::catch_extension_unwind(|| {
1793        let future = make_future();
1794        match budget.deadline {
1795            Some(deadline) => block_on(async move {
1796                asupersync::time::timeout_at(deadline, future)
1797                    .await
1798                    .map_err(|_elapsed| ())
1799            }),
1800            None => Ok(block_on(future)),
1801        }
1802    });
1803
1804    match execution {
1805        Err(_payload) => Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1806        Ok(Err(())) => Err(McpError::new(
1807            McpErrorCode::RequestCancelled,
1808            "Request timeout exceeded",
1809        )),
1810        Ok(Ok(outcome)) => {
1811            if budget_error(ctx).is_some() {
1812                // A synchronous handler cannot be preempted by timeout_at, so
1813                // a late completion surfaces here; deadline expiry keeps its
1814                // distinguishable timeout message.
1815                if budget.is_past_deadline(ctx.cx().now()) {
1816                    Err(McpError::new(
1817                        McpErrorCode::RequestCancelled,
1818                        "Request timeout exceeded",
1819                    ))
1820                } else {
1821                    Err(McpError::request_cancelled())
1822                }
1823            } else {
1824                Ok(outcome)
1825            }
1826        }
1827    }
1828}
1829
1830/// Drives one handler future without entering the legacy blocking dispatcher.
1831///
1832/// The future stays inside its request-owned child task: timeout drops the
1833/// pending future, and dropping the parent task's join cancels the child before
1834/// the parent can complete. This helper deliberately receives the child Cx
1835/// separately from the framework context so modern handlers can propagate that
1836/// structured capability to their own nested work.
1837async fn run_handler_in_request<'a, T>(
1838    ctx: &'a McpContext,
1839    request_cx: &'a Cx,
1840    budget: Budget,
1841    handler_class: &'static str,
1842    make_future: impl FnOnce(&'a Cx) -> BoxFuture<'a, McpOutcome<T>>,
1843) -> McpResult<McpOutcome<T>> {
1844    if request_cx.is_cancel_requested() || budget_error(ctx).is_some() {
1845        return Err(McpError::request_cancelled());
1846    }
1847
1848    let future = crate::catch_extension_unwind(|| make_future(request_cx))
1849        .map_err(|_payload| sanitized_handler_panic(ctx.cx(), handler_class))?;
1850    let mut future = future;
1851    let poll_handler = std::future::poll_fn(|task_cx| {
1852        match crate::catch_extension_unwind(|| future.as_mut().poll(task_cx)) {
1853            Ok(Poll::Ready(outcome)) => Poll::Ready(Ok(outcome)),
1854            Ok(Poll::Pending) => Poll::Pending,
1855            Err(_payload) => Poll::Ready(Err(())),
1856        }
1857    });
1858
1859    let outcome = match budget.deadline {
1860        Some(deadline) => match asupersync::time::timeout_at(deadline, poll_handler).await {
1861            Ok(Ok(outcome)) => outcome,
1862            Ok(Err(())) => return Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1863            Err(_elapsed) => {
1864                return Err(McpError::new(
1865                    McpErrorCode::RequestCancelled,
1866                    "Request timeout exceeded",
1867                ));
1868            }
1869        },
1870        None => match poll_handler.await {
1871            Ok(outcome) => outcome,
1872            Err(()) => return Err(sanitized_handler_panic(ctx.cx(), handler_class)),
1873        },
1874    };
1875
1876    if request_cx.is_cancel_requested() || budget_error(ctx).is_some() {
1877        Err(McpError::request_cancelled())
1878    } else {
1879        Ok(outcome)
1880    }
1881}
1882
1883fn derive_handler_context(
1884    request_ctx: &McpContext,
1885    progress_marker: Option<ProgressMarker>,
1886    notification_sender: Option<&NotificationSender>,
1887    bidirectional_senders: Option<&BidirectionalSenders>,
1888    protocol_era: ProtocolEra,
1889) -> McpContext {
1890    trace!(
1891        target: targets::HANDLER,
1892        "Deriving handler context for request {}",
1893        request_ctx.request_id()
1894    );
1895    let mut handler_ctx = request_ctx
1896        .clone()
1897        .with_final_request_surface(matches!(protocol_era, ProtocolEra::Modern2026));
1898
1899    // The owned modern dispatch path may already have installed a
1900    // request-scoped final-progress runtime. Preserve that reporter so its
1901    // marker, monotonic high-water mark, coalescing slot, and eventual
1902    // terminal finalization remain one request-owned authority.
1903    let reuse_installed_final_reporter =
1904        matches!(protocol_era, ProtocolEra::Modern2026) && handler_ctx.has_progress_reporter();
1905
1906    if let (Some(marker), Some(sender)) = (progress_marker, notification_sender)
1907        && !reuse_installed_final_reporter
1908    {
1909        let sender = sender.clone();
1910        let reporter = match protocol_era {
1911            ProtocolEra::Legacy2024 => ProgressNotificationSender::new(marker, move |request| {
1912                sender(request);
1913            })
1914            .into_reporter(),
1915            ProtocolEra::Modern2026 => {
1916                ProgressNotificationSender::new_final(marker, move |request| {
1917                    sender(request);
1918                })
1919                .into_reporter()
1920            }
1921        };
1922        handler_ctx = handler_ctx.with_progress_reporter(reporter);
1923    }
1924
1925    if let Some(sender) = notification_sender {
1926        let sender = sender.clone();
1927        handler_ctx = handler_ctx.with_log_sender(Arc::new(
1928            crate::handler::LogNotificationSender::new(move |request| {
1929                sender(request);
1930            }),
1931        ));
1932    }
1933
1934    if let Some(senders) = bidirectional_senders {
1935        if let Some(ref sampling) = senders.sampling {
1936            handler_ctx = handler_ctx.with_sampling(sampling.clone());
1937        }
1938        if let Some(ref elicitation) = senders.elicitation {
1939            handler_ctx = handler_ctx.with_elicitation(elicitation.clone());
1940        }
1941        if let Some(ref roots) = senders.roots {
1942            handler_ctx = handler_ctx.with_roots_provider(roots.clone());
1943        }
1944    }
1945
1946    handler_ctx
1947}
1948
1949fn final_progress_marker(metadata: &OpenMetadata) -> McpResult<Option<ProgressMarker>> {
1950    metadata
1951        .get("progressToken")
1952        .map(|value| {
1953            serde_json::from_value(value.clone()).map_err(|_| {
1954                McpError::invalid_params("final progressToken must be a string or integer")
1955            })
1956        })
1957        .transpose()
1958}
1959
1960/// Routes MCP requests to the appropriate handlers.
1961pub struct Router {
1962    tools: HashMap<String, AdmittedToolRegistration>,
1963    tool_order: Vec<String>,
1964    completion_handler: Option<BoxedCompletionHandler>,
1965    /// Whether the server-wide completion fallback is admitted for final dispatch.
1966    default_final_completion_enabled: bool,
1967    /// Legacy completion providers selected by exact prompt name.
1968    legacy_prompt_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1969    /// Legacy completion providers selected by exact resource-template URI.
1970    legacy_resource_template_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1971    /// Final completion providers selected by exact prompt name.
1972    final_prompt_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1973    /// Final completion providers selected by exact resource-template URI.
1974    final_resource_template_completion_handlers: HashMap<String, BoxedCompletionHandler>,
1975    resources: HashMap<String, BoxedResourceHandler>,
1976    /// Static resources visible to exact MCP 2024-11-05 only.
1977    final_only_resources: HashSet<String>,
1978    /// Immutable final catalog entries. Absence means exact-legacy-only.
1979    final_resources: HashMap<String, AdmittedFinalResourceRegistration>,
1980    resource_order: Vec<String>,
1981    prompts: HashMap<String, BoxedPromptHandler>,
1982    /// Prompts visible to exact MCP 2024-11-05 only.
1983    final_only_prompts: HashSet<String>,
1984    /// Immutable final catalog entries. Absence means exact-legacy-only.
1985    final_prompts: HashMap<String, AdmittedFinalPromptRegistration>,
1986    prompt_order: Vec<String>,
1987    resource_templates: HashMap<String, ResourceTemplateEntry>,
1988    resource_template_order: Vec<String>,
1989    /// Pre-sorted template keys by specificity (most specific first).
1990    /// Updated whenever templates are added/modified.
1991    sorted_template_keys: Vec<String>,
1992    /// Whether to enforce strict input validation (reject extra properties).
1993    strict_input_validation: bool,
1994    /// Optional list page size for cursor-based pagination.
1995    ///
1996    /// When `None`, list methods return all items in a single response and
1997    /// `nextCursor` is always omitted.
1998    list_page_size: Option<usize>,
1999    /// Monotonic revision bound into every paged catalog continuation cursor.
2000    ///
2001    /// This advances after each successful catalog mutation so a retained
2002    /// cursor cannot observe a later catalog snapshot.
2003    final_catalog_revision: u64,
2004    /// Cache policy emitted on exact modern catalog and resource-read results.
2005    final_cache_hints: FinalCacheHintPolicy,
2006    /// Application-owned durable final Tasks runtime used only after the
2007    /// request metadata has admitted the official extension.
2008    #[cfg(feature = "tasks")]
2009    final_task_runtime: Option<FinalTaskRuntime>,
2010    /// One route-bound upstream final Tasks relay. This is distinct from the
2011    /// local runtime because upstream task IDs must never be recreated or
2012    /// translated into local task state.
2013    #[cfg(all(feature = "proxy", feature = "tasks"))]
2014    final_task_relay: Option<Arc<ProxyFinalTaskRelay>>,
2015    /// Bounded, process-local final request-state records for tool, resource,
2016    /// and prompt retries. A state is accepted only if this router issued it.
2017    mrtr_exchanges: Arc<MrtrExchangeRegistry>,
2018}
2019
2020impl Router {
2021    /// Creates a new empty router.
2022    #[must_use]
2023    pub fn new() -> Self {
2024        Self {
2025            tools: HashMap::new(),
2026            tool_order: Vec::new(),
2027            completion_handler: None,
2028            default_final_completion_enabled: false,
2029            legacy_prompt_completion_handlers: HashMap::new(),
2030            legacy_resource_template_completion_handlers: HashMap::new(),
2031            final_prompt_completion_handlers: HashMap::new(),
2032            final_resource_template_completion_handlers: HashMap::new(),
2033            resources: HashMap::new(),
2034            final_only_resources: HashSet::new(),
2035            final_resources: HashMap::new(),
2036            resource_order: Vec::new(),
2037            prompts: HashMap::new(),
2038            final_only_prompts: HashSet::new(),
2039            final_prompts: HashMap::new(),
2040            prompt_order: Vec::new(),
2041            resource_templates: HashMap::new(),
2042            resource_template_order: Vec::new(),
2043            sorted_template_keys: Vec::new(),
2044            strict_input_validation: false,
2045            list_page_size: None,
2046            final_catalog_revision: 0,
2047            final_cache_hints: FinalCacheHintPolicy::default(),
2048            #[cfg(feature = "tasks")]
2049            final_task_runtime: None,
2050            #[cfg(all(feature = "proxy", feature = "tasks"))]
2051            final_task_relay: None,
2052            mrtr_exchanges: Arc::new(MrtrExchangeRegistry::new()),
2053        }
2054    }
2055
2056    /// Returns the number of framework-minted MRTR exchanges for crate tests.
2057    #[cfg(test)]
2058    pub(crate) fn test_active_mrtr_exchange_count(&self) -> usize {
2059        self.mrtr_exchanges.active_len()
2060    }
2061
2062    #[cfg(feature = "tasks")]
2063    pub(crate) fn set_final_task_runtime(&mut self, runtime: Option<FinalTaskRuntime>) {
2064        self.final_task_runtime = runtime;
2065    }
2066
2067    #[cfg(all(feature = "proxy", feature = "tasks"))]
2068    pub(crate) fn set_final_task_relay(&mut self, relay: Option<Arc<ProxyFinalTaskRelay>>) {
2069        self.final_task_relay = relay;
2070    }
2071
2072    /// Sets the list pagination page size.
2073    ///
2074    /// When set, list methods (`tools/list`, `resources/list`,
2075    /// `resources/templates/list`, and `prompts/list`) will page results using
2076    /// opaque base64 cursors.
2077    pub fn set_list_page_size(&mut self, page_size: Option<usize>) {
2078        self.list_page_size = page_size.filter(|n| *n > 0);
2079    }
2080
2081    fn advance_final_catalog_revision(&mut self) {
2082        self.final_catalog_revision = self
2083            .final_catalog_revision
2084            .checked_add(1)
2085            .expect("final catalog revision cannot overflow");
2086    }
2087
2088    /// Sets the cache hints emitted by final catalog and resource-read
2089    /// responses. The default is a five-minute private catalog TTL and a
2090    /// one-hour private resource-read TTL.
2091    pub fn set_final_cache_hint_policy(
2092        &mut self,
2093        list_ttl_ms: CacheTtl,
2094        resource_read_ttl_ms: CacheTtl,
2095        scope: CacheScope,
2096    ) {
2097        self.final_cache_hints = FinalCacheHintPolicy {
2098            list_ttl_ms,
2099            resource_read_ttl_ms,
2100            scope,
2101        };
2102    }
2103
2104    /// Returns the active final cache-hint policy as
2105    /// `(&list_ttl_ms, &resource_read_ttl_ms, scope)`.
2106    #[must_use]
2107    pub fn final_cache_hint_policy(&self) -> (&CacheTtl, &CacheTtl, CacheScope) {
2108        (
2109            &self.final_cache_hints.list_ttl_ms,
2110            &self.final_cache_hints.resource_read_ttl_ms,
2111            self.final_cache_hints.scope,
2112        )
2113    }
2114
2115    /// Sets whether to use strict input validation.
2116    ///
2117    /// When enabled, tool input validation will reject any properties not
2118    /// explicitly defined in the tool's input schema (enforces `additionalProperties: false`).
2119    ///
2120    /// When disabled (default), extra properties are allowed unless the schema
2121    /// explicitly sets `additionalProperties: false`.
2122    pub fn set_strict_input_validation(&mut self, strict: bool) {
2123        self.strict_input_validation = strict;
2124    }
2125
2126    /// Returns whether strict input validation is enabled.
2127    #[must_use]
2128    pub fn strict_input_validation(&self) -> bool {
2129        self.strict_input_validation
2130    }
2131
2132    /// Rebuilds the sorted template keys vector.
2133    /// Called after any modification to resource_templates.
2134    fn rebuild_sorted_template_keys(&mut self) {
2135        self.sorted_template_keys = self.resource_templates.keys().cloned().collect();
2136        self.sorted_template_keys.sort_by(|a, b| {
2137            let entry_a = &self.resource_templates[a];
2138            let entry_b = &self.resource_templates[b];
2139            let (a_literals, a_literal_segments, a_segments) = entry_a.specificity;
2140            let (b_literals, b_literal_segments, b_segments) = entry_b.specificity;
2141            b_literals
2142                .cmp(&a_literals)
2143                .then(b_literal_segments.cmp(&a_literal_segments))
2144                .then(b_segments.cmp(&a_segments))
2145                .then_with(|| a.cmp(b))
2146        });
2147    }
2148
2149    /// Rejects a modern handler template that could select an already-admitted
2150    /// modern resource route. Dispatch must never resolve such a collision by
2151    /// registration order or by the specificity tie-breaker.
2152    fn reject_final_template_collisions(
2153        &self,
2154        candidate: &ReversibleResourceTemplate,
2155        replacing_source: Option<&str>,
2156    ) -> McpResult<()> {
2157        for uri in self.final_resources.keys() {
2158            if candidate
2159                .match_uri(uri)
2160                .map_err(|_| McpError::invalid_params("resource template match admission failed"))?
2161                .is_some()
2162            {
2163                return Err(McpError::invalid_params(
2164                    "resource template collides with an exact final resource",
2165                ));
2166            }
2167        }
2168
2169        for (source, entry) in &self.resource_templates {
2170            if replacing_source == Some(source.as_str()) {
2171                continue;
2172            }
2173            let Some(existing) = entry.matcher.as_ref() else {
2174                continue;
2175            };
2176            if reversible_templates_may_overlap(candidate, existing)? {
2177                return Err(McpError::invalid_params(
2178                    "resource template collides with an admitted final resource template",
2179                ));
2180            }
2181        }
2182
2183        Ok(())
2184    }
2185
2186    /// Rejects a modern exact resource whose byte-exact URI is matched by an
2187    /// already-admitted modern resource template.
2188    fn reject_final_exact_resource_template_collisions(&self, uri: &str) -> McpResult<()> {
2189        for entry in self.resource_templates.values() {
2190            let Some(matcher) = entry.matcher.as_ref() else {
2191                continue;
2192            };
2193            if matcher
2194                .match_uri(uri)
2195                .map_err(|_| McpError::invalid_params("resource template match admission failed"))?
2196                .is_some()
2197            {
2198                return Err(McpError::invalid_params(
2199                    "exact final resource collides with an admitted resource template",
2200                ));
2201            }
2202        }
2203
2204        Ok(())
2205    }
2206
2207    /// Adds a tool handler.
2208    ///
2209    /// If a tool with the same name already exists, it will be replaced.
2210    /// Use [`add_tool_with_behavior`](Self::add_tool_with_behavior) for
2211    /// finer control over duplicate handling.
2212    pub fn add_tool<H: ToolHandler + 'static>(&mut self, handler: H) -> McpResult<()> {
2213        self.add_tool_with_behavior(handler, crate::DuplicateBehavior::Replace)
2214    }
2215
2216    /// Adds an intentionally exact-2024-only tool handler.
2217    ///
2218    /// This is the explicit escape hatch for a legacy definition that cannot
2219    /// satisfy final schema admission. The tool remains available to exact
2220    /// MCP 2024-11-05 list and call routes, but is absent from every modern
2221    /// catalog and modern dispatch lookup. Ordinary [`Self::add_tool`] never
2222    /// falls back to this path.
2223    pub fn add_legacy_tool<H: ToolHandler + 'static>(&mut self, handler: H) -> McpResult<()> {
2224        self.add_legacy_tool_with_behavior(handler, crate::DuplicateBehavior::Replace)
2225    }
2226
2227    /// Adds a tool handler with specified duplicate behavior.
2228    ///
2229    /// Returns `Err` if duplicate policy rejects the name or if the candidate's
2230    /// immutable definition, schemas, final metadata, or required error mapper
2231    /// cannot be admitted. Every error is returned before catalog mutation.
2232    pub fn add_tool_with_behavior<H: ToolHandler + 'static>(
2233        &mut self,
2234        handler: H,
2235        behavior: crate::DuplicateBehavior,
2236    ) -> Result<(), McpError> {
2237        self.add_tool_registration_with_behavior(handler, behavior, true, true, false)
2238    }
2239
2240    /// Adds an exact-final-only tool with duplicate handling.
2241    pub(crate) fn add_final_tool_with_behavior<H: ToolHandler + 'static>(
2242        &mut self,
2243        handler: H,
2244        behavior: crate::DuplicateBehavior,
2245    ) -> Result<(), McpError> {
2246        self.add_tool_registration_with_behavior(handler, behavior, true, false, false)
2247    }
2248
2249    /// Adds a final-only tool carrying validated MCP Apps metadata.
2250    ///
2251    /// This is deliberately narrower than ordinary tool registration: callers
2252    /// must first install the Apps extension through the builder, and the tool
2253    /// is never projected into exact MCP 2024-11-05 discovery or dispatch.
2254    pub(crate) fn add_mcp_apps_tool_with_behavior<H: ToolHandler + 'static>(
2255        &mut self,
2256        handler: H,
2257        behavior: crate::DuplicateBehavior,
2258    ) -> Result<(), McpError> {
2259        self.add_tool_registration_with_behavior(handler, behavior, true, false, true)
2260    }
2261
2262    /// Adds an intentionally exact-2024-only tool with duplicate policy.
2263    ///
2264    /// The definition is snapshotted before mutation, but no final definition,
2265    /// schema, metadata, or error-mapper hook is read. This prevents an
2266    /// explicitly legacy-only registration from accidentally claiming modern
2267    /// support while retaining the same duplicate semantics as ordinary tools.
2268    pub fn add_legacy_tool_with_behavior<H: ToolHandler + 'static>(
2269        &mut self,
2270        handler: H,
2271        behavior: crate::DuplicateBehavior,
2272    ) -> Result<(), McpError> {
2273        self.add_tool_registration_with_behavior(handler, behavior, false, true, false)
2274    }
2275
2276    fn add_tool_registration_with_behavior<H: ToolHandler + 'static>(
2277        &mut self,
2278        handler: H,
2279        behavior: crate::DuplicateBehavior,
2280        admit_final: bool,
2281        legacy_enabled: bool,
2282        allow_mcp_apps: bool,
2283    ) -> Result<(), McpError> {
2284        let def = crate::catch_extension_unwind(|| handler.definition()).map_err(|_payload| {
2285            McpError::internal_error("tool definition hook panicked during admission")
2286        })?;
2287        let name = &def.name;
2288
2289        let existed = self.tools.contains_key(name);
2290        if existed {
2291            match behavior {
2292                crate::DuplicateBehavior::Error => {
2293                    return Err(duplicate_registration_error("Tool", name));
2294                }
2295                crate::DuplicateBehavior::Warn => {
2296                    log::warn!(
2297                        target: "fastmcp_rust::router",
2298                        "tool already exists, keeping original; tool_key={}",
2299                        safe_log_label(name)
2300                    );
2301                    return Ok(());
2302                }
2303                crate::DuplicateBehavior::Replace => {
2304                    log::debug!(
2305                        target: "fastmcp_rust::router",
2306                        "replacing tool; tool_key={}",
2307                        safe_log_label(name)
2308                    );
2309                    // Fall through to insert
2310                }
2311                crate::DuplicateBehavior::Ignore => {
2312                    return Ok(());
2313                }
2314            }
2315        }
2316
2317        // Admission must finish before any map or ordering mutation. In
2318        // particular, a rejected replacement must retain the prior handler
2319        // and its admitted schemas for both protocol eras.
2320        let name = def.name.clone();
2321        let admitted = if admit_final {
2322            AdmittedToolRegistration::admit(handler, def, legacy_enabled)?
2323        } else {
2324            AdmittedToolRegistration::legacy_only(handler, def)
2325        };
2326        if let Some(final_registration) = admitted.final_registration.as_ref() {
2327            self.validate_mcp_apps_tool_admission(
2328                &final_registration.final_definition,
2329                allow_mcp_apps,
2330            )?;
2331        }
2332        self.tools.insert(name.clone(), admitted);
2333        if !existed {
2334            self.tool_order.push(name);
2335        }
2336        self.advance_final_catalog_revision();
2337        Ok(())
2338    }
2339
2340    /// Keeps Apps metadata on its explicit, negotiated, exact-final path.
2341    fn validate_mcp_apps_tool_admission(
2342        &self,
2343        tool: &FinalTool,
2344        allow_mcp_apps: bool,
2345    ) -> McpResult<()> {
2346        let metadata = tool.mcp_apps_metadata().map_err(|error| {
2347            McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2348        })?;
2349        if metadata.is_some() && !allow_mcp_apps {
2350            return Err(McpError::invalid_request(
2351                "MCP Apps tool metadata requires ServerBuilder::mcp_apps_tool",
2352            ));
2353        }
2354        self.validate_mcp_apps_tool_resource_binding(tool)
2355    }
2356
2357    /// Verifies that a final tool's optional Apps UI binding selects a
2358    /// registered final Apps HTML resource before either catalog is mutated.
2359    fn validate_mcp_apps_tool_resource_binding(&self, tool: &FinalTool) -> McpResult<()> {
2360        let binding = tool.mcp_apps_resource_binding().map_err(|error| {
2361            McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2362        })?;
2363        let Some(binding) = binding else {
2364            return Ok(());
2365        };
2366        let resource = self
2367            .final_resources
2368            .get(binding.resource_uri.as_str())
2369            .ok_or_else(|| {
2370                McpError::invalid_request(format!(
2371                    "MCP Apps UI resource is not registered: {}",
2372                    binding.resource_uri.as_str()
2373                ))
2374            })?;
2375        binding
2376            .validate_resource(&resource.definition)
2377            .map_err(|error| {
2378                McpError::invalid_request(format!("invalid MCP Apps UI resource binding: {error}"))
2379            })
2380    }
2381
2382    /// Prevents replacement of one final Apps resource with a definition that
2383    /// would invalidate an already-admitted tool binding.
2384    fn validate_mcp_apps_resource_bindings(&self, resource: &FinalResource) -> McpResult<()> {
2385        for tool in self.tools.values() {
2386            let Some(final_registration) = tool.final_registration.as_ref() else {
2387                continue;
2388            };
2389            let binding = final_registration
2390                .final_definition
2391                .mcp_apps_resource_binding()
2392                .map_err(|error| {
2393                    McpError::invalid_request(format!("invalid MCP Apps tool metadata: {error}"))
2394                })?;
2395            if let Some(binding) = binding
2396                && binding.resource_uri == resource.uri
2397            {
2398                binding.validate_resource(resource).map_err(|error| {
2399                    McpError::invalid_request(format!(
2400                        "invalid MCP Apps UI resource replacement: {error}"
2401                    ))
2402                })?;
2403            }
2404        }
2405        Ok(())
2406    }
2407
2408    /// Prevents generic resource registration from projecting an Apps View
2409    /// into exact MCP 2024-11-05. The typed provider is admitted only through
2410    /// the builder's negotiated final-only registration path.
2411    fn validate_mcp_apps_ui_resource_admission(
2412        &self,
2413        resource: &FinalResource,
2414        allow_mcp_apps: bool,
2415    ) -> McpResult<()> {
2416        let requires_mcp_apps = Self::mcp_apps_ui_resource_requires_special_admission(resource)?;
2417        if !requires_mcp_apps {
2418            return Ok(());
2419        }
2420        if !allow_mcp_apps {
2421            return Err(McpError::invalid_request(
2422                "MCP Apps UI resources require ServerBuilder::mcp_apps_ui_resource",
2423            ));
2424        }
2425        let is_apps_html = resource.uri.as_str().starts_with("ui://")
2426            && resource.mime_type.as_deref() == Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE);
2427        if !is_apps_html {
2428            return Err(McpError::invalid_request(
2429                "MCP Apps UI resources must use a ui:// URI and the MCP Apps HTML MIME type",
2430            ));
2431        }
2432        Ok(())
2433    }
2434
2435    fn mcp_apps_ui_resource_requires_special_admission(
2436        resource: &FinalResource,
2437    ) -> McpResult<bool> {
2438        let metadata = resource.mcp_apps_metadata().map_err(|error| {
2439            McpError::invalid_request(format!("invalid MCP Apps resource metadata: {error}"))
2440        })?;
2441        let is_apps_html = resource.uri.as_str().starts_with("ui://")
2442            && resource.mime_type.as_deref() == Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE);
2443        Ok(is_apps_html || metadata.is_some())
2444    }
2445
2446    /// Returns whether this router contains a final-only MCP Apps component.
2447    ///
2448    /// Builder-level composition uses this inventory before consuming a child
2449    /// server. Invalid retained final metadata is treated as Apps-bound so a
2450    /// malformed child cannot bypass the destination's Apps opt-in gate.
2451    #[must_use]
2452    pub(crate) fn has_mcp_apps_bound_components(&self) -> bool {
2453        self.final_resources.values().any(|registration| {
2454            Self::mcp_apps_ui_resource_requires_special_admission(&registration.definition)
2455                .unwrap_or(true)
2456        }) || self.tools.values().any(|registration| {
2457            registration
2458                .final_registration
2459                .as_ref()
2460                .is_some_and(|final_registration| {
2461                    final_registration
2462                        .final_definition
2463                        .mcp_apps_metadata()
2464                        .map_or(true, |metadata| metadata.is_some())
2465                })
2466        })
2467    }
2468
2469    /// Registers the handler for `completion/complete`.
2470    ///
2471    /// This is the server-wide fallback for final completion dispatch and the
2472    /// sole route for exact MCP 2024-11-05. A final provider registered for a
2473    /// specific prompt or resource template takes precedence. Re-registering
2474    /// replaces the prior fallback, matching ordinary component registration
2475    /// semantics.
2476    pub fn add_completion_handler<H: CompletionHandler + 'static>(&mut self, handler: H) {
2477        self.completion_handler = Some(Box::new(handler));
2478        self.default_final_completion_enabled = true;
2479    }
2480
2481    /// Registers a completion handler for exact MCP 2024-11-05 dispatch only.
2482    pub fn add_legacy_completion_handler<H: CompletionHandler + 'static>(&mut self, handler: H) {
2483        self.completion_handler = Some(Box::new(handler));
2484        self.default_final_completion_enabled = false;
2485    }
2486
2487    /// Registers a legacy completion provider for one exact prompt name.
2488    ///
2489    /// This route takes precedence over the legacy server-wide fallback and is
2490    /// removed atomically when `Replace` admits a new prompt at the same name.
2491    pub(crate) fn add_legacy_prompt_completion_handler<H: CompletionHandler + 'static>(
2492        &mut self,
2493        prompt_name: impl Into<String>,
2494        handler: H,
2495    ) {
2496        self.legacy_prompt_completion_handlers
2497            .insert(prompt_name.into(), Box::new(handler));
2498    }
2499
2500    /// Registers a legacy completion provider for one exact resource-template URI.
2501    ///
2502    /// This route takes precedence over the legacy server-wide fallback and is
2503    /// removed atomically when `Replace` admits a new template at the same URI.
2504    pub(crate) fn add_legacy_resource_template_completion_handler<
2505        H: CompletionHandler + 'static,
2506    >(
2507        &mut self,
2508        uri_template: impl Into<String>,
2509        handler: H,
2510    ) {
2511        self.legacy_resource_template_completion_handlers
2512            .insert(uri_template.into(), Box::new(handler));
2513    }
2514
2515    /// Registers a final completion provider for one exact prompt name.
2516    ///
2517    /// The provider is selected only after final prompt and argument admission
2518    /// succeeds. It never changes exact MCP 2024-11-05's server-wide route.
2519    pub fn add_prompt_completion_handler<H: CompletionHandler + 'static>(
2520        &mut self,
2521        prompt_name: impl Into<String>,
2522        handler: H,
2523    ) {
2524        self.final_prompt_completion_handlers
2525            .insert(prompt_name.into(), Box::new(handler));
2526    }
2527
2528    /// Registers a final completion provider for one exact resource-template URI.
2529    ///
2530    /// The provider is selected only after the resource template and requested
2531    /// template variable have been admitted for final dispatch.
2532    pub fn add_resource_template_completion_handler<H: CompletionHandler + 'static>(
2533        &mut self,
2534        uri_template: impl Into<String>,
2535        handler: H,
2536    ) {
2537        self.final_resource_template_completion_handlers
2538            .insert(uri_template.into(), Box::new(handler));
2539    }
2540
2541    /// Returns whether a `completion/complete` handler is installed.
2542    #[must_use]
2543    pub fn has_completion_handler(&self) -> bool {
2544        self.completion_handler.is_some()
2545            || !self.legacy_prompt_completion_handlers.is_empty()
2546            || !self.legacy_resource_template_completion_handlers.is_empty()
2547            || !self.final_prompt_completion_handlers.is_empty()
2548            || !self.final_resource_template_completion_handlers.is_empty()
2549    }
2550
2551    fn has_final_completion_handler(&self) -> bool {
2552        self.default_final_completion_enabled || self.has_admitted_final_completion_provider()
2553    }
2554
2555    fn has_admitted_final_completion_provider(&self) -> bool {
2556        self.final_prompt_completion_handlers
2557            .keys()
2558            .any(|name| self.final_prompts.contains_key(name))
2559            || self
2560                .final_resource_template_completion_handlers
2561                .keys()
2562                .any(|uri| {
2563                    self.resource_templates
2564                        .get(uri)
2565                        .is_some_and(|entry| entry.final_definition.is_some())
2566                })
2567    }
2568
2569    /// Adds a resource handler.
2570    ///
2571    /// If a resource with the same URI already exists, it will be replaced.
2572    /// Use [`add_resource_with_behavior`](Self::add_resource_with_behavior) for
2573    /// finer control over duplicate handling.
2574    pub fn add_resource<H: ResourceHandler + 'static>(&mut self, handler: H) {
2575        if let Err(error) = self.add_resource_registration_with_behavior(
2576            handler,
2577            crate::DuplicateBehavior::Replace,
2578            true,
2579            true,
2580            false,
2581        ) {
2582            log::warn!(
2583                target: "fastmcp_rust::router",
2584                "rejected resource registration; code={:?}",
2585                error.code
2586            );
2587        }
2588    }
2589
2590    /// Adds an intentionally exact-2024-only resource handler.
2591    pub fn add_legacy_resource<H: ResourceHandler + 'static>(&mut self, handler: H) {
2592        if let Err(error) = self.add_resource_registration_with_behavior(
2593            handler,
2594            crate::DuplicateBehavior::Replace,
2595            false,
2596            true,
2597            false,
2598        ) {
2599            log::warn!(
2600                target: "fastmcp_rust::router",
2601                "rejected exact-2024-only resource registration; code={:?}",
2602                error.code
2603            );
2604        }
2605    }
2606
2607    /// Adds a resource handler with specified duplicate behavior.
2608    ///
2609    /// Returns `Err` if behavior is [`crate::DuplicateBehavior::Error`] and the
2610    /// resource URI already exists.
2611    pub fn add_resource_with_behavior<H: ResourceHandler + 'static>(
2612        &mut self,
2613        handler: H,
2614        behavior: crate::DuplicateBehavior,
2615    ) -> Result<(), McpError> {
2616        self.add_resource_registration_with_behavior(handler, behavior, true, true, false)
2617    }
2618
2619    /// Adds an exact-final-only resource or resource template with duplicate handling.
2620    pub(crate) fn add_final_resource_with_behavior<H: ResourceHandler + 'static>(
2621        &mut self,
2622        handler: H,
2623        behavior: crate::DuplicateBehavior,
2624    ) -> Result<(), McpError> {
2625        self.add_resource_registration_with_behavior(handler, behavior, true, false, false)
2626    }
2627
2628    /// Adds one final-only MCP Apps HTML resource after builder-level Apps opt-in.
2629    pub(crate) fn add_mcp_apps_ui_resource_with_behavior<H: ResourceHandler + 'static>(
2630        &mut self,
2631        handler: H,
2632        behavior: crate::DuplicateBehavior,
2633    ) -> Result<(), McpError> {
2634        self.add_resource_registration_with_behavior(handler, behavior, true, false, true)
2635    }
2636
2637    /// Adds an exact-2024-only resource handler with duplicate handling.
2638    pub fn add_legacy_resource_with_behavior<H: ResourceHandler + 'static>(
2639        &mut self,
2640        handler: H,
2641        behavior: crate::DuplicateBehavior,
2642    ) -> Result<(), McpError> {
2643        self.add_resource_registration_with_behavior(handler, behavior, false, true, false)
2644    }
2645
2646    fn add_resource_registration_with_behavior<H: ResourceHandler + 'static>(
2647        &mut self,
2648        handler: H,
2649        behavior: crate::DuplicateBehavior,
2650        admit_final: bool,
2651        legacy_enabled: bool,
2652        allow_mcp_apps: bool,
2653    ) -> Result<(), McpError> {
2654        let (template, def) =
2655            crate::catch_extension_unwind(|| (handler.template(), handler.definition())).map_err(
2656                |_payload| {
2657                    McpError::internal_error("resource definition hook panicked during admission")
2658                },
2659            )?;
2660
2661        // Check for duplicates
2662        let key = match template.as_ref() {
2663            Some(template) => template.uri_template.clone(),
2664            None => def.uri.clone(),
2665        };
2666
2667        let exists = if template.is_some() {
2668            self.resource_templates.contains_key(&key)
2669        } else {
2670            self.resources.contains_key(&key)
2671        };
2672
2673        if exists {
2674            match behavior {
2675                crate::DuplicateBehavior::Error => {
2676                    return Err(duplicate_registration_error("Resource", &key));
2677                }
2678                crate::DuplicateBehavior::Warn => {
2679                    log::warn!(
2680                        target: "fastmcp_rust::router",
2681                        "resource already exists, keeping original; resource_key={}",
2682                        safe_log_label(&key)
2683                    );
2684                    return Ok(());
2685                }
2686                crate::DuplicateBehavior::Replace => {
2687                    log::debug!(
2688                        target: "fastmcp_rust::router",
2689                        "replacing resource; resource_key={}",
2690                        safe_log_label(&key)
2691                    );
2692                    // Fall through to insert
2693                }
2694                crate::DuplicateBehavior::Ignore => {
2695                    return Ok(());
2696                }
2697            }
2698        }
2699
2700        if let Some(template) = template {
2701            let legacy_matcher = if legacy_enabled {
2702                match admit_legacy_resource_template(&template.uri_template) {
2703                    Ok(matcher) => Some(matcher),
2704                    Err(error) if !admit_final => return Err(error),
2705                    Err(_) => None,
2706                }
2707            } else {
2708                None
2709            };
2710            let (matcher, specificity) = if admit_final {
2711                let (matcher, specificity) = admit_resource_template(&template.uri_template)?;
2712                self.reject_final_template_collisions(
2713                    &matcher,
2714                    Some(template.uri_template.as_str()),
2715                )?;
2716                (Some(matcher), specificity)
2717            } else {
2718                let matcher = legacy_matcher.as_ref().ok_or_else(|| {
2719                    McpError::internal_error(
2720                        "exact-2024 resource template admission lost its matcher",
2721                    )
2722                })?;
2723                (None, matcher.specificity())
2724            };
2725            let uri_use_policy = if admit_final {
2726                ResourceUriUsePolicy::from_client_direct_https(
2727                    crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
2728                        |_payload| {
2729                            McpError::internal_error(
2730                                "resource URI-use policy hook panicked during admission",
2731                            )
2732                        },
2733                    )?,
2734                )
2735            } else {
2736                ResourceUriUsePolicy::server_mediated()
2737            };
2738            let final_definition = admit_final
2739                .then(|| {
2740                    admit_final_resource_template_definition(
2741                        Some(&handler),
2742                        &template,
2743                        uri_use_policy,
2744                    )
2745                })
2746                .transpose()?;
2747            let boxed: BoxedResourceHandler = Box::new(handler);
2748            let is_new = !self.resource_templates.contains_key(&template.uri_template);
2749            if !is_new {
2750                self.legacy_resource_template_completion_handlers
2751                    .remove(&template.uri_template);
2752                self.final_resource_template_completion_handlers
2753                    .remove(&template.uri_template);
2754            }
2755            let entry = ResourceTemplateEntry {
2756                matcher,
2757                specificity,
2758                template: template.clone(),
2759                handler: Some(boxed),
2760                final_definition,
2761                uri_use_policy,
2762                legacy_enabled: legacy_matcher.is_some(),
2763                legacy_matcher,
2764            };
2765            self.resource_templates
2766                .insert(template.uri_template.clone(), entry);
2767            if is_new {
2768                self.resource_template_order.push(template.uri_template);
2769            }
2770            self.rebuild_sorted_template_keys();
2771        } else {
2772            let uri_use_policy = if admit_final {
2773                ResourceUriUsePolicy::from_client_direct_https(
2774                    crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
2775                        |_payload| {
2776                            McpError::internal_error(
2777                                "resource URI-use policy hook panicked during admission",
2778                            )
2779                        },
2780                    )?,
2781                )
2782            } else {
2783                ResourceUriUsePolicy::server_mediated()
2784            };
2785            let final_definition = admit_final
2786                .then(|| admit_final_resource_definition(&handler, &def, uri_use_policy))
2787                .transpose()?;
2788            if let Some(final_definition) = final_definition.as_ref() {
2789                self.validate_mcp_apps_ui_resource_admission(final_definition, allow_mcp_apps)?;
2790                self.validate_mcp_apps_resource_bindings(final_definition)?;
2791                self.reject_final_exact_resource_template_collisions(&def.uri)?;
2792            }
2793            let boxed: BoxedResourceHandler = Box::new(handler);
2794            let is_new = !self.resources.contains_key(&def.uri);
2795            self.resources.insert(def.uri.clone(), boxed);
2796            if legacy_enabled {
2797                self.final_only_resources.remove(&def.uri);
2798            } else {
2799                self.final_only_resources.insert(def.uri.clone());
2800            }
2801            match final_definition {
2802                Some(final_definition) => {
2803                    self.final_resources.insert(
2804                        def.uri.clone(),
2805                        AdmittedFinalResourceRegistration {
2806                            definition: final_definition,
2807                            tags: def.tags.clone(),
2808                            uri_use_policy,
2809                        },
2810                    );
2811                }
2812                None => {
2813                    self.final_resources.remove(&def.uri);
2814                }
2815            }
2816            if is_new {
2817                self.resource_order.push(def.uri);
2818            }
2819        }
2820
2821        self.advance_final_catalog_revision();
2822        Ok(())
2823    }
2824
2825    /// Adds a resource template definition.
2826    ///
2827    /// If a template with the same URI template already exists, its definition
2828    /// is replaced while any registered handler is retained. Use
2829    /// [`add_resource_template_with_behavior`](Self::add_resource_template_with_behavior)
2830    /// for finer control over duplicate handling.
2831    pub fn add_resource_template(&mut self, template: ResourceTemplate) {
2832        let key = template.uri_template.clone();
2833        if let Err(error) =
2834            self.add_resource_template_with_behavior(template, crate::DuplicateBehavior::Replace)
2835        {
2836            log::warn!(
2837                target: "fastmcp_rust::router",
2838                "rejected resource template definition; template_key={}; code={:?}",
2839                safe_log_label(&key),
2840                error.code
2841            );
2842        }
2843    }
2844
2845    /// Adds an exact-2024-only resource template definition.
2846    pub fn add_legacy_resource_template(&mut self, template: ResourceTemplate) {
2847        let key = template.uri_template.clone();
2848        if let Err(error) = self.add_resource_template_registration_with_behavior(
2849            template,
2850            crate::DuplicateBehavior::Replace,
2851            false,
2852        ) {
2853            log::warn!(
2854                target: "fastmcp_rust::router",
2855                "rejected exact-2024-only resource template; template_key={}; code={:?}",
2856                safe_log_label(&key),
2857                error.code
2858            );
2859        }
2860    }
2861
2862    /// Adds a resource template definition with specified duplicate behavior.
2863    ///
2864    /// Replacing a definition retains an existing handler registered for the
2865    /// same URI template. Returns `Err` when behavior is
2866    /// [`crate::DuplicateBehavior::Error`] and the URI template already exists.
2867    pub fn add_resource_template_with_behavior(
2868        &mut self,
2869        template: ResourceTemplate,
2870        behavior: crate::DuplicateBehavior,
2871    ) -> Result<(), McpError> {
2872        self.add_resource_template_registration_with_behavior(template, behavior, true)
2873    }
2874
2875    /// Adds an exact-2024-only resource template with duplicate handling.
2876    pub fn add_legacy_resource_template_with_behavior(
2877        &mut self,
2878        template: ResourceTemplate,
2879        behavior: crate::DuplicateBehavior,
2880    ) -> Result<(), McpError> {
2881        self.add_resource_template_registration_with_behavior(template, behavior, false)
2882    }
2883
2884    fn add_resource_template_registration_with_behavior(
2885        &mut self,
2886        template: ResourceTemplate,
2887        behavior: crate::DuplicateBehavior,
2888        admit_final: bool,
2889    ) -> Result<(), McpError> {
2890        let key = template.uri_template.clone();
2891        let existed = self.resource_templates.contains_key(&key);
2892
2893        if existed {
2894            match behavior {
2895                crate::DuplicateBehavior::Error => {
2896                    return Err(duplicate_registration_error("Resource template", &key));
2897                }
2898                crate::DuplicateBehavior::Warn => {
2899                    log::warn!(
2900                        target: "fastmcp_rust::router",
2901                        "resource template already exists, keeping original; template_key={}",
2902                        safe_log_label(&key)
2903                    );
2904                    return Ok(());
2905                }
2906                crate::DuplicateBehavior::Replace => {
2907                    log::debug!(
2908                        target: "fastmcp_rust::router",
2909                        "replacing resource template definition; template_key={}",
2910                        safe_log_label(&key)
2911                    );
2912                }
2913                crate::DuplicateBehavior::Ignore => return Ok(()),
2914            }
2915        }
2916
2917        let legacy_matcher = match admit_legacy_resource_template(&key) {
2918            Ok(matcher) => Some(matcher),
2919            Err(error) if !admit_final => return Err(error),
2920            Err(_) => None,
2921        };
2922        let (matcher, specificity) = if admit_final {
2923            let (matcher, specificity) = admit_resource_template(&key)?;
2924            self.reject_final_template_collisions(&matcher, Some(key.as_str()))?;
2925            (Some(matcher), specificity)
2926        } else {
2927            let matcher = legacy_matcher.as_ref().ok_or_else(|| {
2928                McpError::internal_error("exact-2024 resource template admission lost its matcher")
2929            })?;
2930            (None, matcher.specificity())
2931        };
2932        let uri_use_policy = ResourceUriUsePolicy::server_mediated();
2933        let final_definition = admit_final
2934            .then(|| {
2935                admit_final_resource_template_definition::<dyn ResourceHandler>(
2936                    None,
2937                    &template,
2938                    uri_use_policy,
2939                )
2940            })
2941            .transpose()?;
2942        if existed {
2943            self.legacy_resource_template_completion_handlers
2944                .remove(&key);
2945            self.final_resource_template_completion_handlers
2946                .remove(&key);
2947        }
2948        let needs_rebuild = match self.resource_templates.get_mut(&key) {
2949            Some(existing) => {
2950                existing.template = template;
2951                existing.matcher = matcher;
2952                existing.specificity = specificity;
2953                existing.final_definition = final_definition;
2954                existing.uri_use_policy = uri_use_policy;
2955                existing.legacy_enabled = legacy_matcher.is_some();
2956                existing.legacy_matcher = legacy_matcher;
2957                false // Key already exists, order unchanged
2958            }
2959            None => {
2960                self.resource_templates.insert(
2961                    key.clone(),
2962                    ResourceTemplateEntry {
2963                        matcher,
2964                        specificity,
2965                        template,
2966                        handler: None,
2967                        final_definition,
2968                        uri_use_policy,
2969                        legacy_enabled: legacy_matcher.is_some(),
2970                        legacy_matcher,
2971                    },
2972                );
2973                true // New key added, need to rebuild
2974            }
2975        };
2976        if needs_rebuild {
2977            self.resource_template_order.push(key);
2978            self.rebuild_sorted_template_keys();
2979        }
2980        self.advance_final_catalog_revision();
2981        Ok(())
2982    }
2983
2984    /// Adds a prompt handler.
2985    ///
2986    /// If a prompt with the same name already exists, it will be replaced.
2987    /// Use [`add_prompt_with_behavior`](Self::add_prompt_with_behavior) for
2988    /// finer control over duplicate handling.
2989    pub fn add_prompt<H: PromptHandler + 'static>(&mut self, handler: H) {
2990        if let Err(error) = self.add_prompt_registration_with_behavior(
2991            handler,
2992            crate::DuplicateBehavior::Replace,
2993            true,
2994            true,
2995        ) {
2996            log::warn!(
2997                target: "fastmcp_rust::router",
2998                "rejected prompt registration; code={:?}",
2999                error.code
3000            );
3001        }
3002    }
3003
3004    /// Adds an intentionally exact-2024-only prompt handler.
3005    pub fn add_legacy_prompt<H: PromptHandler + 'static>(&mut self, handler: H) {
3006        if let Err(error) = self.add_prompt_registration_with_behavior(
3007            handler,
3008            crate::DuplicateBehavior::Replace,
3009            false,
3010            true,
3011        ) {
3012            log::warn!(
3013                target: "fastmcp_rust::router",
3014                "rejected exact-2024-only prompt registration; code={:?}",
3015                error.code
3016            );
3017        }
3018    }
3019
3020    /// Adds a prompt handler with specified duplicate behavior.
3021    ///
3022    /// Returns `Err` if behavior is [`crate::DuplicateBehavior::Error`] and the
3023    /// prompt name already exists.
3024    pub fn add_prompt_with_behavior<H: PromptHandler + 'static>(
3025        &mut self,
3026        handler: H,
3027        behavior: crate::DuplicateBehavior,
3028    ) -> Result<(), McpError> {
3029        self.add_prompt_registration_with_behavior(handler, behavior, true, true)
3030    }
3031
3032    /// Adds an exact-final-only prompt with duplicate handling.
3033    pub(crate) fn add_final_prompt_with_behavior<H: PromptHandler + 'static>(
3034        &mut self,
3035        handler: H,
3036        behavior: crate::DuplicateBehavior,
3037    ) -> Result<(), McpError> {
3038        self.add_prompt_registration_with_behavior(handler, behavior, true, false)
3039    }
3040
3041    /// Adds an exact-2024-only prompt with duplicate handling.
3042    pub fn add_legacy_prompt_with_behavior<H: PromptHandler + 'static>(
3043        &mut self,
3044        handler: H,
3045        behavior: crate::DuplicateBehavior,
3046    ) -> Result<(), McpError> {
3047        self.add_prompt_registration_with_behavior(handler, behavior, false, true)
3048    }
3049
3050    fn add_prompt_registration_with_behavior<H: PromptHandler + 'static>(
3051        &mut self,
3052        handler: H,
3053        behavior: crate::DuplicateBehavior,
3054        admit_final: bool,
3055        legacy_enabled: bool,
3056    ) -> Result<(), McpError> {
3057        let def = crate::catch_extension_unwind(|| handler.definition()).map_err(|_payload| {
3058            McpError::internal_error("prompt definition hook panicked during admission")
3059        })?;
3060        let name = &def.name;
3061
3062        let existed = self.prompts.contains_key(name);
3063        if existed {
3064            match behavior {
3065                crate::DuplicateBehavior::Error => {
3066                    return Err(duplicate_registration_error("Prompt", name));
3067                }
3068                crate::DuplicateBehavior::Warn => {
3069                    log::warn!(
3070                        target: "fastmcp_rust::router",
3071                        "prompt already exists, keeping original; prompt_key={}",
3072                        safe_log_label(name)
3073                    );
3074                    return Ok(());
3075                }
3076                crate::DuplicateBehavior::Replace => {
3077                    log::debug!(
3078                        target: "fastmcp_rust::router",
3079                        "replacing prompt; prompt_key={}",
3080                        safe_log_label(name)
3081                    );
3082                    // Fall through to insert
3083                }
3084                crate::DuplicateBehavior::Ignore => {
3085                    return Ok(());
3086                }
3087            }
3088        }
3089
3090        let uri_use_policy = if admit_final {
3091            ResourceUriUsePolicy::from_client_direct_https(
3092                crate::catch_extension_unwind(|| handler.final_client_direct_https()).map_err(
3093                    |_payload| {
3094                        McpError::internal_error(
3095                            "prompt URI-use policy hook panicked during admission",
3096                        )
3097                    },
3098                )?,
3099            )
3100        } else {
3101            ResourceUriUsePolicy::server_mediated()
3102        };
3103        let final_definition = admit_final
3104            .then(|| admit_final_prompt_definition(&handler, &def))
3105            .transpose()?;
3106        if existed {
3107            self.legacy_prompt_completion_handlers.remove(&def.name);
3108            self.final_prompt_completion_handlers.remove(&def.name);
3109        }
3110        self.prompts.insert(def.name.clone(), Box::new(handler));
3111        if legacy_enabled {
3112            self.final_only_prompts.remove(&def.name);
3113        } else {
3114            self.final_only_prompts.insert(def.name.clone());
3115        }
3116        match final_definition {
3117            Some(final_definition) => {
3118                self.final_prompts.insert(
3119                    def.name.clone(),
3120                    AdmittedFinalPromptRegistration {
3121                        definition: final_definition,
3122                        tags: def.tags.clone(),
3123                        uri_use_policy,
3124                    },
3125                );
3126            }
3127            None => {
3128                self.final_prompts.remove(&def.name);
3129            }
3130        }
3131        if !existed {
3132            self.prompt_order.push(def.name);
3133        }
3134        self.advance_final_catalog_revision();
3135        Ok(())
3136    }
3137
3138    /// Returns all tool definitions.
3139    #[must_use]
3140    pub fn tools(&self) -> Vec<Tool> {
3141        self.tool_order
3142            .iter()
3143            .filter_map(|name| self.tools.get(name))
3144            .filter(|entry| entry.legacy_enabled)
3145            .map(|entry| entry.definition.clone())
3146            .collect()
3147    }
3148
3149    /// Returns tool definitions filtered by session state and tags.
3150    ///
3151    /// Tools that have been disabled in the session state will not be included.
3152    /// If tag filters are provided, tools must match the include/exclude criteria.
3153    #[must_use]
3154    pub fn tools_filtered(
3155        &self,
3156        session_state: Option<&SessionState>,
3157        tag_filters: Option<&TagFilters<'_>>,
3158    ) -> Vec<Tool> {
3159        self.tool_order
3160            .iter()
3161            .filter_map(|name| self.tools.get(name))
3162            .filter_map(|entry| {
3163                if !entry.legacy_enabled {
3164                    return None;
3165                }
3166                let def = &entry.definition;
3167                // Check session state filter
3168                if let Some(state) = session_state {
3169                    if !state.is_tool_enabled(&def.name) {
3170                        return None;
3171                    }
3172                }
3173                // Check tag filters
3174                if let Some(filters) = tag_filters {
3175                    if !filters.matches(&def.tags) {
3176                        return None;
3177                    }
3178                }
3179                Some(def.clone())
3180            })
3181            .collect()
3182    }
3183
3184    /// Returns all resource definitions.
3185    #[must_use]
3186    pub fn resources(&self) -> Vec<Resource> {
3187        self.resource_order
3188            .iter()
3189            .filter_map(|uri| self.resources.get(uri))
3190            .filter(|handler| {
3191                let uri = handler.definition().uri;
3192                !self.final_only_resources.contains(&uri)
3193            })
3194            .map(|h| h.definition())
3195            .collect()
3196    }
3197
3198    /// Returns resource definitions filtered by session state and tags.
3199    ///
3200    /// Resources that have been disabled in the session state will not be included.
3201    /// If tag filters are provided, resources must match the include/exclude criteria.
3202    #[must_use]
3203    pub fn resources_filtered(
3204        &self,
3205        session_state: Option<&SessionState>,
3206        tag_filters: Option<&TagFilters<'_>>,
3207    ) -> Vec<Resource> {
3208        self.resource_order
3209            .iter()
3210            .filter_map(|uri| self.resources.get(uri))
3211            .filter_map(|h| {
3212                let def = h.definition();
3213                if self.final_only_resources.contains(&def.uri) {
3214                    return None;
3215                }
3216                // Check session state filter
3217                if let Some(state) = session_state {
3218                    if !state.is_resource_enabled(&def.uri) {
3219                        return None;
3220                    }
3221                }
3222                // Check tag filters
3223                if let Some(filters) = tag_filters {
3224                    if !filters.matches(&def.tags) {
3225                        return None;
3226                    }
3227                }
3228                Some(def)
3229            })
3230            .collect()
3231    }
3232
3233    /// Returns all resource templates.
3234    #[must_use]
3235    pub fn resource_templates(&self) -> Vec<ResourceTemplate> {
3236        self.resource_template_order
3237            .iter()
3238            .filter_map(|t| self.resource_templates.get(t))
3239            .filter(|entry| entry.legacy_enabled)
3240            .map(|entry| entry.template.clone())
3241            .collect()
3242    }
3243
3244    /// Returns resource templates filtered by session state and tags.
3245    ///
3246    /// Templates that have been disabled in the session state will not be included.
3247    /// If tag filters are provided, templates must match the include/exclude criteria.
3248    #[must_use]
3249    pub fn resource_templates_filtered(
3250        &self,
3251        session_state: Option<&SessionState>,
3252        tag_filters: Option<&TagFilters<'_>>,
3253    ) -> Vec<ResourceTemplate> {
3254        self.resource_template_order
3255            .iter()
3256            .filter_map(|t| self.resource_templates.get(t))
3257            .filter_map(|entry| {
3258                if !entry.legacy_enabled {
3259                    return None;
3260                }
3261                // Check session state filter
3262                if let Some(state) = session_state {
3263                    if !state.is_resource_enabled(&entry.template.uri_template) {
3264                        return None;
3265                    }
3266                }
3267                // Check tag filters
3268                if let Some(filters) = tag_filters {
3269                    if !filters.matches(&entry.template.tags) {
3270                        return None;
3271                    }
3272                }
3273                Some(entry.template.clone())
3274            })
3275            .collect()
3276    }
3277
3278    /// Returns all prompt definitions.
3279    #[must_use]
3280    pub fn prompts(&self) -> Vec<Prompt> {
3281        self.prompt_order
3282            .iter()
3283            .filter_map(|name| self.prompts.get(name))
3284            .filter(|handler| {
3285                let name = handler.definition().name;
3286                !self.final_only_prompts.contains(&name)
3287            })
3288            .map(|h| h.definition())
3289            .collect()
3290    }
3291
3292    /// Returns prompt definitions filtered by session state and tags.
3293    ///
3294    /// Prompts that have been disabled in the session state will not be included.
3295    /// If tag filters are provided, prompts must match the include/exclude criteria.
3296    #[must_use]
3297    pub fn prompts_filtered(
3298        &self,
3299        session_state: Option<&SessionState>,
3300        tag_filters: Option<&TagFilters<'_>>,
3301    ) -> Vec<Prompt> {
3302        self.prompt_order
3303            .iter()
3304            .filter_map(|name| self.prompts.get(name))
3305            .filter_map(|h| {
3306                let def = h.definition();
3307                if self.final_only_prompts.contains(&def.name) {
3308                    return None;
3309                }
3310                // Check session state filter
3311                if let Some(state) = session_state {
3312                    if !state.is_prompt_enabled(&def.name) {
3313                        return None;
3314                    }
3315                }
3316                // Check tag filters
3317                if let Some(filters) = tag_filters {
3318                    if !filters.matches(&def.tags) {
3319                        return None;
3320                    }
3321                }
3322                Some(def)
3323            })
3324            .collect()
3325    }
3326
3327    /// Returns the number of registered tools.
3328    #[must_use]
3329    pub fn tools_count(&self) -> usize {
3330        self.tools.len()
3331    }
3332
3333    /// Returns the number of registered resources.
3334    #[must_use]
3335    pub fn resources_count(&self) -> usize {
3336        self.resources.len()
3337    }
3338
3339    /// Returns the number of registered resource templates.
3340    #[must_use]
3341    pub fn resource_templates_count(&self) -> usize {
3342        self.resource_templates.len()
3343    }
3344
3345    /// Returns the number of registered prompts.
3346    #[must_use]
3347    pub fn prompts_count(&self) -> usize {
3348        self.prompts.len()
3349    }
3350
3351    /// Returns the immutable behavior registry for final server discovery.
3352    ///
3353    /// This records only APIs backed by this router's installed catalog. The
3354    /// The server composition supplies final subscription execution and
3355    /// publishes catalog/resource changes through its request-owned listener
3356    /// registry. This router records the catalog branches that can therefore
3357    /// be selected by those final filters; it still does not advertise the
3358    /// removed logging-request emitter.
3359    #[must_use]
3360    pub(crate) fn server_discovery_behavior_registry(&self) -> ServerBehaviorRegistry {
3361        let mut behaviors = Vec::with_capacity(11);
3362        behaviors.push(ServerBehavior::SubscriptionsListen);
3363        if self.has_final_completion_handler() {
3364            behaviors.push(ServerBehavior::CompletionComplete);
3365        }
3366        if self
3367            .tool_order
3368            .iter()
3369            .filter_map(|name| self.tools.get(name))
3370            .any(|entry| entry.final_registration.is_some())
3371        {
3372            behaviors.push(ServerBehavior::ToolsList);
3373            behaviors.push(ServerBehavior::ToolsListChangedNotification);
3374        }
3375        if !self.final_resources.is_empty()
3376            || self
3377                .resource_template_order
3378                .iter()
3379                .filter_map(|key| self.resource_templates.get(key))
3380                .any(|entry| entry.final_definition.is_some())
3381        {
3382            behaviors.push(ServerBehavior::ResourcesList);
3383            behaviors.push(ServerBehavior::ResourcesListChangedNotification);
3384        }
3385        if !self.final_resources.is_empty() {
3386            behaviors.push(ServerBehavior::ResourceUpdateDelivery);
3387        }
3388        if !self.final_prompts.is_empty() {
3389            behaviors.push(ServerBehavior::PromptsList);
3390            behaviors.push(ServerBehavior::PromptsListChangedNotification);
3391        }
3392        ServerBehaviorRegistry::from_behaviors(behaviors)
3393    }
3394
3395    /// Gets a tool handler by name.
3396    #[must_use]
3397    pub fn get_tool(&self, name: &str) -> Option<&BoxedToolHandler> {
3398        self.tools.get(name).map(|entry| &entry.handler)
3399    }
3400
3401    /// Gets a resource handler by URI.
3402    #[must_use]
3403    pub fn get_resource(&self, uri: &str) -> Option<&BoxedResourceHandler> {
3404        self.resources.get(uri)
3405    }
3406
3407    /// Gets a resource template by URI template.
3408    #[must_use]
3409    pub fn get_resource_template(&self, uri_template: &str) -> Option<&ResourceTemplate> {
3410        self.resource_templates
3411            .get(uri_template)
3412            .map(|entry| &entry.template)
3413    }
3414
3415    /// Returns true if a resource exists for the given URI (static or template match).
3416    #[must_use]
3417    pub fn resource_exists(&self, uri: &str) -> bool {
3418        self.resolve_resource(uri).is_some()
3419    }
3420
3421    /// Forwards a session `resources/subscribe` onto the resolved handler.
3422    pub(crate) fn notify_resource_subscribed(&self, ctx: &McpContext, uri: &str) -> McpResult<()> {
3423        if let Some(resolved) = self.resolve_resource(uri) {
3424            resolved.handler.on_subscribe(ctx, uri)?;
3425        }
3426        Ok(())
3427    }
3428
3429    /// Forwards a session `resources/unsubscribe` onto the resolved handler.
3430    pub(crate) fn notify_resource_unsubscribed(
3431        &self,
3432        ctx: &McpContext,
3433        uri: &str,
3434    ) -> McpResult<()> {
3435        if let Some(resolved) = self.resolve_resource(uri) {
3436            resolved.handler.on_unsubscribe(ctx, uri)?;
3437        }
3438        Ok(())
3439    }
3440
3441    fn resolve_resource(&self, uri: &str) -> Option<ResolvedResource<'_>> {
3442        self.resolve_resource_for_era(uri, None)
3443    }
3444
3445    /// Resolves a registered tool visible in one requested protocol era.
3446    ///
3447    /// A final-only tool (Apps-linked, task-capable) is reachable only in the
3448    /// modern era; a legacy-enabled tool is reachable in the exact-2024 era.
3449    #[cfg(test)]
3450    fn resolve_tool_for_era(
3451        &self,
3452        name: &str,
3453        era: Option<ProtocolEra>,
3454    ) -> Option<&AdmittedToolRegistration> {
3455        let registration = self.tools.get(name)?;
3456        let visible = era.is_none_or(|era| match era {
3457            ProtocolEra::Modern2026 => registration.final_registration.is_some(),
3458            ProtocolEra::Legacy2024 => registration.legacy_enabled,
3459        });
3460        visible.then_some(registration)
3461    }
3462
3463    /// Resolves a resource that is visible in one requested protocol era.
3464    ///
3465    /// A static resource takes precedence only when it is visible to that
3466    /// era. Otherwise a resource-template instance from the requested era
3467    /// must remain reachable; registrations intentionally keep the two
3468    /// catalogs separate.
3469    fn resolve_resource_for_era(
3470        &self,
3471        uri: &str,
3472        era: Option<ProtocolEra>,
3473    ) -> Option<ResolvedResource<'_>> {
3474        if let Some(handler) = self.resources.get(uri) {
3475            let resolved = ResolvedResource {
3476                handler,
3477                params: UriParams::new(),
3478                final_enabled: self.final_resources.contains_key(uri),
3479                legacy_enabled: !self.final_only_resources.contains(uri),
3480                uri_use_policy: self
3481                    .final_resources
3482                    .get(uri)
3483                    .map_or_else(ResourceUriUsePolicy::server_mediated, |entry| {
3484                        entry.uri_use_policy
3485                    }),
3486            };
3487            if era.is_none_or(|era| resolved.is_enabled_in(era)) {
3488                return Some(resolved);
3489            }
3490        }
3491
3492        // Use pre-sorted template keys to avoid sorting on every lookup
3493        'templates: for key in &self.sorted_template_keys {
3494            let entry = &self.resource_templates[key];
3495            let Some(handler) = entry.handler.as_ref() else {
3496                continue;
3497            };
3498            let legacy_params = || {
3499                entry
3500                    .legacy_matcher
3501                    .as_ref()
3502                    .and_then(|matcher| matcher.matches(uri))
3503            };
3504            let final_params = || {
3505                let values = entry.matcher.as_ref()?.match_uri(uri).ok()??;
3506                let mut params = UriParams::with_capacity(values.len());
3507                for (name, value) in values {
3508                    let TemplateValue::Scalar(value) = value else {
3509                        return None;
3510                    };
3511                    params.insert(name, value);
3512                }
3513                Some(params)
3514            };
3515            let Some(params) = (match era {
3516                Some(ProtocolEra::Legacy2024) => legacy_params(),
3517                Some(ProtocolEra::Modern2026) => final_params(),
3518                None => final_params().or_else(legacy_params),
3519            }) else {
3520                continue 'templates;
3521            };
3522            let resolved = ResolvedResource {
3523                handler,
3524                params,
3525                final_enabled: entry.final_definition.is_some(),
3526                legacy_enabled: entry.legacy_enabled,
3527                uri_use_policy: entry.uri_use_policy,
3528            };
3529            if era.is_none_or(|era| resolved.is_enabled_in(era)) {
3530                return Some(resolved);
3531            }
3532        }
3533
3534        None
3535    }
3536
3537    /// Gets a prompt handler by name.
3538    #[must_use]
3539    pub fn get_prompt(&self, name: &str) -> Option<&BoxedPromptHandler> {
3540        self.prompts.get(name)
3541    }
3542
3543    // ========================================================================
3544    // Request Dispatch Methods
3545    // ========================================================================
3546
3547    /// Handles the initialize request.
3548    pub fn handle_initialize(
3549        &self,
3550        request_ctx: &McpContext,
3551        session: &mut Session,
3552        params: InitializeParams,
3553        instructions: Option<&str>,
3554    ) -> McpResult<InitializeResult> {
3555        if let Some(error) = budget_error(request_ctx) {
3556            return Err(error);
3557        }
3558
3559        debug!(
3560            target: targets::SESSION,
3561            "preparing session initialization; client_key={}",
3562            safe_log_label(&params.client_info.name)
3563        );
3564
3565        // Initialize the session
3566        session.initialize(
3567            params.client_info,
3568            params.capabilities,
3569            PROTOCOL_VERSION.to_string(),
3570        );
3571
3572        Ok(InitializeResult {
3573            protocol_version: PROTOCOL_VERSION.to_string(),
3574            capabilities: session.server_capabilities().clone(),
3575            server_info: session.server_info().clone(),
3576            instructions: instructions.map(String::from),
3577        })
3578    }
3579
3580    /// Dispatches one exact legacy `completion/complete` request.
3581    ///
3582    /// This route decodes through the dual-era core contract before invoking
3583    /// the installed handler. In particular, a final `_meta` object remains a
3584    /// cross-era error even though the legacy parameter shape is otherwise
3585    /// intentionally open.
3586    pub(crate) fn dispatch_legacy_completion(
3587        &self,
3588        request_ctx: &McpContext,
3589        request: &JsonRpcRequest,
3590    ) -> McpResult<serde_json::Value> {
3591        if request.method != COMPLETION_COMPLETE {
3592            return Err(McpError::method_not_found(&request.method));
3593        }
3594
3595        let request = CoreRequest::decode(
3596            ProtocolEra::Legacy2024,
3597            COMPLETION_COMPLETE,
3598            request.params.as_ref(),
3599        )
3600        .map_err(|error| McpError::invalid_params(error.to_string()))?;
3601        let CoreRequest::Legacy(LegacyCoreRequest::Completion(params)) = request else {
3602            return Err(McpError::internal_error(
3603                "legacy completion dispatch selected another core request",
3604            ));
3605        };
3606
3607        serde_json::to_value(self.handle_completion_legacy(request_ctx, params)?)
3608            .map_err(McpError::from)
3609    }
3610
3611    pub(crate) async fn dispatch_legacy_completion_in_request(
3612        &self,
3613        request_ctx: &McpContext,
3614        request_cx: &Cx,
3615        request: &JsonRpcRequest,
3616    ) -> McpResult<serde_json::Value> {
3617        if request.method != COMPLETION_COMPLETE {
3618            return Err(McpError::method_not_found(&request.method));
3619        }
3620
3621        let request = CoreRequest::decode(
3622            ProtocolEra::Legacy2024,
3623            COMPLETION_COMPLETE,
3624            request.params.as_ref(),
3625        )
3626        .map_err(|error| McpError::invalid_params(error.to_string()))?;
3627        let CoreRequest::Legacy(LegacyCoreRequest::Completion(params)) = request else {
3628            return Err(McpError::internal_error(
3629                "legacy completion dispatch selected another core request",
3630            ));
3631        };
3632
3633        serde_json::to_value(
3634            self.handle_completion_legacy_in_request(request_ctx, request_cx, params)
3635                .await?,
3636        )
3637        .map_err(McpError::from)
3638    }
3639
3640    /// Handles one exact MCP 2024-11-05 completion request.
3641    pub fn handle_completion_legacy(
3642        &self,
3643        request_ctx: &McpContext,
3644        params: LegacyCompletionParams,
3645    ) -> McpResult<LegacyCompletionResult> {
3646        block_on(self.handle_completion_legacy_in_request(request_ctx, request_ctx.cx(), params))
3647    }
3648
3649    pub(crate) async fn handle_completion_legacy_in_request(
3650        &self,
3651        request_ctx: &McpContext,
3652        request_cx: &Cx,
3653        params: LegacyCompletionParams,
3654    ) -> McpResult<LegacyCompletionResult> {
3655        let dispatch_started_at = request_ctx.cx().now();
3656        if let Some(error) = budget_error(request_ctx) {
3657            return Err(error);
3658        }
3659
3660        let target_handler = match &params.reference {
3661            fastmcp_protocol::LegacyCompletionReference::Prompt { name } => {
3662                self.legacy_prompt_completion_handlers.get(name)
3663            }
3664            fastmcp_protocol::LegacyCompletionReference::Resource { uri } => {
3665                self.legacy_resource_template_completion_handlers.get(uri)
3666            }
3667        };
3668        let handler = target_handler
3669            .or(self.completion_handler.as_ref())
3670            .ok_or_else(|| McpError::method_not_found(COMPLETION_COMPLETE))?;
3671        let handler_ctx =
3672            derive_handler_context(request_ctx, None, None, None, ProtocolEra::Legacy2024);
3673        let handler_timeout =
3674            read_handler_timeout(request_ctx.cx(), "completion_timeout", || handler.timeout())?;
3675        let effective_budget = compose_handler_budget(
3676            request_ctx.cx().budget(),
3677            request_ctx.budget(),
3678            handler_timeout,
3679            dispatch_started_at,
3680        );
3681        let handler_ctx = handler_ctx.with_operation_deadline(effective_budget.deadline);
3682        let outcome = run_handler_in_request(
3683            &handler_ctx,
3684            request_cx,
3685            effective_budget,
3686            "completion",
3687            |child_cx| handler.complete_legacy_async_in_request(&handler_ctx, child_cx, params),
3688        )
3689        .await?;
3690
3691        let completion = match outcome {
3692            Outcome::Ok(completion) => completion,
3693            Outcome::Err(error) => {
3694                return Err(sanitize_handler_error(
3695                    request_ctx.cx(),
3696                    "completion",
3697                    error,
3698                ));
3699            }
3700            Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
3701            Outcome::Panicked(_payload) => {
3702                return Err(sanitized_handler_panic(request_ctx.cx(), "completion"));
3703            }
3704        };
3705
3706        Ok(LegacyCompletionResult {
3707            completion,
3708            meta: None,
3709        })
3710    }
3711
3712    async fn handle_completion_final_in_request(
3713        &self,
3714        request_ctx: &McpContext,
3715        request_cx: &Cx,
3716        params: FinalCompletionParams,
3717    ) -> McpResult<FinalCompletionResult> {
3718        let dispatch_started_at = request_ctx.cx().now();
3719        if request_cx.is_cancel_requested() {
3720            return Err(McpError::request_cancelled());
3721        }
3722        if let Some(error) = budget_error(request_ctx) {
3723            return Err(error);
3724        }
3725        if !self.has_final_completion_handler() {
3726            return Err(McpError::method_not_found(COMPLETION_COMPLETE));
3727        }
3728
3729        let provider_handler = match &params.reference {
3730            FinalCompletionReference::Prompt { name }
3731            | FinalCompletionReference::PromptWithTitle { name, .. } => {
3732                // Completion is a reference-discovery surface as well as an
3733                // invocation surface. Keep a component disabled for this
3734                // modern connection indistinguishable from an unregistered
3735                // prompt, before consulting its schema or a provider.
3736                if !request_ctx.is_prompt_enabled(name) {
3737                    return Err(McpError::invalid_params(
3738                        "completion prompt reference is not registered",
3739                    ));
3740                }
3741                let prompt = self.final_prompts.get(name).ok_or_else(|| {
3742                    McpError::invalid_params("completion prompt reference is not registered")
3743                })?;
3744                if !prompt
3745                    .definition
3746                    .arguments
3747                    .as_deref()
3748                    .unwrap_or_default()
3749                    .iter()
3750                    .any(|argument| argument.name == params.argument.name)
3751                {
3752                    return Err(McpError::invalid_params(
3753                        "completion argument is not declared by the referenced target",
3754                    ));
3755                }
3756                self.final_prompt_completion_handlers.get(name)
3757            }
3758            FinalCompletionReference::Resource { uri } => {
3759                // Resource-template completion must use the same individual-request
3760                // admission gate as resources/read. Catalog listing is deliberately
3761                // connection-independent, so never fall through to the global
3762                // completion provider for a disabled template.
3763                if !request_ctx.is_resource_enabled(uri) {
3764                    return Err(McpError::invalid_params(
3765                        "completion resource reference is not registered",
3766                    ));
3767                }
3768                let template = self
3769                    .resource_templates
3770                    .get(uri)
3771                    .filter(|entry| entry.final_definition.is_some())
3772                    .ok_or_else(|| {
3773                        McpError::invalid_params("completion resource reference is not registered")
3774                    })?;
3775                let template = fastmcp_protocol::UriTemplate::parse(
3776                    &template.template.uri_template,
3777                )
3778                .map_err(|_| {
3779                    McpError::internal_error(
3780                        "admitted completion resource template is no longer valid",
3781                    )
3782                })?;
3783                if !template.parts().iter().any(|part| {
3784                    matches!(part, UriTemplatePart::Expression(expression)
3785                        if expression
3786                            .variables()
3787                            .iter()
3788                            .any(|variable| variable.name() == params.argument.name))
3789                }) {
3790                    return Err(McpError::invalid_params(
3791                        "completion argument is not declared by the referenced target",
3792                    ));
3793                }
3794                self.final_resource_template_completion_handlers.get(uri)
3795            }
3796        };
3797
3798        let handler = match provider_handler {
3799            Some(handler) => handler,
3800            None if self.default_final_completion_enabled => self
3801                .completion_handler
3802                .as_ref()
3803                .ok_or_else(|| McpError::method_not_found(COMPLETION_COMPLETE))?,
3804            None => {
3805                return Err(McpError::invalid_params(
3806                    "no final completion provider is registered for the referenced target",
3807                ));
3808            }
3809        };
3810        let handler_ctx =
3811            derive_handler_context(request_ctx, None, None, None, ProtocolEra::Modern2026);
3812        let handler_timeout =
3813            read_handler_timeout(request_ctx.cx(), "completion_timeout", || handler.timeout())?;
3814        let effective_budget = compose_handler_budget(
3815            request_ctx.cx().budget(),
3816            request_ctx.budget(),
3817            handler_timeout,
3818            dispatch_started_at,
3819        );
3820        let handler_ctx = handler_ctx.with_operation_deadline(effective_budget.deadline);
3821        let outcome = run_handler_in_request(
3822            &handler_ctx,
3823            request_cx,
3824            effective_budget,
3825            "completion",
3826            |child_cx| handler.complete_final_async_in_request(&handler_ctx, child_cx, params),
3827        )
3828        .await?;
3829
3830        let completion = match outcome {
3831            Outcome::Ok(completion) => {
3832                if completion.values.len() > fastmcp_protocol::MAX_COMPLETION_VALUES {
3833                    return Err(McpError::internal_error(
3834                        "completion handler returned more than 100 values",
3835                    ));
3836                }
3837                completion.validate().map_err(McpError::internal_error)?;
3838                completion
3839            }
3840            Outcome::Err(error) => {
3841                return Err(sanitize_handler_error(
3842                    request_ctx.cx(),
3843                    "completion",
3844                    error,
3845                ));
3846            }
3847            Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
3848            Outcome::Panicked(_payload) => {
3849                return Err(sanitized_handler_panic(request_ctx.cx(), "completion"));
3850            }
3851        };
3852
3853        Ok(FinalCompletionResult { completion })
3854    }
3855
3856    /// Dispatches a modern request through the transport-neutral final router.
3857    ///
3858    /// This is the modern server-side routing seam. It deliberately has no
3859    /// `Session` argument: final catalog pages and cursors are independent of
3860    /// connection state. A supplied modern connection context still controls
3861    /// individual request admission and binds MRTR retries to its durable
3862    /// partition.
3863    /// Every successful response is re-emitted through the final
3864    /// complete-result contract. State-bearing lifecycle methods and exact
3865    /// 2024-11-05 wire results stay on the legacy adapter rather than
3866    /// acquiring accidental modern semantics.
3867    pub(crate) fn dispatch_stateless(
3868        &self,
3869        request_ctx: &McpContext,
3870        request: &JsonRpcRequest,
3871    ) -> McpResult<serde_json::Value> {
3872        // The connection-oriented server adapter remains synchronous today.
3873        // Keep its ordered compatibility semantics here; modern runtime entry
3874        // points must use `dispatch_stateless_owned` below instead of sharing
3875        // this blocking bridge.
3876        let continuation_cancellation = fastmcp_core::McpRequestCancellation::new();
3877        self.dispatch_stateless_with_continuation_cancellation(
3878            request_ctx,
3879            request,
3880            &continuation_cancellation,
3881        )
3882    }
3883
3884    /// Dispatches a modern request with the exact admitted `params` source.
3885    ///
3886    /// Callers that received an ingress raw-parameter sidecar must use this
3887    /// entry point so final MRTR retries retain ordered response entries and
3888    /// reject duplicate keys before registry admission.
3889    pub(crate) fn dispatch_stateless_with_raw_params(
3890        &self,
3891        request_ctx: &McpContext,
3892        request: &JsonRpcRequest,
3893        raw_params: Option<&str>,
3894    ) -> McpResult<serde_json::Value> {
3895        let continuation_cancellation = fastmcp_core::McpRequestCancellation::new();
3896        self.dispatch_stateless_with_continuation_cancellation_and_raw_params(
3897            request_ctx,
3898            request,
3899            raw_params,
3900            &continuation_cancellation,
3901        )
3902    }
3903
3904    pub(crate) fn dispatch_stateless_with_continuation_cancellation(
3905        &self,
3906        request_ctx: &McpContext,
3907        request: &JsonRpcRequest,
3908        continuation_cancellation: &fastmcp_core::McpRequestCancellation,
3909    ) -> McpResult<serde_json::Value> {
3910        self.dispatch_stateless_with_continuation_cancellation_and_raw_params(
3911            request_ctx,
3912            request,
3913            None,
3914            continuation_cancellation,
3915        )
3916    }
3917
3918    /// Dispatches with connection-owned continuation cancellation and the
3919    /// exact admitted parameter source retained by transport ingress.
3920    pub(crate) fn dispatch_stateless_with_continuation_cancellation_and_raw_params(
3921        &self,
3922        request_ctx: &McpContext,
3923        request: &JsonRpcRequest,
3924        raw_params: Option<&str>,
3925        continuation_cancellation: &fastmcp_core::McpRequestCancellation,
3926    ) -> McpResult<serde_json::Value> {
3927        block_on(self.dispatch_stateless_in_request(
3928            request_ctx,
3929            request_ctx.cx(),
3930            request,
3931            raw_params,
3932            continuation_cancellation,
3933        ))
3934    }
3935
3936    /// Dispatches one modern request in a request-owned structured child task.
3937    ///
3938    /// The caller owns the returned future. It owns exactly one child task,
3939    /// waits for that task to finish, and cancellation of that wait aborts the
3940    /// child through `TaskHandle::join` before control returns. No task is
3941    /// detached: a result is produced only after the handler task has reached a
3942    /// terminal state. The child Cx is propagated to the modern handler hooks
3943    /// so their nested work remains in the same request lifetime.
3944    pub(crate) async fn dispatch_stateless_owned(
3945        self: Arc<Self>,
3946        request_ctx: McpContext,
3947        request: JsonRpcRequest,
3948    ) -> McpResult<serde_json::Value> {
3949        self.dispatch_stateless_owned_with_continuation_cancellation(
3950            request_ctx,
3951            request,
3952            fastmcp_core::McpRequestCancellation::new(),
3953        )
3954        .await
3955    }
3956
3957    /// Dispatches one modern request with the continuation owner selected by
3958    /// its transport connection. The owner is deliberately distinct from the
3959    /// request cancellation: an `input_required` response ends one JSON-RPC
3960    /// request normally, while its retry remains valid until its connection
3961    /// disconnects or the continuation expires.
3962    pub(crate) async fn dispatch_stateless_owned_with_continuation_cancellation(
3963        self: Arc<Self>,
3964        request_ctx: McpContext,
3965        request: JsonRpcRequest,
3966        continuation_cancellation: fastmcp_core::McpRequestCancellation,
3967    ) -> McpResult<serde_json::Value> {
3968        self.dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
3969            request_ctx,
3970            request,
3971            None,
3972            continuation_cancellation,
3973        )
3974        .await
3975    }
3976
3977    /// Dispatches an owned modern request with its retained raw-parameter
3978    /// sidecar. The sidecar is owned because a request child may outlive the
3979    /// transport frame reader that admitted it.
3980    pub(crate) async fn dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
3981        self: Arc<Self>,
3982        request_ctx: McpContext,
3983        request: JsonRpcRequest,
3984        raw_params: Option<Arc<str>>,
3985        continuation_cancellation: fastmcp_core::McpRequestCancellation,
3986    ) -> McpResult<serde_json::Value> {
3987        if let Some(error) = budget_error(&request_ctx) {
3988            return Err(error);
3989        }
3990
3991        let join_cx = request_ctx.cx().clone();
3992        let dispatch_ctx = request_ctx.clone();
3993        let spawn_self = Arc::clone(&self);
3994        let spawn_request = request.clone();
3995        let spawn_raw_params = raw_params.clone();
3996        let spawn_continuation_cancellation = continuation_cancellation.clone();
3997        let mut task = match request_ctx.cx().spawn(move |child_cx| async move {
3998            spawn_self
3999                .dispatch_stateless_in_request(
4000                    &dispatch_ctx,
4001                    &child_cx,
4002                    &spawn_request,
4003                    spawn_raw_params.as_deref(),
4004                    &spawn_continuation_cancellation,
4005                )
4006                .await
4007        }) {
4008            Ok(task) => task,
4009            // A context without a spawn gateway (lab/test contexts, plain
4010            // synchronous callers) cannot host the request-owned child; the
4011            // in-request dispatch on the caller's own Cx preserves the same
4012            // cancellation observations without child isolation. Every other
4013            // spawn failure (region closed, quota) stays a scheduling error.
4014            Err(asupersync::runtime::state::SpawnError::RuntimeUnavailable) => {
4015                return self
4016                    .dispatch_stateless_in_request(
4017                        &request_ctx,
4018                        request_ctx.cx(),
4019                        &request,
4020                        raw_params.as_deref(),
4021                        &continuation_cancellation,
4022                    )
4023                    .await;
4024            }
4025            Err(_error) => {
4026                return Err(McpError::internal_error(
4027                    "request-owned modern dispatch could not be scheduled",
4028                ));
4029            }
4030        };
4031
4032        match task.join(&join_cx).await {
4033            Ok(result) => result,
4034            Err(asupersync::runtime::JoinError::Panicked(_payload)) => {
4035                Err(sanitized_handler_panic(&join_cx, "modern_dispatch"))
4036            }
4037            Err(_error) => Err(McpError::request_cancelled()),
4038        }
4039    }
4040
4041    async fn dispatch_stateless_in_request(
4042        &self,
4043        request_ctx: &McpContext,
4044        request_cx: &Cx,
4045        request: &JsonRpcRequest,
4046        raw_params: Option<&str>,
4047        continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4048    ) -> McpResult<serde_json::Value> {
4049        if request_cx.is_cancel_requested() {
4050            return Err(McpError::request_cancelled());
4051        }
4052        if let Some(error) = budget_error(request_ctx) {
4053            return Err(error);
4054        }
4055
4056        let params = request.params.as_ref();
4057        let result = match request.method.as_str() {
4058            // Connection health-check. Session dispatch already answers `{}`.
4059            // Stateless HTTP needs the same check without adding ping to
4060            // FINAL_2026_07_28_METHODS / FinalCoreRequest.
4061            "ping" => serde_json::json!({}),
4062            COMPLETION_COMPLETE => {
4063                let request =
4064                    CoreRequest::decode(ProtocolEra::Modern2026, COMPLETION_COMPLETE, params)
4065                        .map_err(|error| McpError::invalid_params(error.to_string()))?;
4066                let CoreRequest::Final(FinalCoreRequest::Completion(params)) = request else {
4067                    return Err(McpError::internal_error(
4068                        "modern completion dispatch selected another core request",
4069                    ));
4070                };
4071                encode_stateless_handler_result(
4072                    self.handle_completion_final_in_request(request_ctx, request_cx, params)
4073                        .await,
4074                )?
4075            }
4076            "tools/list" => {
4077                let request = CoreRequest::decode(ProtocolEra::Modern2026, "tools/list", params)
4078                    .map_err(|error| McpError::invalid_params(error.to_string()))?;
4079                let CoreRequest::Final(FinalCoreRequest::ToolsList(params)) = request else {
4080                    return Err(McpError::internal_error(
4081                        "modern tools/list dispatch selected another core request",
4082                    ));
4083                };
4084                encode_final_core_result(
4085                    self.handle_final_tools_list(request_ctx, params),
4086                    |result| FinalCoreResult::ToolsList {
4087                        result,
4088                        diagnostic: None,
4089                    },
4090                )?
4091            }
4092            "tools/call" => {
4093                self.admit_final_mrtr_response_map(params)?;
4094                let request = CoreRequest::decode_with_raw_params(
4095                    ProtocolEra::Modern2026,
4096                    "tools/call",
4097                    params,
4098                    raw_params,
4099                )
4100                .map_err(|error| McpError::invalid_params(error.to_string()))?;
4101                let CoreRequest::Final(FinalCoreRequest::ToolsCall(params)) = request else {
4102                    return Err(McpError::internal_error(
4103                        "modern tools/call dispatch selected another core request",
4104                    ));
4105                };
4106                let binding = final_mrtr_binding(
4107                    request_ctx,
4108                    "tools/call",
4109                    params.name.clone(),
4110                    &params.arguments,
4111                )?;
4112                match self.resolve_final_mrtr_retry(
4113                    params.request_state.as_deref(),
4114                    params.input_responses.as_ref(),
4115                    binding.as_ref(),
4116                )? {
4117                    FinalMrtrDispatch::InputRequired(result) => result,
4118                    FinalMrtrDispatch::Fresh => {
4119                        self.dispatch_final_tools_call(
4120                            request_ctx,
4121                            request_cx,
4122                            params,
4123                            binding,
4124                            None,
4125                            continuation_cancellation,
4126                        )
4127                        .await?
4128                    }
4129                    FinalMrtrDispatch::Resume(resume_inputs) => {
4130                        self.dispatch_final_tools_call(
4131                            request_ctx,
4132                            request_cx,
4133                            params,
4134                            binding,
4135                            Some(resume_inputs),
4136                            continuation_cancellation,
4137                        )
4138                        .await?
4139                    }
4140                }
4141            }
4142            "resources/list" => {
4143                let request =
4144                    CoreRequest::decode(ProtocolEra::Modern2026, "resources/list", params)
4145                        .map_err(|error| McpError::invalid_params(error.to_string()))?;
4146                let CoreRequest::Final(FinalCoreRequest::ResourcesList(params)) = &request else {
4147                    return Err(McpError::internal_error(
4148                        "modern resources/list dispatch selected another core request",
4149                    ));
4150                };
4151                encode_final_core_result(
4152                    self.handle_final_resources_list(request_ctx, params.clone()),
4153                    |result| FinalCoreResult::ResourcesList {
4154                        result,
4155                        diagnostic: None,
4156                    },
4157                )?
4158            }
4159            "resources/templates/list" => {
4160                let request = CoreRequest::decode(
4161                    ProtocolEra::Modern2026,
4162                    "resources/templates/list",
4163                    params,
4164                )
4165                .map_err(|error| McpError::invalid_params(error.to_string()))?;
4166                let CoreRequest::Final(FinalCoreRequest::ResourceTemplatesList(params)) = &request
4167                else {
4168                    return Err(McpError::internal_error(
4169                        "modern resources/templates/list dispatch selected another core request",
4170                    ));
4171                };
4172                encode_final_core_result(
4173                    self.handle_final_resource_templates_list(request_ctx, params.clone()),
4174                    |result| FinalCoreResult::ResourceTemplatesList {
4175                        result,
4176                        diagnostic: None,
4177                    },
4178                )?
4179            }
4180            "resources/read" => {
4181                self.admit_final_mrtr_response_map(params)?;
4182                let request = CoreRequest::decode_with_raw_params(
4183                    ProtocolEra::Modern2026,
4184                    "resources/read",
4185                    params,
4186                    raw_params,
4187                )
4188                .map_err(|error| McpError::invalid_params(error.to_string()))?;
4189                let CoreRequest::Final(FinalCoreRequest::ResourcesRead(params)) = &request else {
4190                    return Err(McpError::internal_error(
4191                        "modern resources/read dispatch selected another core request",
4192                    ));
4193                };
4194                let binding = final_mrtr_binding(
4195                    request_ctx,
4196                    "resources/read",
4197                    params.uri.as_str().to_owned(),
4198                    &(),
4199                )?;
4200                match self.resolve_final_mrtr_retry(
4201                    params.request_state.as_deref(),
4202                    params.input_responses.as_ref(),
4203                    binding.as_ref(),
4204                )? {
4205                    FinalMrtrDispatch::InputRequired(result) => result,
4206                    FinalMrtrDispatch::Fresh => {
4207                        self.dispatch_final_resources_read(
4208                            request_ctx,
4209                            request_cx,
4210                            params.clone(),
4211                            binding,
4212                            None,
4213                            continuation_cancellation,
4214                        )
4215                        .await?
4216                    }
4217                    FinalMrtrDispatch::Resume(resume_inputs) => {
4218                        self.dispatch_final_resources_read(
4219                            request_ctx,
4220                            request_cx,
4221                            params.clone(),
4222                            binding,
4223                            Some(resume_inputs),
4224                            continuation_cancellation,
4225                        )
4226                        .await?
4227                    }
4228                }
4229            }
4230            "prompts/list" => {
4231                let request = CoreRequest::decode(ProtocolEra::Modern2026, "prompts/list", params)
4232                    .map_err(|error| McpError::invalid_params(error.to_string()))?;
4233                let CoreRequest::Final(FinalCoreRequest::PromptsList(params)) = &request else {
4234                    return Err(McpError::internal_error(
4235                        "modern prompts/list dispatch selected another core request",
4236                    ));
4237                };
4238                encode_final_core_result(
4239                    self.handle_final_prompts_list(request_ctx, params.clone()),
4240                    |result| FinalCoreResult::PromptsList {
4241                        result,
4242                        diagnostic: None,
4243                    },
4244                )?
4245            }
4246            "prompts/get" => {
4247                self.admit_final_mrtr_response_map(params)?;
4248                let request = CoreRequest::decode_with_raw_params(
4249                    ProtocolEra::Modern2026,
4250                    "prompts/get",
4251                    params,
4252                    raw_params,
4253                )
4254                .map_err(|error| McpError::invalid_params(error.to_string()))?;
4255                let CoreRequest::Final(FinalCoreRequest::PromptsGet(params)) = request else {
4256                    return Err(McpError::internal_error(
4257                        "modern prompts/get dispatch selected another core request",
4258                    ));
4259                };
4260                let binding = final_mrtr_binding(
4261                    request_ctx,
4262                    "prompts/get",
4263                    params.name.clone(),
4264                    &params.arguments,
4265                )?;
4266                match self.resolve_final_mrtr_retry(
4267                    params.request_state.as_deref(),
4268                    params.input_responses.as_ref(),
4269                    binding.as_ref(),
4270                )? {
4271                    FinalMrtrDispatch::InputRequired(result) => result,
4272                    FinalMrtrDispatch::Fresh => {
4273                        self.dispatch_final_prompts_get(
4274                            request_ctx,
4275                            request_cx,
4276                            params,
4277                            binding,
4278                            None,
4279                            continuation_cancellation,
4280                        )
4281                        .await?
4282                    }
4283                    FinalMrtrDispatch::Resume(resume_inputs) => {
4284                        self.dispatch_final_prompts_get(
4285                            request_ctx,
4286                            request_cx,
4287                            params,
4288                            binding,
4289                            Some(resume_inputs),
4290                            continuation_cancellation,
4291                        )
4292                        .await?
4293                    }
4294                }
4295            }
4296            _ => return Err(McpError::method_not_found(&request.method)),
4297        };
4298
4299        if request_cx.is_cancel_requested() {
4300            return Err(McpError::request_cancelled());
4301        }
4302        if let Some(error) = budget_error(request_ctx) {
4303            return Err(error);
4304        }
4305        Ok(result)
4306    }
4307
4308    /// Admits bounded raw retry values before final parameter decoding clones
4309    /// them into method-specific fields or materializes `inputResponses`.
4310    fn admit_final_mrtr_response_map(&self, params: Option<&serde_json::Value>) -> McpResult<()> {
4311        let Some(params) = params else {
4312            return Ok(());
4313        };
4314        admit_mrtr_raw_json_value(params, MAX_MRTR_RAW_PARAMS_BYTES)?;
4315        let Some(input_responses) = params
4316            .as_object()
4317            .and_then(|members| members.get("inputResponses"))
4318        else {
4319            return Ok(());
4320        };
4321        admit_mrtr_raw_json_value(input_responses, MAX_MRTR_RAW_INPUT_RESPONSES_BYTES)?;
4322        let Some(input_responses) = input_responses.as_object() else {
4323            return Ok(());
4324        };
4325        if input_responses.len() > self.mrtr_exchanges.max_inputs_per_round() {
4326            return Err(McpError::invalid_params(
4327                "MRTR inputResponses exceeds the configured bound",
4328            ));
4329        }
4330        Ok(())
4331    }
4332
4333    #[cfg(feature = "tasks")]
4334    fn admit_final_task_tool(&self, metadata: &OpenMetadata) -> McpResult<&FinalTaskRuntime> {
4335        require_final_tasks_capability(metadata)?;
4336        let runtime = self.final_task_runtime.as_ref().ok_or_else(|| {
4337            McpError::internal_error("task-capable tool requires an installed final Tasks runtime")
4338        })?;
4339        runtime.ensure_task_service_ready()?;
4340        Ok(runtime)
4341    }
4342
4343    fn issue_final_mrtr_input_required(
4344        &self,
4345        request_ctx: &McpContext,
4346        continuation_cancellation: fastmcp_core::McpRequestCancellation,
4347        binding: MrtrExchangeBinding,
4348        handler_result: InputRequiredResult,
4349    ) -> McpResult<serde_json::Value> {
4350        // The handler may describe the input it needs, but it never controls
4351        // requestState. Its former state member and open result siblings are
4352        // intentionally not forwarded across this framework boundary.
4353        let input_requests = handler_mrtr_input_requests(request_ctx, &handler_result)?;
4354        let required =
4355            self.mrtr_exchanges
4356                .issue_bound(continuation_cancellation, binding, input_requests)?;
4357        encode_mrtr_input_required_result(required)
4358    }
4359
4360    /// Resolves a final embedded-input retry before its method handler runs.
4361    ///
4362    /// Only a framework-issued state whose immutable operation binding still
4363    /// matches may yield resume inputs. A complete retry passes those typed
4364    /// values into the handler's resume-aware final hook exactly once.
4365    fn resolve_final_mrtr_retry(
4366        &self,
4367        request_state: Option<&str>,
4368        input_responses: Option<&FinalInputResponses>,
4369        binding: Option<&MrtrExchangeBinding>,
4370    ) -> McpResult<FinalMrtrDispatch> {
4371        match (request_state, input_responses) {
4372            (None, None) => Ok(FinalMrtrDispatch::Fresh),
4373            (Some(request_state), None) => {
4374                let binding = binding.ok_or_else(|| {
4375                    McpError::invalid_params("MRTR retries require session state")
4376                })?;
4377                match self
4378                    .mrtr_exchanges
4379                    .accept_state_only_bound(request_state, binding)?
4380                {
4381                    MrtrRetry::Complete(inputs) => Ok(FinalMrtrDispatch::Resume(inputs)),
4382                    MrtrRetry::InputRequired(_) => Err(McpError::internal_error(
4383                        "state-only MRTR retry cannot issue further input requests",
4384                    )),
4385                }
4386            }
4387            (Some(request_state), Some(input_responses)) => {
4388                let binding = binding.ok_or_else(|| {
4389                    McpError::invalid_params("MRTR retries require session state")
4390                })?;
4391                match self.mrtr_exchanges.accept_final_input_responses_bound(
4392                    request_state,
4393                    binding,
4394                    input_responses,
4395                )? {
4396                    MrtrRetry::Complete(inputs) => Ok(FinalMrtrDispatch::Resume(inputs)),
4397                    MrtrRetry::InputRequired(result) => encode_mrtr_input_required_result(result)
4398                        .map(FinalMrtrDispatch::InputRequired),
4399                }
4400            }
4401            _ => Err(McpError::invalid_params(
4402                "final MRTR inputResponses require requestState",
4403            )),
4404        }
4405    }
4406
4407    async fn dispatch_final_tools_call(
4408        &self,
4409        request_ctx: &McpContext,
4410        request_cx: &Cx,
4411        params: FinalCallToolParams,
4412        binding: Option<MrtrExchangeBinding>,
4413        resume_inputs: Option<MrtrCompletedInputs>,
4414        continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4415    ) -> McpResult<serde_json::Value> {
4416        #[cfg(feature = "tasks")]
4417        let request_metadata = params.meta.clone();
4418        let session_state = request_ctx
4419            .session_state()
4420            .cloned()
4421            .unwrap_or_else(SessionState::new);
4422        let outcome = self
4423            .handle_tools_call_final_in_request(
4424                request_ctx,
4425                request_cx,
4426                params,
4427                session_state,
4428                None,
4429                None,
4430                resume_inputs.as_ref(),
4431            )
4432            .await?;
4433        match outcome {
4434            FinalToolOutcome::Complete(result) => encode_final_tools_call_result(Ok(result)),
4435            FinalToolOutcome::InputRequired(result) => {
4436                let binding = binding.ok_or_else(|| {
4437                    McpError::internal_error(
4438                        "MRTR input_required requires session state to bind retries",
4439                    )
4440                })?;
4441                self.issue_final_mrtr_input_required(
4442                    request_ctx,
4443                    continuation_cancellation.clone(),
4444                    binding,
4445                    result,
4446                )
4447            }
4448            #[cfg(feature = "tasks")]
4449            FinalToolOutcome::CreateTask {
4450                work_descriptor,
4451                status_message,
4452            } => {
4453                #[cfg(all(feature = "proxy", feature = "tasks"))]
4454                if let Some(relay) = self.final_task_relay.as_ref() {
4455                    // A relayed task is already durable upstream. Decode its
4456                    // private carrier only after the downstream capability
4457                    // gate, retain the route-bound snapshot for controls, and
4458                    // emit the exact upstream handle without local creation.
4459                    require_final_tasks_capability(&request_metadata)?;
4460                    if let Some(result) = relay.admit_carried_task(&work_descriptor)? {
4461                        return encode_final_task_result(result);
4462                    }
4463                    return Err(McpError::internal_error(
4464                        "a proxy final Tasks relay received a non-relayed CreateTask outcome",
4465                    ));
4466                }
4467                // A handler's declaration means it may return CreateTask; it
4468                // does not turn its Complete or InputRequired outcomes into
4469                // Tasks operations. Admit only the branch that can mutate the
4470                // Tasks store, immediately before that mutation.
4471                let runtime = self.admit_final_task_tool(&request_metadata)?;
4472                encode_final_task_result(
4473                    runtime.create_task_with_work(work_descriptor, status_message)?,
4474                )
4475            }
4476        }
4477    }
4478
4479    async fn dispatch_final_resources_read(
4480        &self,
4481        request_ctx: &McpContext,
4482        request_cx: &Cx,
4483        params: FinalReadResourceParams,
4484        binding: Option<MrtrExchangeBinding>,
4485        resume_inputs: Option<MrtrCompletedInputs>,
4486        continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4487    ) -> McpResult<serde_json::Value> {
4488        let session_state = request_ctx
4489            .session_state()
4490            .cloned()
4491            .unwrap_or_else(SessionState::new);
4492        match self
4493            .handle_resources_read_final_in_request(
4494                request_ctx,
4495                request_cx,
4496                params,
4497                session_state,
4498                None,
4499                None,
4500                resume_inputs.as_ref(),
4501            )
4502            .await?
4503        {
4504            FinalMethodOutcome::Complete(result) => encode_final_resources_read_result(Ok(result)),
4505            FinalMethodOutcome::InputRequired(result) => {
4506                let binding = binding.ok_or_else(|| {
4507                    McpError::internal_error(
4508                        "MRTR input_required requires session state to bind retries",
4509                    )
4510                })?;
4511                self.issue_final_mrtr_input_required(
4512                    request_ctx,
4513                    continuation_cancellation.clone(),
4514                    binding,
4515                    result,
4516                )
4517            }
4518        }
4519    }
4520
4521    async fn dispatch_final_prompts_get(
4522        &self,
4523        request_ctx: &McpContext,
4524        request_cx: &Cx,
4525        params: FinalGetPromptParams,
4526        binding: Option<MrtrExchangeBinding>,
4527        resume_inputs: Option<MrtrCompletedInputs>,
4528        continuation_cancellation: &fastmcp_core::McpRequestCancellation,
4529    ) -> McpResult<serde_json::Value> {
4530        let session_state = request_ctx
4531            .session_state()
4532            .cloned()
4533            .unwrap_or_else(SessionState::new);
4534        match self
4535            .handle_prompts_get_final_in_request(
4536                request_ctx,
4537                request_cx,
4538                params,
4539                session_state,
4540                None,
4541                None,
4542                resume_inputs.as_ref(),
4543            )
4544            .await?
4545        {
4546            FinalMethodOutcome::Complete(result) => encode_final_prompts_get_result(Ok(result)),
4547            FinalMethodOutcome::InputRequired(result) => {
4548                let binding = binding.ok_or_else(|| {
4549                    McpError::internal_error(
4550                        "MRTR input_required requires session state to bind retries",
4551                    )
4552                })?;
4553                self.issue_final_mrtr_input_required(
4554                    request_ctx,
4555                    continuation_cancellation.clone(),
4556                    binding,
4557                    result,
4558                )
4559            }
4560        }
4561    }
4562
4563    /// Handles the tools/list request.
4564    ///
4565    /// If session_state is provided, disabled tools will be filtered out.
4566    /// If include_tags/exclude_tags are provided, tools are filtered by tags.
4567    pub fn handle_tools_list(
4568        &self,
4569        request_ctx: &McpContext,
4570        params: ListToolsParams,
4571        session_state: Option<&SessionState>,
4572    ) -> McpResult<ListToolsResult> {
4573        if let Some(error) = budget_error(request_ctx) {
4574            return Err(error);
4575        }
4576
4577        let tag_filters =
4578            TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4579        let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
4580            Some(&tag_filters)
4581        } else {
4582            None
4583        };
4584        let tools =
4585            crate::catch_extension_unwind(|| self.tools_filtered(session_state, tag_filters))
4586                .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "tool_definition"))?;
4587        let Some(page_size) = self.list_page_size else {
4588            return Ok(ListToolsResult {
4589                tools,
4590                next_cursor: None,
4591            });
4592        };
4593        let query = FinalCatalogQuery::from_tag_filters(
4594            params.include_tags.as_deref(),
4595            params.exclude_tags.as_deref(),
4596        );
4597        let (tools, next_cursor) = page_session_filtered_catalog(
4598            tools,
4599            params.cursor.as_deref(),
4600            Some(page_size),
4601            FinalCatalogKind::Tools,
4602            self.final_catalog_revision,
4603            &query,
4604            session_state.is_some(),
4605            |tool| tool.name.as_str(),
4606        )?;
4607        Ok(ListToolsResult { tools, next_cursor })
4608    }
4609
4610    /// Handles a final tools/list request using only final-admitted entries.
4611    /// Normal registration admits schemas before committing its handler, so a
4612    /// malformed candidate cannot influence a modern catalog page or cursor.
4613    fn handle_final_tools_list(
4614        &self,
4615        request_ctx: &McpContext,
4616        params: FinalListParams,
4617    ) -> McpResult<FinalListToolsResult> {
4618        if let Some(error) = budget_error(request_ctx) {
4619            return Err(error);
4620        }
4621
4622        let query = FinalCatalogQuery::from_final_list_params(&params);
4623        let tag_filters =
4624            TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4625        let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
4626            Some(&tag_filters)
4627        } else {
4628            None
4629        };
4630        let tools = crate::catch_extension_unwind(|| {
4631            self.tool_order
4632                .iter()
4633                .filter_map(|name| self.tools.get(name))
4634                .filter(|entry| entry.final_registration.is_some())
4635                .map(|entry| entry.definition.clone())
4636                .filter(|tool| tag_filters.is_none_or(|filters| filters.matches(&tool.tags)))
4637                .collect::<Vec<_>>()
4638        })
4639        .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "tool_definition"))?;
4640
4641        let (tools, next_cursor) = page_final_catalog(
4642            tools,
4643            params.cursor.as_deref(),
4644            self.list_page_size,
4645            FinalCatalogKind::Tools,
4646            self.final_catalog_revision,
4647            &query,
4648            None,
4649        )?;
4650        let result = ListToolsResult { tools, next_cursor };
4651        self.project_final_tools_list(request_ctx, result, self.final_cache_hints.clone())
4652    }
4653
4654    fn project_final_tools_list(
4655        &self,
4656        _request_ctx: &McpContext,
4657        result: ListToolsResult,
4658        cache_hints: FinalCacheHintPolicy,
4659    ) -> McpResult<FinalListToolsResult> {
4660        let tools = result
4661            .tools
4662            .into_iter()
4663            .map(|tool| {
4664                let entry = self.tools.get(&tool.name).ok_or_else(|| {
4665                    McpError::internal_error("listed tool is absent from the router catalog")
4666                })?;
4667                let final_registration = entry.final_registration.as_ref().ok_or_else(|| {
4668                    McpError::internal_error(
4669                        "legacy-only tool reached the final catalog projection",
4670                    )
4671                })?;
4672
4673                Ok(final_registration.final_definition.clone())
4674            })
4675            .collect::<McpResult<Vec<_>>>()?;
4676        Ok(FinalListToolsResult {
4677            tools,
4678            next_cursor: result.next_cursor,
4679            ttl_ms: cache_hints.list_ttl_ms,
4680            cache_scope: cache_hints.scope,
4681        })
4682    }
4683
4684    fn handle_final_resources_list(
4685        &self,
4686        request_ctx: &McpContext,
4687        params: FinalListParams,
4688    ) -> McpResult<FinalListResourcesResult> {
4689        if let Some(error) = budget_error(request_ctx) {
4690            return Err(error);
4691        }
4692        let query = FinalCatalogQuery::from_final_list_params(&params);
4693        let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4694        let filters =
4695            (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4696        let resources = self
4697            .resource_order
4698            .iter()
4699            .filter_map(|uri| self.final_resources.get(uri).map(|entry| (uri, entry)))
4700            .filter(|(_, entry)| {
4701                filters
4702                    .as_ref()
4703                    .is_none_or(|filters| filters.matches(&entry.tags))
4704            })
4705            .map(|(_, entry)| {
4706                admit_final_resource_uri(
4707                    entry.uri_use_policy,
4708                    &entry.definition.uri,
4709                    FinalResourceUriUse::CatalogResource,
4710                )?;
4711                Ok(entry.definition.clone())
4712            })
4713            .collect::<McpResult<Vec<_>>>()?;
4714        let (resources, next_cursor) = page_final_catalog(
4715            resources,
4716            params.cursor.as_deref(),
4717            self.list_page_size,
4718            FinalCatalogKind::Resources,
4719            self.final_catalog_revision,
4720            &query,
4721            None,
4722        )?;
4723        Ok(FinalListResourcesResult {
4724            resources,
4725            next_cursor,
4726            ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4727            cache_scope: self.final_cache_hints.scope,
4728        })
4729    }
4730
4731    fn handle_final_resource_templates_list(
4732        &self,
4733        request_ctx: &McpContext,
4734        params: FinalListParams,
4735    ) -> McpResult<FinalListResourceTemplatesResult> {
4736        if let Some(error) = budget_error(request_ctx) {
4737            return Err(error);
4738        }
4739        let query = FinalCatalogQuery::from_final_list_params(&params);
4740        let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4741        let filters =
4742            (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4743        let resource_templates = self
4744            .resource_template_order
4745            .iter()
4746            .filter_map(|key| self.resource_templates.get(key).map(|entry| (key, entry)))
4747            .filter_map(|(_, entry)| {
4748                entry
4749                    .final_definition
4750                    .as_ref()
4751                    .map(|definition| (definition, &entry.template.tags, entry.uri_use_policy))
4752            })
4753            .filter(|(_, tags, _)| filters.as_ref().is_none_or(|filters| filters.matches(tags)))
4754            .map(|(definition, _, uri_use_policy)| {
4755                admit_final_resource_template_uri(uri_use_policy, &definition.uri_template)?;
4756                Ok(definition.clone())
4757            })
4758            .collect::<McpResult<Vec<_>>>()?;
4759        let (resource_templates, next_cursor) = page_final_catalog(
4760            resource_templates,
4761            params.cursor.as_deref(),
4762            self.list_page_size,
4763            FinalCatalogKind::ResourceTemplates,
4764            self.final_catalog_revision,
4765            &query,
4766            None,
4767        )?;
4768        Ok(FinalListResourceTemplatesResult {
4769            resource_templates,
4770            next_cursor,
4771            ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4772            cache_scope: self.final_cache_hints.scope,
4773        })
4774    }
4775
4776    fn handle_final_prompts_list(
4777        &self,
4778        request_ctx: &McpContext,
4779        params: FinalListParams,
4780    ) -> McpResult<FinalListPromptsResult> {
4781        if let Some(error) = budget_error(request_ctx) {
4782            return Err(error);
4783        }
4784        let query = FinalCatalogQuery::from_final_list_params(&params);
4785        let filters = TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
4786        let filters =
4787            (params.include_tags.is_some() || params.exclude_tags.is_some()).then_some(filters);
4788        let prompts = self
4789            .prompt_order
4790            .iter()
4791            .filter_map(|name| self.final_prompts.get(name).map(|entry| (name, entry)))
4792            .filter(|(_, entry)| {
4793                filters
4794                    .as_ref()
4795                    .is_none_or(|filters| filters.matches(&entry.tags))
4796            })
4797            .map(|(_, entry)| entry.definition.clone())
4798            .collect();
4799        let (prompts, next_cursor) = page_final_catalog(
4800            prompts,
4801            params.cursor.as_deref(),
4802            self.list_page_size,
4803            FinalCatalogKind::Prompts,
4804            self.final_catalog_revision,
4805            &query,
4806            None,
4807        )?;
4808        Ok(FinalListPromptsResult {
4809            prompts,
4810            next_cursor,
4811            ttl_ms: self.final_cache_hints.list_ttl_ms.clone(),
4812            cache_scope: self.final_cache_hints.scope,
4813        })
4814    }
4815
4816    /// Handles the tools/call request.
4817    ///
4818    /// # Arguments
4819    ///
4820    /// * `request_ctx` - Request authority for cancellation, identity, auth, and accounting
4821    /// * `params` - The tool call parameters including tool name and arguments
4822    /// * `session_state` - Session state for per-session storage
4823    /// * `notification_sender` - Optional callback for sending progress notifications
4824    /// * `bidirectional_senders` - Optional senders for sampling/elicitation
4825    pub fn handle_tools_call(
4826        &self,
4827        request_ctx: &McpContext,
4828        params: CallToolParams,
4829        session_state: SessionState,
4830        notification_sender: Option<&NotificationSender>,
4831        bidirectional_senders: Option<&BidirectionalSenders>,
4832    ) -> McpResult<CallToolResult> {
4833        block_on(self.handle_tools_call_in_request(
4834            request_ctx,
4835            request_ctx.cx(),
4836            params,
4837            session_state,
4838            notification_sender,
4839            bidirectional_senders,
4840        ))
4841    }
4842
4843    pub(crate) async fn handle_tools_call_in_request(
4844        &self,
4845        request_ctx: &McpContext,
4846        request_cx: &Cx,
4847        params: CallToolParams,
4848        session_state: SessionState,
4849        notification_sender: Option<&NotificationSender>,
4850        bidirectional_senders: Option<&BidirectionalSenders>,
4851    ) -> McpResult<CallToolResult> {
4852        debug!(
4853            target: targets::HANDLER,
4854            "calling modern tool; tool_key={}; arguments_present={}",
4855            safe_log_label(&params.name),
4856            params.arguments.is_some()
4857        );
4858
4859        let dispatch_started_at = request_ctx.cx().now();
4860        if let Some(error) = budget_error(request_ctx) {
4861            return Err(error);
4862        }
4863        if !session_state.is_tool_enabled(&params.name) {
4864            return Err(McpError::new(
4865                McpErrorCode::MethodNotFound,
4866                format!("Tool '{}' is disabled for this session", params.name),
4867            ));
4868        }
4869
4870        let entry = self
4871            .tools
4872            .get(&params.name)
4873            .ok_or_else(|| McpError::method_not_found(&format!("tool: {}", params.name)))?;
4874        if !entry.legacy_enabled {
4875            return Err(McpError::method_not_found(&format!(
4876                "tool: {}",
4877                params.name
4878            )));
4879        }
4880        let handler = &entry.handler;
4881        let arguments = params.arguments.unwrap_or_else(|| serde_json::json!({}));
4882        let validation_result = if self.strict_input_validation {
4883            validate_strict(&entry.definition.input_schema, &arguments)
4884        } else {
4885            validate(&entry.definition.input_schema, &arguments)
4886        };
4887        if let Err(validation_errors) = validation_result {
4888            let error_messages: Vec<String> = validation_errors
4889                .iter()
4890                .map(|error| format!("{}: {}", error.path, error.message))
4891                .collect();
4892            return Err(McpError::invalid_params(format!(
4893                "Input validation failed: {}",
4894                error_messages.join("; ")
4895            )));
4896        }
4897
4898        let progress_marker = params
4899            .meta
4900            .as_ref()
4901            .and_then(|meta| meta.progress_marker.clone());
4902        let ctx = derive_handler_context(
4903            request_ctx,
4904            progress_marker,
4905            notification_sender,
4906            bidirectional_senders,
4907            ProtocolEra::Legacy2024,
4908        );
4909        let handler_timeout =
4910            read_handler_timeout(request_ctx.cx(), "tool_timeout", || handler.timeout())?;
4911        let effective_budget = compose_handler_budget(
4912            request_ctx.cx().budget(),
4913            request_ctx.budget(),
4914            handler_timeout,
4915            dispatch_started_at,
4916        );
4917        let ctx = ctx.with_operation_deadline(effective_budget.deadline);
4918        let outcome =
4919            run_handler_in_request(&ctx, request_cx, effective_budget, "tool", |child_cx| {
4920                handler.call_async_in_request(&ctx, child_cx, arguments)
4921            })
4922            .await?;
4923
4924        match outcome {
4925            Outcome::Ok(content) => Ok(CallToolResult {
4926                content: legacy_contents_from_handler(content)?,
4927                is_error: false,
4928                meta: None,
4929                additional: BTreeMap::new(),
4930            }),
4931            Outcome::Err(error) => {
4932                let error = sanitize_handler_error(request_ctx.cx(), "tool", error);
4933                if is_framework_terminal_tool_error(error.code) {
4934                    return Err(error);
4935                }
4936                Ok(CallToolResult {
4937                    content: vec![LegacyContent::Text {
4938                        text: error.message,
4939                        annotations: None,
4940                        additional: BTreeMap::new(),
4941                    }],
4942                    is_error: true,
4943                    meta: None,
4944                    additional: BTreeMap::new(),
4945                })
4946            }
4947            Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
4948            Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "tool")),
4949        }
4950    }
4951
4952    /// Handles one final MCP 2026-07-28 `tools/call` request.
4953    ///
4954    /// Legacy dispatch remains on [`Self::handle_tools_call`], including its
4955    /// exact `CallToolResult` behavior. Final dispatch calls the final handler
4956    /// hook directly and encodes the returned complete result with the typed
4957    /// core result codec.
4958    async fn handle_tools_call_final_in_request(
4959        &self,
4960        request_ctx: &McpContext,
4961        request_cx: &Cx,
4962        params: FinalCallToolParams,
4963        session_state: SessionState,
4964        notification_sender: Option<&NotificationSender>,
4965        bidirectional_senders: Option<&BidirectionalSenders>,
4966        resume_inputs: Option<&MrtrCompletedInputs>,
4967    ) -> McpResult<FinalToolOutcome> {
4968        debug!(
4969            target: targets::HANDLER,
4970            "calling final tool; tool_key={}; arguments_present={}",
4971            safe_log_label(&params.name),
4972            !params.arguments.is_absent()
4973        );
4974
4975        let dispatch_started_at = request_ctx.cx().now();
4976        if request_cx.is_cancel_requested() {
4977            return Err(McpError::request_cancelled());
4978        }
4979        if let Some(error) = budget_error(request_ctx) {
4980            return Err(error);
4981        }
4982        let progress_marker = final_progress_marker(&params.meta)?;
4983        // Modern connection state is attached to `request_ctx`. Stateless
4984        // callers still supply the explicit `session_state`, so both views
4985        // must admit the component before a final handler (or an MRTR state)
4986        // can be reached.
4987        if !session_state.is_tool_enabled(&params.name)
4988            || !request_ctx.is_tool_enabled(&params.name)
4989        {
4990            return Err(McpError::new(
4991                McpErrorCode::MethodNotFound,
4992                format!("Tool '{}' is disabled for this session", params.name),
4993            ));
4994        }
4995
4996        let entry = self
4997            .tools
4998            .get(&params.name)
4999            .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {}", params.name)))?;
5000        let final_registration = entry
5001            .final_registration
5002            .as_ref()
5003            .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {}", params.name)))?;
5004        let handler = &entry.handler;
5005        if handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none() {
5006            return Err(McpError::invalid_params(
5007                MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
5008            ));
5009        }
5010        let input_schema = final_registration.schemas.input.as_ref();
5011        let output_schema = final_registration.schemas.output.as_ref();
5012        // FinalArguments deserialization rejects an explicit null before this
5013        // point, so `arguments` is here always Absent or a typed value.
5014        #[cfg(feature = "tasks")]
5015        let declares_final_tasks = final_registration.declares_final_tasks;
5016        let arguments = params
5017            .arguments
5018            .into_value()
5019            .unwrap_or_else(|| serde_json::json!({}));
5020        let input_validation_failed = match input_schema {
5021            Some(schema) => {
5022                let validation = if self.strict_input_validation {
5023                    validate_strict(schema.schema(), &arguments)
5024                } else {
5025                    schema.validate(&arguments)
5026                };
5027                validation.is_err()
5028            }
5029            None if self.strict_input_validation => {
5030                // Upstream/proxy tools skip local schema admission so a valid
5031                // non-object catalog schema can be retained without inventing
5032                // an error payload. Gateway strict mode still refuses
5033                // additionalProperties against that catalog input schema.
5034                validate_strict(
5035                    &final_registration.final_definition.input_schema,
5036                    &arguments,
5037                )
5038                .is_err()
5039            }
5040            None => false,
5041        };
5042        if input_validation_failed {
5043            let mut result = crate::handler::promote_legacy_tool_content(vec![Content::text(
5044                "Tool arguments do not match the declared input schema.",
5045            )])?;
5046            result.payload.is_error = true;
5047            result.payload.structured_content = final_registration
5048                .schemas
5049                .errors
5050                .as_ref()
5051                .map(|errors| errors.input_validation.clone());
5052            return Ok(FinalToolOutcome::Complete(result));
5053        }
5054
5055        // A route-bound proxy asks its selected upstream to create a task
5056        // during this handler call. Unlike a local handler's deferred
5057        // `CreateTask`, that side effect cannot be rolled back after the
5058        // proxy returns. Require the exact downstream Tasks declaration
5059        // before invoking any task-capable proxy handler.
5060        #[cfg(all(feature = "proxy", feature = "tasks"))]
5061        if declares_final_tasks && self.final_task_relay.is_some() {
5062            require_final_tasks_capability(&params.meta)?;
5063        }
5064
5065        let ctx = derive_handler_context(
5066            request_ctx,
5067            progress_marker,
5068            notification_sender,
5069            bidirectional_senders,
5070            ProtocolEra::Modern2026,
5071        );
5072        let handler_timeout =
5073            read_handler_timeout(request_ctx.cx(), "tool_timeout", || handler.timeout())?;
5074        let effective_budget = compose_handler_budget(
5075            request_ctx.cx().budget(),
5076            request_ctx.budget(),
5077            handler_timeout,
5078            dispatch_started_at,
5079        );
5080        let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5081        let outcome =
5082            run_handler_in_request(&ctx, request_cx, effective_budget, "tool", |child_cx| {
5083                // MRTR-aware handlers receive every call through the resuming
5084                // hook: None marks the initial invocation, Some the admitted
5085                // retry. The default resuming hook forwards to the plain
5086                // final hook, so MRTR-unaware handlers are unaffected.
5087                handler.call_final_outcome_async_resuming_in_request(
5088                    &ctx,
5089                    child_cx,
5090                    arguments,
5091                    resume_inputs,
5092                )
5093            })
5094            .await?;
5095
5096        match outcome {
5097            Outcome::Ok(result) => {
5098                match &result {
5099                    FinalToolOutcome::Complete(result) => {
5100                        if let Some(output_schema) = output_schema {
5101                            let structured_content = result
5102                                .payload
5103                                .structured_content
5104                                .as_ref()
5105                                .ok_or_else(|| {
5106                                    McpError::internal_error(
5107                                        "tool output is missing structuredContent required by the declared output schema",
5108                                    )
5109                                })?;
5110                            if output_schema.validate(structured_content).is_err() {
5111                                return Err(McpError::internal_error(
5112                                    "tool output does not match the declared output schema",
5113                                ));
5114                            }
5115                        }
5116                    }
5117                    #[cfg(feature = "tasks")]
5118                    FinalToolOutcome::CreateTask { .. } if !declares_final_tasks => {
5119                        return Err(McpError::invalid_request(
5120                            "tool returned CreateTask without declaring final Tasks capability",
5121                        ));
5122                    }
5123                    FinalToolOutcome::InputRequired(_) => {}
5124                    #[cfg(feature = "tasks")]
5125                    FinalToolOutcome::CreateTask { .. } => {}
5126                }
5127                Ok(result)
5128            }
5129            Outcome::Err(error) => {
5130                let error = sanitize_handler_error(request_ctx.cx(), "tool", error);
5131                if is_framework_terminal_tool_error(error.code) {
5132                    return Err(error);
5133                }
5134                let mut result =
5135                    crate::handler::promote_legacy_tool_content(vec![Content::Text {
5136                        text: error.message,
5137                    }])?;
5138                result.payload.is_error = true;
5139                result.payload.structured_content = final_registration
5140                    .schemas
5141                    .errors
5142                    .as_ref()
5143                    .map(|errors| errors.handler.clone());
5144                Ok(FinalToolOutcome::Complete(result))
5145            }
5146            Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5147            Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "tool")),
5148        }
5149    }
5150
5151    /// Handles the resources/list request.
5152    ///
5153    /// If session_state is provided, disabled resources will be filtered out.
5154    /// If include_tags/exclude_tags are provided, resources are filtered by tags.
5155    pub fn handle_resources_list(
5156        &self,
5157        request_ctx: &McpContext,
5158        params: ListResourcesParams,
5159        session_state: Option<&SessionState>,
5160    ) -> McpResult<ListResourcesResult> {
5161        if let Some(error) = budget_error(request_ctx) {
5162            return Err(error);
5163        }
5164
5165        let tag_filters =
5166            TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5167        let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5168            Some(&tag_filters)
5169        } else {
5170            None
5171        };
5172        let resources = crate::catch_extension_unwind(|| {
5173            self.resources_filtered(session_state, tag_filters)
5174        })
5175        .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "resource_definition"))?;
5176        let Some(page_size) = self.list_page_size else {
5177            return Ok(ListResourcesResult {
5178                resources,
5179                next_cursor: None,
5180            });
5181        };
5182        let query = FinalCatalogQuery::from_tag_filters(
5183            params.include_tags.as_deref(),
5184            params.exclude_tags.as_deref(),
5185        );
5186        let (resources, next_cursor) = page_session_filtered_catalog(
5187            resources,
5188            params.cursor.as_deref(),
5189            Some(page_size),
5190            FinalCatalogKind::Resources,
5191            self.final_catalog_revision,
5192            &query,
5193            session_state.is_some(),
5194            |resource| resource.uri.as_str(),
5195        )?;
5196        Ok(ListResourcesResult {
5197            resources,
5198            next_cursor,
5199        })
5200    }
5201
5202    /// Handles the resources/templates/list request.
5203    ///
5204    /// If session_state is provided, disabled resource templates will be filtered out.
5205    /// If include_tags/exclude_tags are provided, templates are filtered by tags.
5206    pub fn handle_resource_templates_list(
5207        &self,
5208        request_ctx: &McpContext,
5209        params: ListResourceTemplatesParams,
5210        session_state: Option<&SessionState>,
5211    ) -> McpResult<ListResourceTemplatesResult> {
5212        if let Some(error) = budget_error(request_ctx) {
5213            return Err(error);
5214        }
5215
5216        let tag_filters =
5217            TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5218        let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5219            Some(&tag_filters)
5220        } else {
5221            None
5222        };
5223        let templates = self.resource_templates_filtered(session_state, tag_filters);
5224        let Some(page_size) = self.list_page_size else {
5225            return Ok(ListResourceTemplatesResult {
5226                resource_templates: templates,
5227                next_cursor: None,
5228            });
5229        };
5230        let query = FinalCatalogQuery::from_tag_filters(
5231            params.include_tags.as_deref(),
5232            params.exclude_tags.as_deref(),
5233        );
5234        let (resource_templates, next_cursor) = page_session_filtered_catalog(
5235            templates,
5236            params.cursor.as_deref(),
5237            Some(page_size),
5238            FinalCatalogKind::ResourceTemplates,
5239            self.final_catalog_revision,
5240            &query,
5241            session_state.is_some(),
5242            |template| template.uri_template.as_str(),
5243        )?;
5244        Ok(ListResourceTemplatesResult {
5245            resource_templates,
5246            next_cursor,
5247        })
5248    }
5249
5250    /// Handles the resources/read request.
5251    ///
5252    /// # Arguments
5253    ///
5254    /// * `request_ctx` - Request authority for cancellation, identity, auth, and accounting
5255    /// * `params` - The resource read parameters including URI
5256    /// * `session_state` - Session state for per-session storage
5257    /// * `notification_sender` - Optional callback for sending progress notifications
5258    /// * `bidirectional_senders` - Optional senders for sampling/elicitation
5259    pub fn handle_resources_read(
5260        &self,
5261        request_ctx: &McpContext,
5262        params: &ReadResourceParams,
5263        session_state: SessionState,
5264        notification_sender: Option<&NotificationSender>,
5265        bidirectional_senders: Option<&BidirectionalSenders>,
5266    ) -> McpResult<ReadResourceResult> {
5267        block_on(self.handle_resources_read_in_request(
5268            request_ctx,
5269            request_ctx.cx(),
5270            params,
5271            session_state,
5272            notification_sender,
5273            bidirectional_senders,
5274        ))
5275    }
5276
5277    pub(crate) async fn handle_resources_read_in_request(
5278        &self,
5279        request_ctx: &McpContext,
5280        request_cx: &Cx,
5281        params: &ReadResourceParams,
5282        session_state: SessionState,
5283        notification_sender: Option<&NotificationSender>,
5284        bidirectional_senders: Option<&BidirectionalSenders>,
5285    ) -> McpResult<ReadResourceResult> {
5286        debug!(
5287            target: targets::HANDLER,
5288            "reading modern resource; resource_key={}",
5289            safe_log_label(&params.uri)
5290        );
5291
5292        let dispatch_started_at = request_ctx.cx().now();
5293        if let Some(error) = budget_error(request_ctx) {
5294            return Err(error);
5295        }
5296        if !session_state.is_resource_enabled(&params.uri) {
5297            return Err(McpError::new(
5298                McpErrorCode::ResourceNotFound,
5299                format!("Resource '{}' is disabled for this session", params.uri),
5300            ));
5301        }
5302
5303        let resolved = self
5304            .resolve_resource_for_era(&params.uri, Some(ProtocolEra::Legacy2024))
5305            .ok_or_else(|| McpError::resource_not_found(&params.uri))?;
5306        if !resolved.legacy_enabled {
5307            return Err(McpError::resource_not_found(&params.uri));
5308        }
5309        let progress_marker = params
5310            .meta
5311            .as_ref()
5312            .and_then(|meta| meta.progress_marker.clone());
5313        let ctx = derive_handler_context(
5314            request_ctx,
5315            progress_marker,
5316            notification_sender,
5317            bidirectional_senders,
5318            ProtocolEra::Legacy2024,
5319        );
5320        let handler_timeout = read_handler_timeout(request_ctx.cx(), "resource_timeout", || {
5321            resolved.handler.timeout()
5322        })?;
5323        let effective_budget = compose_handler_budget(
5324            request_ctx.cx().budget(),
5325            request_ctx.budget(),
5326            handler_timeout,
5327            dispatch_started_at,
5328        );
5329        let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5330        let outcome =
5331            run_handler_in_request(&ctx, request_cx, effective_budget, "resource", |child_cx| {
5332                resolved.handler.read_async_with_uri_in_request(
5333                    &ctx,
5334                    child_cx,
5335                    &params.uri,
5336                    &resolved.params,
5337                )
5338            })
5339            .await?;
5340
5341        let contents = match outcome {
5342            Outcome::Ok(contents) => contents,
5343            Outcome::Err(error) => {
5344                return Err(sanitize_handler_error(request_ctx.cx(), "resource", error));
5345            }
5346            Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
5347            Outcome::Panicked(_payload) => {
5348                return Err(sanitized_handler_panic(request_ctx.cx(), "resource"));
5349            }
5350        };
5351
5352        Ok(ReadResourceResult {
5353            contents: legacy_resource_contents_from_handler(contents)?,
5354            meta: None,
5355            additional: BTreeMap::new(),
5356        })
5357    }
5358
5359    /// Handles one final MCP 2026-07-28 `resources/read` request.
5360    ///
5361    /// Legacy dispatch remains on [`Self::handle_resources_read`], including
5362    /// its exact `ReadResourceResult` shape. Final dispatch calls the final
5363    /// handler hook directly so embedded resource metadata, open fields, and
5364    /// cache hints are not projected through the legacy resource surface.
5365    async fn handle_resources_read_final_in_request(
5366        &self,
5367        request_ctx: &McpContext,
5368        request_cx: &Cx,
5369        params: FinalReadResourceParams,
5370        session_state: SessionState,
5371        notification_sender: Option<&NotificationSender>,
5372        bidirectional_senders: Option<&BidirectionalSenders>,
5373        resume_inputs: Option<&MrtrCompletedInputs>,
5374    ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
5375        let progress_marker = final_progress_marker(&params.meta)?;
5376        let uri = params.uri.as_str();
5377        debug!(
5378            target: targets::HANDLER,
5379            "reading final resource; resource_key={}",
5380            safe_log_label(uri)
5381        );
5382
5383        let dispatch_started_at = request_ctx.cx().now();
5384        if request_cx.is_cancel_requested() {
5385            return Err(McpError::request_cancelled());
5386        }
5387        if let Some(error) = budget_error(request_ctx) {
5388            return Err(error);
5389        }
5390        // See the corresponding final tools/call check: a modern connection's
5391        // durable component state lives on the request context.
5392        if !session_state.is_resource_enabled(uri) || !request_ctx.is_resource_enabled(uri) {
5393            return Err(McpError::new(
5394                McpErrorCode::ResourceNotFound,
5395                format!("Resource '{uri}' is disabled for this session"),
5396            ));
5397        }
5398
5399        let resolved = self
5400            .resolve_resource_for_era(uri, Some(ProtocolEra::Modern2026))
5401            .ok_or_else(|| {
5402                McpError::with_data(
5403                    McpErrorCode::InvalidParams,
5404                    "Resource not found",
5405                    serde_json::json!({"uri": uri}),
5406                )
5407            })?;
5408        if !resolved.final_enabled {
5409            return Err(McpError::invalid_params(
5410                "resource is registered only for exact MCP 2024-11-05 dispatch",
5411            ));
5412        }
5413        if resolved.handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none()
5414        {
5415            return Err(McpError::invalid_params(
5416                MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
5417            ));
5418        }
5419        admit_final_resource_uri(
5420            resolved.uri_use_policy,
5421            &params.uri,
5422            FinalResourceUriUse::ResourceReadTarget,
5423        )?;
5424        let cache_hint_provenance = crate::catch_extension_unwind(|| {
5425            resolved.handler.final_resource_read_cache_hint_provenance()
5426        })
5427        .map_err(|_payload| {
5428            McpError::internal_error("resource cache-hint provenance hook panicked during dispatch")
5429        })?;
5430        let ctx = derive_handler_context(
5431            request_ctx,
5432            progress_marker,
5433            notification_sender,
5434            bidirectional_senders,
5435            ProtocolEra::Modern2026,
5436        );
5437        let handler_timeout = read_handler_timeout(request_ctx.cx(), "resource_timeout", || {
5438            resolved.handler.timeout()
5439        })?;
5440        let effective_budget = compose_handler_budget(
5441            request_ctx.cx().budget(),
5442            request_ctx.budget(),
5443            handler_timeout,
5444            dispatch_started_at,
5445        );
5446        let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5447        let outcome =
5448            run_handler_in_request(&ctx, request_cx, effective_budget, "resource", |child_cx| {
5449                if let Some(resume_inputs) = resume_inputs {
5450                    resolved
5451                        .handler
5452                        .read_final_outcome_async_with_uri_resuming_in_request(
5453                            &ctx,
5454                            child_cx,
5455                            uri,
5456                            &resolved.params,
5457                            Some(resume_inputs),
5458                        )
5459                } else {
5460                    resolved
5461                        .handler
5462                        .read_final_outcome_async_with_uri_in_request(
5463                            &ctx,
5464                            child_cx,
5465                            uri,
5466                            &resolved.params,
5467                        )
5468                }
5469            })
5470            .await?;
5471
5472        match outcome {
5473            Outcome::Ok(mut result) => {
5474                admit_final_resource_read_outcome(resolved.uri_use_policy, &result)?;
5475                // Provenance, not equality with a wire value, determines
5476                // whether router policy owns these hints. An explicit final
5477                // handler may intentionally choose the same values as the
5478                // legacy bridge and must still retain them unchanged.
5479                if let FinalMethodOutcome::Complete(complete) = &mut result
5480                    && cache_hint_provenance == FinalResourceReadCacheHintProvenance::RouterPolicy
5481                {
5482                    complete.payload.ttl_ms = self.final_cache_hints.resource_read_ttl_ms.clone();
5483                    complete.payload.cache_scope = self.final_cache_hints.scope;
5484                }
5485                Ok(result)
5486            }
5487            Outcome::Err(error) => Err(sanitize_handler_error(request_ctx.cx(), "resource", error)),
5488            Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5489            Outcome::Panicked(_payload) => {
5490                Err(sanitized_handler_panic(request_ctx.cx(), "resource"))
5491            }
5492        }
5493    }
5494
5495    /// Handles the prompts/list request.
5496    ///
5497    /// If session_state is provided, disabled prompts will be filtered out.
5498    /// If include_tags/exclude_tags are provided, prompts are filtered by tags.
5499    pub fn handle_prompts_list(
5500        &self,
5501        request_ctx: &McpContext,
5502        params: ListPromptsParams,
5503        session_state: Option<&SessionState>,
5504    ) -> McpResult<ListPromptsResult> {
5505        if let Some(error) = budget_error(request_ctx) {
5506            return Err(error);
5507        }
5508
5509        let tag_filters =
5510            TagFilters::new(params.include_tags.as_ref(), params.exclude_tags.as_ref());
5511        let tag_filters = if params.include_tags.is_some() || params.exclude_tags.is_some() {
5512            Some(&tag_filters)
5513        } else {
5514            None
5515        };
5516        let prompts =
5517            crate::catch_extension_unwind(|| self.prompts_filtered(session_state, tag_filters))
5518                .map_err(|_payload| {
5519                    sanitized_handler_panic(request_ctx.cx(), "prompt_definition")
5520                })?;
5521        let Some(page_size) = self.list_page_size else {
5522            return Ok(ListPromptsResult {
5523                prompts,
5524                next_cursor: None,
5525            });
5526        };
5527        let query = FinalCatalogQuery::from_tag_filters(
5528            params.include_tags.as_deref(),
5529            params.exclude_tags.as_deref(),
5530        );
5531        let (prompts, next_cursor) = page_session_filtered_catalog(
5532            prompts,
5533            params.cursor.as_deref(),
5534            Some(page_size),
5535            FinalCatalogKind::Prompts,
5536            self.final_catalog_revision,
5537            &query,
5538            session_state.is_some(),
5539            |prompt| prompt.name.as_str(),
5540        )?;
5541        Ok(ListPromptsResult {
5542            prompts,
5543            next_cursor,
5544        })
5545    }
5546
5547    /// Handles the prompts/get request.
5548    ///
5549    /// # Arguments
5550    ///
5551    /// * `request_ctx` - Request authority for cancellation, identity, auth, and accounting
5552    /// * `params` - The prompt get parameters including name and arguments
5553    /// * `session_state` - Session state for per-session storage
5554    /// * `notification_sender` - Optional callback for sending progress notifications
5555    /// * `bidirectional_senders` - Optional senders for sampling/elicitation
5556    pub fn handle_prompts_get(
5557        &self,
5558        request_ctx: &McpContext,
5559        params: GetPromptParams,
5560        session_state: SessionState,
5561        notification_sender: Option<&NotificationSender>,
5562        bidirectional_senders: Option<&BidirectionalSenders>,
5563    ) -> McpResult<GetPromptResult> {
5564        block_on(self.handle_prompts_get_in_request(
5565            request_ctx,
5566            request_ctx.cx(),
5567            params,
5568            session_state,
5569            notification_sender,
5570            bidirectional_senders,
5571        ))
5572    }
5573
5574    pub(crate) async fn handle_prompts_get_in_request(
5575        &self,
5576        request_ctx: &McpContext,
5577        request_cx: &Cx,
5578        params: GetPromptParams,
5579        session_state: SessionState,
5580        notification_sender: Option<&NotificationSender>,
5581        bidirectional_senders: Option<&BidirectionalSenders>,
5582    ) -> McpResult<GetPromptResult> {
5583        debug!(
5584            target: targets::HANDLER,
5585            "getting modern prompt; prompt_key={}; arguments_present={}",
5586            safe_log_label(&params.name),
5587            params.arguments.is_some()
5588        );
5589
5590        let dispatch_started_at = request_ctx.cx().now();
5591        if let Some(error) = budget_error(request_ctx) {
5592            return Err(error);
5593        }
5594        if !session_state.is_prompt_enabled(&params.name) {
5595            return Err(McpError::new(
5596                McpErrorCode::PromptNotFound,
5597                format!("Prompt '{}' is disabled for this session", params.name),
5598            ));
5599        }
5600
5601        let handler = self.prompts.get(&params.name).ok_or_else(|| {
5602            McpError::new(
5603                McpErrorCode::PromptNotFound,
5604                format!("Prompt not found: {}", params.name),
5605            )
5606        })?;
5607        if self.final_only_prompts.contains(&params.name) {
5608            return Err(McpError::new(
5609                McpErrorCode::PromptNotFound,
5610                format!("Prompt not found: {}", params.name),
5611            ));
5612        }
5613        let description = crate::catch_extension_unwind(|| handler.definition().description)
5614            .map_err(|_payload| sanitized_handler_panic(request_ctx.cx(), "prompt_definition"))?;
5615        let progress_marker = params
5616            .meta
5617            .as_ref()
5618            .and_then(|meta| meta.progress_marker.clone());
5619        let ctx = derive_handler_context(
5620            request_ctx,
5621            progress_marker,
5622            notification_sender,
5623            bidirectional_senders,
5624            ProtocolEra::Legacy2024,
5625        );
5626        let handler_timeout =
5627            read_handler_timeout(request_ctx.cx(), "prompt_timeout", || handler.timeout())?;
5628        let effective_budget = compose_handler_budget(
5629            request_ctx.cx().budget(),
5630            request_ctx.budget(),
5631            handler_timeout,
5632            dispatch_started_at,
5633        );
5634        let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5635        let arguments = params.arguments.unwrap_or_default();
5636        let outcome =
5637            run_handler_in_request(&ctx, request_cx, effective_budget, "prompt", |child_cx| {
5638                handler.get_async_in_request(&ctx, child_cx, arguments)
5639            })
5640            .await?;
5641
5642        let messages = match outcome {
5643            Outcome::Ok(messages) => messages,
5644            Outcome::Err(error) => {
5645                return Err(sanitize_handler_error(request_ctx.cx(), "prompt", error));
5646            }
5647            Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
5648            Outcome::Panicked(_payload) => {
5649                return Err(sanitized_handler_panic(request_ctx.cx(), "prompt"));
5650            }
5651        };
5652
5653        Ok(GetPromptResult {
5654            description,
5655            messages: legacy_prompt_messages_from_handler(messages)?,
5656            meta: None,
5657            additional: BTreeMap::new(),
5658        })
5659    }
5660
5661    /// Handles one final MCP 2026-07-28 `prompts/get` request.
5662    ///
5663    /// Legacy dispatch remains on [`Self::handle_prompts_get`], including its
5664    /// exact `GetPromptResult` projection. Final dispatch calls the final
5665    /// handler hook directly so handler-authored final content and complete
5666    /// result metadata never pass through the legacy prompt surface.
5667    async fn handle_prompts_get_final_in_request(
5668        &self,
5669        request_ctx: &McpContext,
5670        request_cx: &Cx,
5671        params: FinalGetPromptParams,
5672        session_state: SessionState,
5673        notification_sender: Option<&NotificationSender>,
5674        bidirectional_senders: Option<&BidirectionalSenders>,
5675        resume_inputs: Option<&MrtrCompletedInputs>,
5676    ) -> McpResult<FinalMethodOutcome<FinalGetPromptResult>> {
5677        debug!(
5678            target: targets::HANDLER,
5679            "getting final prompt; prompt_key={}; arguments_present={}",
5680            safe_log_label(&params.name),
5681            !params.arguments.is_absent()
5682        );
5683
5684        let dispatch_started_at = request_ctx.cx().now();
5685        if request_cx.is_cancel_requested() {
5686            return Err(McpError::request_cancelled());
5687        }
5688        if let Some(error) = budget_error(request_ctx) {
5689            return Err(error);
5690        }
5691        let progress_marker = final_progress_marker(&params.meta)?;
5692        // See the corresponding final tools/call check: a modern connection's
5693        // durable component state lives on the request context.
5694        if !session_state.is_prompt_enabled(&params.name)
5695            || !request_ctx.is_prompt_enabled(&params.name)
5696        {
5697            return Err(McpError::new(
5698                McpErrorCode::PromptNotFound,
5699                format!("Prompt '{}' is disabled for this session", params.name),
5700            ));
5701        }
5702
5703        let handler = self
5704            .prompts
5705            .get(&params.name)
5706            .ok_or_else(|| McpError::invalid_params(format!("Unknown prompt: {}", params.name)))?;
5707        let final_registration = self.final_prompts.get(&params.name).ok_or_else(|| {
5708            McpError::invalid_params("prompt is registered only for exact MCP 2024-11-05 dispatch")
5709        })?;
5710        if handler.declares_final_mrtr() && request_ctx.session_cache_partition().is_none() {
5711            return Err(McpError::invalid_params(
5712                MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
5713            ));
5714        }
5715        // FinalArguments deserialization rejects an explicit null before this
5716        // point, so `arguments` is here always Absent or a typed value.
5717        let arguments = params.arguments.into_value().unwrap_or_default();
5718        admit_declared_final_prompt_arguments(
5719            final_registration
5720                .definition
5721                .arguments
5722                .as_deref()
5723                .unwrap_or_default(),
5724            arguments.keys(),
5725        )?;
5726        let ctx = derive_handler_context(
5727            request_ctx,
5728            progress_marker,
5729            notification_sender,
5730            bidirectional_senders,
5731            ProtocolEra::Modern2026,
5732        );
5733        let handler_timeout =
5734            read_handler_timeout(request_ctx.cx(), "prompt_timeout", || handler.timeout())?;
5735        let effective_budget = compose_handler_budget(
5736            request_ctx.cx().budget(),
5737            request_ctx.budget(),
5738            handler_timeout,
5739            dispatch_started_at,
5740        );
5741        let ctx = ctx.with_operation_deadline(effective_budget.deadline);
5742        let arguments = arguments.into_iter().collect();
5743        let outcome =
5744            run_handler_in_request(&ctx, request_cx, effective_budget, "prompt", |child_cx| {
5745                if let Some(resume_inputs) = resume_inputs {
5746                    handler.get_final_outcome_async_resuming_in_request(
5747                        &ctx,
5748                        child_cx,
5749                        arguments,
5750                        Some(resume_inputs),
5751                    )
5752                } else {
5753                    handler.get_final_outcome_async_in_request(&ctx, child_cx, arguments)
5754                }
5755            })
5756            .await?;
5757
5758        match outcome {
5759            Outcome::Ok(result) => {
5760                admit_final_prompt_outcome(final_registration.uri_use_policy, &result)?;
5761                Ok(result)
5762            }
5763            Outcome::Err(error) => Err(sanitize_handler_error(request_ctx.cx(), "prompt", error)),
5764            Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
5765            Outcome::Panicked(_payload) => Err(sanitized_handler_panic(request_ctx.cx(), "prompt")),
5766        }
5767    }
5768}
5769
5770impl Default for Router {
5771    fn default() -> Self {
5772        Self::new()
5773    }
5774}
5775
5776// ============================================================================
5777// Mount/Composition Support
5778// ============================================================================
5779
5780/// Result of a mount operation.
5781#[derive(Debug, Default)]
5782pub struct MountResult {
5783    /// Number of tools mounted.
5784    pub tools: usize,
5785    /// Number of resources mounted.
5786    pub resources: usize,
5787    /// Number of resource templates mounted.
5788    pub resource_templates: usize,
5789    /// Number of prompts mounted.
5790    pub prompts: usize,
5791    /// Any warnings generated during mounting (e.g., name conflicts).
5792    pub warnings: Vec<String>,
5793    /// Errors that caused the mount operation to be rejected.
5794    ///
5795    /// A rejected mount does not mutate the destination router.
5796    pub errors: Vec<String>,
5797}
5798
5799impl MountResult {
5800    /// Returns true if any components were mounted.
5801    #[must_use]
5802    pub fn has_components(&self) -> bool {
5803        self.tools > 0 || self.resources > 0 || self.resource_templates > 0 || self.prompts > 0
5804    }
5805
5806    /// Returns true if mounting was not rejected.
5807    #[must_use]
5808    pub fn is_success(&self) -> bool {
5809        self.errors.is_empty()
5810    }
5811
5812    fn merge(&mut self, other: Self) {
5813        self.tools += other.tools;
5814        self.resources += other.resources;
5815        self.resource_templates += other.resource_templates;
5816        self.prompts += other.prompts;
5817        self.warnings.extend(other.warnings);
5818        self.errors.extend(other.errors);
5819    }
5820}
5821
5822#[derive(Clone, Copy)]
5823enum MountSelection {
5824    All,
5825    Tools,
5826    Resources,
5827    Prompts,
5828}
5829
5830impl MountSelection {
5831    const fn includes_tools(self) -> bool {
5832        matches!(self, Self::All | Self::Tools)
5833    }
5834
5835    const fn includes_resources(self) -> bool {
5836        matches!(self, Self::All | Self::Resources)
5837    }
5838
5839    const fn includes_prompts(self) -> bool {
5840        matches!(self, Self::All | Self::Prompts)
5841    }
5842}
5843
5844impl Router {
5845    /// Applies a prefix to a name or URI.
5846    fn apply_prefix(name: &str, prefix: Option<&str>) -> String {
5847        match prefix {
5848            Some(p) if !p.is_empty() => format!("{}/{}", p, name),
5849            _ => name.to_string(),
5850        }
5851    }
5852
5853    /// Returns whether mounting preserves component keys exactly.
5854    ///
5855    /// `Some("")` is deliberately equivalent to no prefix: [`Self::apply_prefix`]
5856    /// leaves every key byte-for-byte unchanged in both cases. Final route
5857    /// projection and collision admission must use this rule rather than
5858    /// distinguishing the two `Option` representations.
5859    fn prefix_preserves_keys(prefix: Option<&str>) -> bool {
5860        prefix.is_none_or(str::is_empty)
5861    }
5862
5863    /// Validates a prefix string.
5864    ///
5865    /// Prefixes must be alphanumeric plus underscores and hyphens,
5866    /// and cannot contain slashes.
5867    fn validate_prefix(prefix: &str) -> Result<(), String> {
5868        if prefix.is_empty() {
5869            return Ok(());
5870        }
5871        if prefix.contains('/') {
5872            return Err("Invalid mount prefix: slashes are not permitted".to_string());
5873        }
5874        // Allow alphanumeric, underscore, hyphen
5875        for ch in prefix.chars() {
5876            if !ch.is_alphanumeric() && ch != '_' && ch != '-' {
5877                return Err(
5878                    "Invalid mount prefix: invalid characters are not permitted".to_string()
5879                );
5880            }
5881        }
5882        Ok(())
5883    }
5884
5885    fn mount_preflight(
5886        &self,
5887        other: &Self,
5888        prefix: Option<&str>,
5889        behavior: crate::DuplicateBehavior,
5890        selection: MountSelection,
5891    ) -> MountResult {
5892        let mut result = MountResult::default();
5893
5894        if let Some(prefix) = prefix {
5895            if let Err(error) = Self::validate_prefix(prefix) {
5896                // Keep the warning for callers of the original API while also
5897                // recording the rejection as a real failure.
5898                result.warnings.push(error.clone());
5899                result.errors.push(error);
5900                return result;
5901            }
5902        }
5903
5904        if behavior == crate::DuplicateBehavior::Error {
5905            let mut conflicts = Vec::new();
5906            if selection.includes_tools() {
5907                for name in other.tools.keys() {
5908                    let mounted_name = Self::apply_prefix(name, prefix);
5909                    if self.tools.contains_key(&mounted_name) {
5910                        conflicts.push(("Tool", mounted_name));
5911                    }
5912                }
5913            }
5914            if selection.includes_resources() {
5915                for uri in other.resources.keys() {
5916                    let mounted_uri = Self::apply_prefix(uri, prefix);
5917                    if self.resources.contains_key(&mounted_uri) {
5918                        conflicts.push(("Resource", mounted_uri));
5919                    }
5920                }
5921                for uri_template in other.resource_templates.keys() {
5922                    let mounted_uri_template = Self::apply_prefix(uri_template, prefix);
5923                    if self.resource_templates.contains_key(&mounted_uri_template) {
5924                        conflicts.push(("Resource template", mounted_uri_template));
5925                    }
5926                }
5927            }
5928            if selection.includes_prompts() {
5929                for name in other.prompts.keys() {
5930                    let mounted_name = Self::apply_prefix(name, prefix);
5931                    if self.prompts.contains_key(&mounted_name) {
5932                        conflicts.push(("Prompt", mounted_name));
5933                    }
5934                }
5935            }
5936
5937            conflicts.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(&b.1)));
5938            result
5939                .errors
5940                .extend(conflicts.into_iter().map(|(kind, key)| {
5941                    format!(
5942                        "Mount rejected because {kind} already exists; component_key={}",
5943                        safe_log_label(&key)
5944                    )
5945                }));
5946        }
5947
5948        self.preflight_final_resource_route_collisions(
5949            other,
5950            prefix,
5951            behavior,
5952            selection,
5953            &mut result,
5954        );
5955        self.preflight_mcp_apps_mount_bindings(other, prefix, behavior, selection, &mut result);
5956        result
5957    }
5958
5959    /// Projects the final resource routes that a mount would leave in the
5960    /// destination, then refuses ambiguous exact/template or template/template
5961    /// languages before consuming any source handlers. A nonempty prefix
5962    /// produces legacy-only resources, so it cannot introduce a final route
5963    /// collision.
5964    fn preflight_final_resource_route_collisions(
5965        &self,
5966        other: &Self,
5967        prefix: Option<&str>,
5968        behavior: crate::DuplicateBehavior,
5969        selection: MountSelection,
5970        result: &mut MountResult,
5971    ) {
5972        if !selection.includes_resources() {
5973            return;
5974        }
5975
5976        let mut final_resources: HashSet<String> = self.final_resources.keys().cloned().collect();
5977        let mut final_templates: HashMap<String, ReversibleResourceTemplate> = self
5978            .resource_templates
5979            .iter()
5980            .filter_map(|(uri_template, entry)| {
5981                entry
5982                    .final_definition
5983                    .as_ref()
5984                    .zip(entry.matcher.as_ref())
5985                    .map(|(_, matcher)| (uri_template.clone(), matcher.clone()))
5986            })
5987            .collect();
5988
5989        for uri in other.resources.keys() {
5990            let mounted_uri = Self::apply_prefix(uri, prefix);
5991            let replaces_destination = !self.resources.contains_key(&mounted_uri)
5992                || behavior == crate::DuplicateBehavior::Replace;
5993            if !replaces_destination {
5994                continue;
5995            }
5996            if Self::prefix_preserves_keys(prefix) && other.final_resources.contains_key(uri) {
5997                final_resources.insert(mounted_uri);
5998            } else {
5999                final_resources.remove(&mounted_uri);
6000            }
6001        }
6002
6003        for (uri_template, entry) in &other.resource_templates {
6004            let mounted_uri_template = Self::apply_prefix(uri_template, prefix);
6005            let replaces_destination = !self.resource_templates.contains_key(&mounted_uri_template)
6006                || behavior == crate::DuplicateBehavior::Replace;
6007            if !replaces_destination {
6008                continue;
6009            }
6010            if Self::prefix_preserves_keys(prefix)
6011                && let Some(matcher) = entry
6012                    .final_definition
6013                    .as_ref()
6014                    .zip(entry.matcher.as_ref())
6015                    .map(|(_, matcher)| matcher.clone())
6016            {
6017                final_templates.insert(mounted_uri_template, matcher);
6018            } else {
6019                final_templates.remove(&mounted_uri_template);
6020            }
6021        }
6022
6023        let mut template_routes: Vec<_> = final_templates.iter().collect();
6024        template_routes.sort_unstable_by_key(|(uri, _)| *uri);
6025        let mut exact_routes: Vec<_> = final_resources.iter().collect();
6026        exact_routes.sort_unstable();
6027        let mut collisions = Vec::new();
6028
6029        for (template_uri, matcher) in &template_routes {
6030            for exact_uri in &exact_routes {
6031                match matcher.match_uri(exact_uri) {
6032                    Ok(Some(_)) => collisions.push(format!(
6033                        "Mount rejected because a final resource template collides with an exact final resource; template_key={}; resource_key={}",
6034                        safe_log_label(template_uri),
6035                        safe_log_label(exact_uri)
6036                    )),
6037                    Ok(None) => {}
6038                    Err(_) => collisions.push(format!(
6039                        "Mount rejected because a final resource template lost match admission; template_key={}",
6040                        safe_log_label(template_uri)
6041                    )),
6042                }
6043            }
6044        }
6045
6046        for (index, (left_uri, left)) in template_routes.iter().enumerate() {
6047            for (right_uri, right) in template_routes.iter().skip(index + 1) {
6048                match reversible_templates_may_overlap(left, right) {
6049                    Ok(true) => collisions.push(format!(
6050                        "Mount rejected because final resource template languages collide; left_template_key={}; right_template_key={}",
6051                        safe_log_label(left_uri),
6052                        safe_log_label(right_uri)
6053                    )),
6054                    Ok(false) => {}
6055                    Err(_) => collisions.push(format!(
6056                        "Mount rejected because a final resource template lost match admission; template_key={}",
6057                        safe_log_label(left_uri)
6058                    )),
6059                }
6060            }
6061        }
6062
6063        collisions.sort_unstable();
6064        collisions.dedup();
6065        result.errors.extend(collisions);
6066    }
6067
6068    fn preflight_mcp_apps_mount_bindings(
6069        &self,
6070        other: &Self,
6071        prefix: Option<&str>,
6072        behavior: crate::DuplicateBehavior,
6073        selection: MountSelection,
6074        result: &mut MountResult,
6075    ) {
6076        let mut final_resources: HashMap<String, FinalResource> = self
6077            .final_resources
6078            .iter()
6079            .map(|(uri, registration)| (uri.clone(), registration.definition.clone()))
6080            .collect();
6081        let mut final_tools: HashMap<String, FinalTool> = self
6082            .tools
6083            .iter()
6084            .filter_map(|(name, registration)| {
6085                registration
6086                    .final_registration
6087                    .as_ref()
6088                    .map(|final_registration| {
6089                        (name.clone(), final_registration.final_definition.clone())
6090                    })
6091            })
6092            .collect();
6093
6094        if selection.includes_resources() {
6095            for uri in other.resources.keys() {
6096                let mounted_uri = Self::apply_prefix(uri, prefix);
6097                let replacing = !self.resources.contains_key(&mounted_uri)
6098                    || matches!(behavior, crate::DuplicateBehavior::Replace);
6099                if !replacing {
6100                    continue;
6101                }
6102                let source_final = other.final_resources.get(uri);
6103                if !Self::prefix_preserves_keys(prefix)
6104                    && source_final.is_some_and(|registration| {
6105                        Self::mcp_apps_ui_resource_requires_special_admission(
6106                            &registration.definition,
6107                        )
6108                        .unwrap_or(true)
6109                    })
6110                {
6111                    result.errors.push(format!(
6112                        "Mount rejected because a final-only MCP Apps UI resource cannot be prefixed; resource_key={}",
6113                        safe_log_label(uri)
6114                    ));
6115                    continue;
6116                }
6117                if Self::prefix_preserves_keys(prefix) {
6118                    if let Some(registration) = source_final {
6119                        final_resources.insert(mounted_uri, registration.definition.clone());
6120                    } else {
6121                        final_resources.remove(&mounted_uri);
6122                    }
6123                } else {
6124                    final_resources.remove(&mounted_uri);
6125                }
6126            }
6127        }
6128
6129        if selection.includes_tools() {
6130            for (name, registration) in &other.tools {
6131                let mounted_name = Self::apply_prefix(name, prefix);
6132                let replacing = !self.tools.contains_key(&mounted_name)
6133                    || matches!(behavior, crate::DuplicateBehavior::Replace);
6134                if !replacing {
6135                    continue;
6136                }
6137                if let Some(final_registration) = registration.final_registration.as_ref() {
6138                    let mut definition = final_registration.final_definition.clone();
6139                    definition.name.clone_from(&mounted_name);
6140                    final_tools.insert(mounted_name, definition);
6141                } else {
6142                    final_tools.remove(&mounted_name);
6143                }
6144            }
6145        }
6146
6147        for (name, tool) in final_tools {
6148            let binding = match tool.mcp_apps_resource_binding() {
6149                Ok(binding) => binding,
6150                Err(error) => {
6151                    result.errors.push(format!(
6152                        "Mount rejected because tool has invalid MCP Apps metadata; tool_key={}; error={error}",
6153                        safe_log_label(&name)
6154                    ));
6155                    continue;
6156                }
6157            };
6158            let Some(binding) = binding else {
6159                continue;
6160            };
6161            let Some(resource) = final_resources.get(binding.resource_uri.as_str()) else {
6162                result.errors.push(format!(
6163                    "Mount rejected because an MCP Apps tool binding has no final HTML resource; tool_key={}; resource_uri={}",
6164                    safe_log_label(&name),
6165                    binding.resource_uri.as_str()
6166                ));
6167                continue;
6168            };
6169            if let Err(error) = binding.validate_resource(resource) {
6170                result.errors.push(format!(
6171                    "Mount rejected because an MCP Apps tool binding is invalid; tool_key={}; error={error}",
6172                    safe_log_label(&name)
6173                ));
6174            }
6175        }
6176    }
6177
6178    fn should_mount_duplicate(
6179        behavior: crate::DuplicateBehavior,
6180        kind: &'static str,
6181        key: &str,
6182        result: &mut MountResult,
6183    ) -> bool {
6184        match behavior {
6185            crate::DuplicateBehavior::Error => {
6186                result.errors.push(format!(
6187                    "Mount rejected because {kind} already exists; component_key={}",
6188                    safe_log_label(key)
6189                ));
6190                false
6191            }
6192            crate::DuplicateBehavior::Warn => {
6193                result.warnings.push(format!(
6194                    "{kind} already exists, keeping original; component_key={}",
6195                    safe_log_label(key)
6196                ));
6197                false
6198            }
6199            crate::DuplicateBehavior::Replace => {
6200                result.warnings.push(format!(
6201                    "{kind} already exists, replacing original; component_key={}",
6202                    safe_log_label(key)
6203                ));
6204                true
6205            }
6206            crate::DuplicateBehavior::Ignore => false,
6207        }
6208    }
6209
6210    fn completion_prompt_keys(&self) -> HashSet<String> {
6211        self.prompts
6212            .keys()
6213            .chain(self.final_prompts.keys())
6214            .cloned()
6215            .collect()
6216    }
6217
6218    fn remap_mounted_completion_handler(
6219        handler: crate::handler::BoxedCompletionHandler,
6220        original: String,
6221        mounted: String,
6222        for_prompt: bool,
6223    ) -> crate::handler::BoxedCompletionHandler {
6224        if original == mounted {
6225            return handler;
6226        }
6227        if for_prompt {
6228            Box::new(crate::handler::MountedCompletionHandler::for_prompt(
6229                handler, mounted, original,
6230            ))
6231        } else {
6232            Box::new(
6233                crate::handler::MountedCompletionHandler::for_resource_template(
6234                    handler, mounted, original,
6235                ),
6236            )
6237        }
6238    }
6239
6240    fn remap_mounted_fallback_completion_handler(
6241        handler: crate::handler::BoxedCompletionHandler,
6242        prompt_prefix: Option<&str>,
6243        template_prefix: Option<&str>,
6244    ) -> crate::handler::BoxedCompletionHandler {
6245        if Self::prefix_preserves_keys(prompt_prefix)
6246            && Self::prefix_preserves_keys(template_prefix)
6247        {
6248            return handler;
6249        }
6250        Box::new(crate::handler::PrefixedFallbackCompletionHandler::new(
6251            handler,
6252            prompt_prefix,
6253            template_prefix,
6254        ))
6255    }
6256
6257    fn mount_completion_providers_from(
6258        &mut self,
6259        prompt_prefix: Option<&str>,
6260        template_prefix: Option<&str>,
6261        legacy_prompt_completion_handlers: HashMap<String, crate::handler::BoxedCompletionHandler>,
6262        legacy_resource_template_completion_handlers: HashMap<
6263            String,
6264            crate::handler::BoxedCompletionHandler,
6265        >,
6266        final_prompt_completion_handlers: HashMap<String, crate::handler::BoxedCompletionHandler>,
6267        final_resource_template_completion_handlers: HashMap<
6268            String,
6269            crate::handler::BoxedCompletionHandler,
6270        >,
6271        completion_handler: Option<crate::handler::BoxedCompletionHandler>,
6272        default_final_completion_enabled: bool,
6273        preexisting_prompts: &HashSet<String>,
6274        preexisting_templates: &HashSet<String>,
6275        behavior: crate::DuplicateBehavior,
6276    ) {
6277        let replace = behavior == crate::DuplicateBehavior::Replace;
6278        for (name, handler) in legacy_prompt_completion_handlers {
6279            let mounted = Self::apply_prefix(&name, prompt_prefix);
6280            if !self.prompts.contains_key(&mounted) {
6281                continue;
6282            }
6283            if preexisting_prompts.contains(&mounted) && !replace {
6284                continue;
6285            }
6286            self.legacy_prompt_completion_handlers.insert(
6287                mounted.clone(),
6288                Self::remap_mounted_completion_handler(handler, name, mounted, true),
6289            );
6290        }
6291        for (name, handler) in final_prompt_completion_handlers {
6292            let mounted = Self::apply_prefix(&name, prompt_prefix);
6293            if !self.prompts.contains_key(&mounted) && !self.final_prompts.contains_key(&mounted) {
6294                continue;
6295            }
6296            if preexisting_prompts.contains(&mounted) && !replace {
6297                continue;
6298            }
6299            self.final_prompt_completion_handlers.insert(
6300                mounted.clone(),
6301                Self::remap_mounted_completion_handler(handler, name, mounted, true),
6302            );
6303        }
6304        for (uri, handler) in legacy_resource_template_completion_handlers {
6305            let mounted = Self::apply_prefix(&uri, template_prefix);
6306            if !self.resource_templates.contains_key(&mounted) {
6307                continue;
6308            }
6309            if preexisting_templates.contains(&mounted) && !replace {
6310                continue;
6311            }
6312            self.legacy_resource_template_completion_handlers.insert(
6313                mounted.clone(),
6314                Self::remap_mounted_completion_handler(handler, uri, mounted, false),
6315            );
6316        }
6317        for (uri, handler) in final_resource_template_completion_handlers {
6318            let mounted = Self::apply_prefix(&uri, template_prefix);
6319            if !self.resource_templates.contains_key(&mounted) {
6320                continue;
6321            }
6322            if preexisting_templates.contains(&mounted) && !replace {
6323                continue;
6324            }
6325            self.final_resource_template_completion_handlers.insert(
6326                mounted.clone(),
6327                Self::remap_mounted_completion_handler(handler, uri, mounted, false),
6328            );
6329        }
6330        if let Some(handler) = completion_handler {
6331            if self.completion_handler.is_none() || replace {
6332                self.completion_handler = Some(Self::remap_mounted_fallback_completion_handler(
6333                    handler,
6334                    prompt_prefix,
6335                    template_prefix,
6336                ));
6337                self.default_final_completion_enabled = default_final_completion_enabled;
6338            }
6339        } else if self.completion_handler.is_none() {
6340            self.default_final_completion_enabled |= default_final_completion_enabled;
6341        }
6342    }
6343
6344    /// Mounts all handlers from another router with an optional prefix.
6345    ///
6346    /// This consumes the source router and moves its handlers into this router.
6347    /// Names/URIs are prefixed with `prefix/` if a prefix is provided.
6348    ///
6349    /// # Example
6350    ///
6351    /// ```ignore
6352    /// let mut main_router = Router::new();
6353    /// let db_router = Router::new();
6354    /// // ... add handlers to db_router ...
6355    ///
6356    /// main_router.mount(db_router, Some("db"));
6357    /// // Tool "query" becomes "db/query"
6358    /// ```
6359    pub fn mount(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6360        self.mount_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6361    }
6362
6363    /// Mounts all handlers using the specified duplicate behavior.
6364    ///
6365    /// Prefix validation happens before any destination mutation. With
6366    /// [`crate::DuplicateBehavior::Error`], every selected component is
6367    /// preflighted and any conflict rejects the entire mount atomically.
6368    pub fn mount_with_behavior(
6369        &mut self,
6370        other: Router,
6371        prefix: Option<&str>,
6372        behavior: crate::DuplicateBehavior,
6373    ) -> MountResult {
6374        let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::All);
6375        if !preflight.is_success() {
6376            return preflight;
6377        }
6378
6379        let mut result = preflight;
6380        let preexisting_prompts = self.completion_prompt_keys();
6381        let preexisting_templates = self
6382            .resource_templates
6383            .keys()
6384            .cloned()
6385            .collect::<HashSet<_>>();
6386
6387        let Router {
6388            tools,
6389            tool_order,
6390            resources,
6391            final_only_resources,
6392            final_resources,
6393            resource_order,
6394            prompts,
6395            final_only_prompts,
6396            final_prompts,
6397            prompt_order,
6398            resource_templates,
6399            resource_template_order,
6400            completion_handler,
6401            default_final_completion_enabled,
6402            legacy_prompt_completion_handlers,
6403            legacy_resource_template_completion_handlers,
6404            final_prompt_completion_handlers,
6405            final_resource_template_completion_handlers,
6406            ..
6407        } = other;
6408
6409        // Mount tools
6410        result.merge(self.mount_tools_from(tools, tool_order, prefix, behavior));
6411
6412        // Mount resources
6413        result.merge(self.mount_resources_from(
6414            resources,
6415            final_only_resources,
6416            final_resources,
6417            resource_order,
6418            prefix,
6419            behavior,
6420        ));
6421
6422        // Mount resource templates
6423        result.merge(self.mount_resource_templates_from(
6424            resource_templates,
6425            resource_template_order,
6426            prefix,
6427            behavior,
6428        ));
6429
6430        // Mount prompts
6431        result.merge(self.mount_prompts_from(
6432            prompts,
6433            final_only_prompts,
6434            final_prompts,
6435            prompt_order,
6436            prefix,
6437            behavior,
6438        ));
6439        self.mount_completion_providers_from(
6440            prefix,
6441            prefix,
6442            legacy_prompt_completion_handlers,
6443            legacy_resource_template_completion_handlers,
6444            final_prompt_completion_handlers,
6445            final_resource_template_completion_handlers,
6446            completion_handler,
6447            default_final_completion_enabled,
6448            &preexisting_prompts,
6449            &preexisting_templates,
6450            behavior,
6451        );
6452
6453        // Log mount result
6454        if result.has_components() {
6455            debug!(
6456                target: targets::HANDLER,
6457                "mounted {} tools, {} resources, {} templates, {} prompts; prefix_present={}; prefix_key={}",
6458                result.tools,
6459                result.resources,
6460                result.resource_templates,
6461                result.prompts,
6462                prefix.is_some(),
6463                safe_log_label(prefix.unwrap_or_default())
6464            );
6465            self.advance_final_catalog_revision();
6466        }
6467
6468        result
6469    }
6470
6471    /// Mounts tools and prompts with an optional name prefix, and keeps
6472    /// resource and template keys exact.
6473    ///
6474    /// A nonempty `{prefix}/{uri}` key is not an absolute final URI. Callers
6475    /// that need a modern resource catalog after namespacing tools/prompts
6476    /// must preserve the child's resource URIs instead of prefixing them.
6477    pub fn mount_namespaced_with_behavior(
6478        &mut self,
6479        other: Router,
6480        prefix: Option<&str>,
6481        behavior: crate::DuplicateBehavior,
6482    ) -> MountResult {
6483        let mut preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Tools);
6484        preflight.merge(self.mount_preflight(&other, prefix, behavior, MountSelection::Prompts));
6485        preflight.merge(self.mount_preflight(&other, None, behavior, MountSelection::Resources));
6486        if !preflight.is_success() {
6487            return preflight;
6488        }
6489
6490        let mut result = preflight;
6491        let preexisting_prompts = self.completion_prompt_keys();
6492        let preexisting_templates = self
6493            .resource_templates
6494            .keys()
6495            .cloned()
6496            .collect::<HashSet<_>>();
6497        let Router {
6498            tools,
6499            tool_order,
6500            resources,
6501            final_only_resources,
6502            final_resources,
6503            resource_order,
6504            prompts,
6505            final_only_prompts,
6506            final_prompts,
6507            prompt_order,
6508            resource_templates,
6509            resource_template_order,
6510            completion_handler,
6511            default_final_completion_enabled,
6512            legacy_prompt_completion_handlers,
6513            legacy_resource_template_completion_handlers,
6514            final_prompt_completion_handlers,
6515            final_resource_template_completion_handlers,
6516            ..
6517        } = other;
6518
6519        result.merge(self.mount_tools_from(tools, tool_order, prefix, behavior));
6520        result.merge(self.mount_resources_from(
6521            resources,
6522            final_only_resources,
6523            final_resources,
6524            resource_order,
6525            None,
6526            behavior,
6527        ));
6528        result.merge(self.mount_resource_templates_from(
6529            resource_templates,
6530            resource_template_order,
6531            None,
6532            behavior,
6533        ));
6534        result.merge(self.mount_prompts_from(
6535            prompts,
6536            final_only_prompts,
6537            final_prompts,
6538            prompt_order,
6539            prefix,
6540            behavior,
6541        ));
6542        self.mount_completion_providers_from(
6543            prefix,
6544            None,
6545            legacy_prompt_completion_handlers,
6546            legacy_resource_template_completion_handlers,
6547            final_prompt_completion_handlers,
6548            final_resource_template_completion_handlers,
6549            completion_handler,
6550            default_final_completion_enabled,
6551            &preexisting_prompts,
6552            &preexisting_templates,
6553            behavior,
6554        );
6555
6556        if result.has_components() {
6557            debug!(
6558                target: targets::HANDLER,
6559                "mounted namespaced {} tools, {} resources, {} templates, {} prompts; prefix_present={}; prefix_key={}",
6560                result.tools,
6561                result.resources,
6562                result.resource_templates,
6563                result.prompts,
6564                prefix.is_some(),
6565                safe_log_label(prefix.unwrap_or_default())
6566            );
6567            self.advance_final_catalog_revision();
6568        }
6569
6570        result
6571    }
6572
6573    /// Mounts only tools from a router.
6574    pub fn mount_tools(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6575        self.mount_tools_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6576    }
6577
6578    /// Mounts only tools using the specified duplicate behavior.
6579    pub fn mount_tools_with_behavior(
6580        &mut self,
6581        other: Router,
6582        prefix: Option<&str>,
6583        behavior: crate::DuplicateBehavior,
6584    ) -> MountResult {
6585        let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Tools);
6586        if !preflight.is_success() {
6587            return preflight;
6588        }
6589        let result = self.mount_tools_from(other.tools, other.tool_order, prefix, behavior);
6590        if result.has_components() {
6591            self.advance_final_catalog_revision();
6592        }
6593        result
6594    }
6595
6596    /// Internal: mount tools from a HashMap.
6597    fn mount_tools_from(
6598        &mut self,
6599        mut tools: HashMap<String, AdmittedToolRegistration>,
6600        tool_order: Vec<String>,
6601        prefix: Option<&str>,
6602        behavior: crate::DuplicateBehavior,
6603    ) -> MountResult {
6604        let mut result = MountResult::default();
6605
6606        for name in tool_order {
6607            let Some(entry) = tools.remove(&name) else {
6608                continue;
6609            };
6610            let mounted_name = Self::apply_prefix(&name, prefix);
6611            trace!(
6612                target: targets::HANDLER,
6613                "mounting tool; source_key={}; mounted_key={}",
6614                safe_log_label(&name),
6615                safe_log_label(&mounted_name)
6616            );
6617
6618            // Check for conflicts
6619            let existed = self.tools.contains_key(&mounted_name);
6620            if existed
6621                && !Self::should_mount_duplicate(behavior, "Tool", &mounted_name, &mut result)
6622            {
6623                continue;
6624            }
6625
6626            // Rewrite the immutable definition snapshot and wrap only the
6627            // dispatch target. Admitted schemas and final metadata move as one
6628            // entry and are never recomputed during mounting.
6629            let mounted = entry.with_mounted_name(mounted_name.clone());
6630            let needs_order_push = !existed && !self.tool_order.iter().any(|n| n == &mounted_name);
6631            self.tools.insert(mounted_name.clone(), mounted);
6632            if needs_order_push {
6633                self.tool_order.push(mounted_name);
6634            }
6635            result.tools += 1;
6636        }
6637
6638        if !tools.is_empty() {
6639            // Defensive: older Routers or unusual construction could leave items untracked by
6640            // tool_order. Mount them deterministically to avoid HashMap iteration order leaks.
6641            let mut remaining: Vec<(String, AdmittedToolRegistration)> =
6642                tools.into_iter().collect();
6643            remaining.sort_by(|a, b| a.0.cmp(&b.0));
6644            for (name, entry) in remaining {
6645                let mounted_name = Self::apply_prefix(&name, prefix);
6646
6647                let existed = self.tools.contains_key(&mounted_name);
6648                if existed
6649                    && !Self::should_mount_duplicate(behavior, "Tool", &mounted_name, &mut result)
6650                {
6651                    continue;
6652                }
6653
6654                let mounted = entry.with_mounted_name(mounted_name.clone());
6655                self.tools.insert(mounted_name.clone(), mounted);
6656                if !existed && !self.tool_order.iter().any(|n| n == &mounted_name) {
6657                    self.tool_order.push(mounted_name);
6658                }
6659                result.tools += 1;
6660            }
6661        }
6662
6663        result
6664    }
6665
6666    /// Mounts only resources from a router.
6667    pub fn mount_resources(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
6668        self.mount_resources_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
6669    }
6670
6671    /// Mounts resources and resource templates using the specified duplicate
6672    /// behavior.
6673    pub fn mount_resources_with_behavior(
6674        &mut self,
6675        other: Router,
6676        prefix: Option<&str>,
6677        behavior: crate::DuplicateBehavior,
6678    ) -> MountResult {
6679        let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Resources);
6680        if !preflight.is_success() {
6681            return preflight;
6682        }
6683
6684        let preexisting_templates = self
6685            .resource_templates
6686            .keys()
6687            .cloned()
6688            .collect::<HashSet<_>>();
6689        let Router {
6690            resources,
6691            final_only_resources,
6692            final_resources,
6693            resource_order,
6694            resource_templates,
6695            resource_template_order,
6696            legacy_resource_template_completion_handlers,
6697            final_resource_template_completion_handlers,
6698            ..
6699        } = other;
6700        let mut result = preflight;
6701        result.merge(self.mount_resources_from(
6702            resources,
6703            final_only_resources,
6704            final_resources,
6705            resource_order,
6706            prefix,
6707            behavior,
6708        ));
6709        let template_result = self.mount_resource_templates_from(
6710            resource_templates,
6711            resource_template_order,
6712            prefix,
6713            behavior,
6714        );
6715        result.merge(template_result);
6716        self.mount_completion_providers_from(
6717            None,
6718            prefix,
6719            HashMap::new(),
6720            legacy_resource_template_completion_handlers,
6721            HashMap::new(),
6722            final_resource_template_completion_handlers,
6723            None,
6724            false,
6725            &HashSet::new(),
6726            &preexisting_templates,
6727            behavior,
6728        );
6729        if result.has_components() {
6730            self.advance_final_catalog_revision();
6731        }
6732        result
6733    }
6734
6735    /// Internal: mount resources from a HashMap.
6736    fn mount_resources_from(
6737        &mut self,
6738        mut resources: HashMap<String, BoxedResourceHandler>,
6739        final_only_resources: HashSet<String>,
6740        mut final_resources: HashMap<String, AdmittedFinalResourceRegistration>,
6741        resource_order: Vec<String>,
6742        prefix: Option<&str>,
6743        behavior: crate::DuplicateBehavior,
6744    ) -> MountResult {
6745        use crate::handler::MountedResourceHandler;
6746
6747        let mut result = MountResult::default();
6748
6749        for uri in resource_order {
6750            let Some(handler) = resources.remove(&uri) else {
6751                continue;
6752            };
6753            let mounted_uri = Self::apply_prefix(&uri, prefix);
6754            trace!(
6755                target: targets::HANDLER,
6756                "mounting resource; source_key={}; mounted_key={}",
6757                safe_log_label(&uri),
6758                safe_log_label(&mounted_uri)
6759            );
6760
6761            // Check for conflicts
6762            let existed = self.resources.contains_key(&mounted_uri);
6763            if existed
6764                && !Self::should_mount_duplicate(behavior, "Resource", &mounted_uri, &mut result)
6765            {
6766                continue;
6767            }
6768
6769            // Wrap with mounted URI and insert
6770            let mounted = MountedResourceHandler::new(handler, uri.clone(), mounted_uri.clone());
6771            let needs_order_push =
6772                !existed && !self.resource_order.iter().any(|u| u == &mounted_uri);
6773            self.resources
6774                .insert(mounted_uri.clone(), Box::new(mounted));
6775            if Self::prefix_preserves_keys(prefix) {
6776                if let Some(final_registration) = final_resources.remove(&uri) {
6777                    self.final_resources
6778                        .insert(mounted_uri.clone(), final_registration);
6779                } else {
6780                    self.final_resources.remove(&mounted_uri);
6781                }
6782            } else {
6783                // Nonempty-prefixed resource URIs are intentionally
6784                // legacy-only: the mounting namespace is not an absolute
6785                // final URI.
6786                self.final_resources.remove(&mounted_uri);
6787            }
6788            if Self::prefix_preserves_keys(prefix) && final_only_resources.contains(&uri) {
6789                self.final_only_resources.insert(mounted_uri.clone());
6790            } else {
6791                self.final_only_resources.remove(&mounted_uri);
6792            }
6793            if needs_order_push {
6794                self.resource_order.push(mounted_uri);
6795            }
6796            result.resources += 1;
6797        }
6798
6799        if !resources.is_empty() {
6800            let mut remaining: Vec<(String, BoxedResourceHandler)> =
6801                resources.into_iter().collect();
6802            remaining.sort_by(|a, b| a.0.cmp(&b.0));
6803            for (uri, handler) in remaining {
6804                let mounted_uri = Self::apply_prefix(&uri, prefix);
6805
6806                let existed = self.resources.contains_key(&mounted_uri);
6807                if existed
6808                    && !Self::should_mount_duplicate(
6809                        behavior,
6810                        "Resource",
6811                        &mounted_uri,
6812                        &mut result,
6813                    )
6814                {
6815                    continue;
6816                }
6817
6818                let mounted =
6819                    MountedResourceHandler::new(handler, uri.clone(), mounted_uri.clone());
6820                self.resources
6821                    .insert(mounted_uri.clone(), Box::new(mounted));
6822                if Self::prefix_preserves_keys(prefix) {
6823                    if let Some(final_registration) = final_resources.remove(&uri) {
6824                        self.final_resources
6825                            .insert(mounted_uri.clone(), final_registration);
6826                    } else {
6827                        self.final_resources.remove(&mounted_uri);
6828                    }
6829                } else {
6830                    self.final_resources.remove(&mounted_uri);
6831                }
6832                if Self::prefix_preserves_keys(prefix) && final_only_resources.contains(&uri) {
6833                    self.final_only_resources.insert(mounted_uri.clone());
6834                } else {
6835                    self.final_only_resources.remove(&mounted_uri);
6836                }
6837                if !existed && !self.resource_order.iter().any(|u| u == &mounted_uri) {
6838                    self.resource_order.push(mounted_uri);
6839                }
6840                result.resources += 1;
6841            }
6842        }
6843
6844        result
6845    }
6846
6847    /// Internal: mount resource templates from a HashMap.
6848    fn mount_resource_templates_from(
6849        &mut self,
6850        mut templates: HashMap<String, ResourceTemplateEntry>,
6851        resource_template_order: Vec<String>,
6852        prefix: Option<&str>,
6853        behavior: crate::DuplicateBehavior,
6854    ) -> MountResult {
6855        use crate::handler::MountedResourceHandler;
6856
6857        let mut result = MountResult::default();
6858
6859        for uri_template in resource_template_order {
6860            let Some(entry) = templates.remove(&uri_template) else {
6861                continue;
6862            };
6863            let mounted_uri_template = Self::apply_prefix(&uri_template, prefix);
6864            trace!(
6865                target: targets::HANDLER,
6866                "mounting resource template; source_key={}; mounted_key={}",
6867                safe_log_label(&uri_template),
6868                safe_log_label(&mounted_uri_template)
6869            );
6870
6871            // Check for conflicts
6872            let existed = self.resource_templates.contains_key(&mounted_uri_template);
6873            if existed
6874                && !Self::should_mount_duplicate(
6875                    behavior,
6876                    "Resource template",
6877                    &mounted_uri_template,
6878                    &mut result,
6879                )
6880            {
6881                continue;
6882            }
6883
6884            // Create new template with mounted URI
6885            let mut mounted_template = entry.template.clone();
6886            mounted_template.uri_template = mounted_uri_template.clone();
6887
6888            // Wrap handler if present
6889            let mounted_handler = entry.handler.map(|h| {
6890                let wrapped: BoxedResourceHandler =
6891                    Box::new(MountedResourceHandler::with_template(
6892                        h,
6893                        uri_template.clone(),
6894                        mounted_uri_template.clone(),
6895                        mounted_template.clone(),
6896                    ));
6897                wrapped
6898            });
6899
6900            // Create new entry with mounted template.
6901            let legacy_matcher = if entry.legacy_enabled {
6902                match admit_legacy_resource_template(&mounted_uri_template) {
6903                    Ok(matcher) => Some(matcher),
6904                    Err(error) => {
6905                        result.errors.push(format!(
6906                            "Mount rejected exact-2024 resource template; template_key={}; code={:?}",
6907                            safe_log_label(&mounted_uri_template),
6908                            error.code
6909                        ));
6910                        continue;
6911                    }
6912                }
6913            } else {
6914                None
6915            };
6916            let final_enabled =
6917                Self::prefix_preserves_keys(prefix) && entry.final_definition.is_some();
6918            let (matcher, specificity) = if final_enabled {
6919                match admit_resource_template(&mounted_uri_template) {
6920                    Ok((matcher, specificity)) => (Some(matcher), specificity),
6921                    Err(error) => {
6922                        result.errors.push(format!(
6923                            "Mount rejected resource template; template_key={}; code={:?}",
6924                            safe_log_label(&mounted_uri_template),
6925                            error.code
6926                        ));
6927                        continue;
6928                    }
6929                }
6930            } else {
6931                let specificity = legacy_matcher.as_ref().map_or(
6932                    entry.specificity,
6933                    LegacyResourceTemplateMatcher::specificity,
6934                );
6935                (None, specificity)
6936            };
6937            let mounted_entry = ResourceTemplateEntry {
6938                matcher,
6939                legacy_matcher,
6940                specificity,
6941                template: mounted_template,
6942                handler: mounted_handler,
6943                // A nonempty mount prefix produces a relative legacy
6944                // namespace (for example, `peer/mcp://...`). It cannot
6945                // preserve the exact final absolute-URI contract, so mirror
6946                // static-resource mounting and expose the mounted route to
6947                // legacy only.
6948                final_definition: if Self::prefix_preserves_keys(prefix) {
6949                    entry.final_definition.map(|mut definition| {
6950                        definition.uri_template = mounted_uri_template.clone();
6951                        definition
6952                    })
6953                } else {
6954                    None
6955                },
6956                uri_use_policy: entry.uri_use_policy,
6957                legacy_enabled: entry.legacy_enabled,
6958            };
6959
6960            let needs_order_push = !existed
6961                && !self
6962                    .resource_template_order
6963                    .iter()
6964                    .any(|t| t == &mounted_uri_template);
6965            self.resource_templates
6966                .insert(mounted_uri_template.clone(), mounted_entry);
6967            if needs_order_push {
6968                self.resource_template_order.push(mounted_uri_template);
6969            }
6970            result.resource_templates += 1;
6971        }
6972
6973        if !templates.is_empty() {
6974            let mut remaining: Vec<(String, ResourceTemplateEntry)> =
6975                templates.into_iter().collect();
6976            remaining.sort_by(|a, b| a.0.cmp(&b.0));
6977            for (uri_template, entry) in remaining {
6978                let mounted_uri_template = Self::apply_prefix(&uri_template, prefix);
6979
6980                let existed = self.resource_templates.contains_key(&mounted_uri_template);
6981                if existed
6982                    && !Self::should_mount_duplicate(
6983                        behavior,
6984                        "Resource template",
6985                        &mounted_uri_template,
6986                        &mut result,
6987                    )
6988                {
6989                    continue;
6990                }
6991
6992                let mut mounted_template = entry.template.clone();
6993                mounted_template.uri_template = mounted_uri_template.clone();
6994
6995                let mounted_handler = entry.handler.map(|h| {
6996                    let wrapped: BoxedResourceHandler =
6997                        Box::new(MountedResourceHandler::with_template(
6998                            h,
6999                            uri_template,
7000                            mounted_uri_template.clone(),
7001                            mounted_template.clone(),
7002                        ));
7003                    wrapped
7004                });
7005
7006                let legacy_matcher = if entry.legacy_enabled {
7007                    match admit_legacy_resource_template(&mounted_uri_template) {
7008                        Ok(matcher) => Some(matcher),
7009                        Err(error) => {
7010                            result.errors.push(format!(
7011                                "Mount rejected exact-2024 resource template; template_key={}; code={:?}",
7012                                safe_log_label(&mounted_uri_template),
7013                                error.code
7014                            ));
7015                            continue;
7016                        }
7017                    }
7018                } else {
7019                    None
7020                };
7021                let final_enabled =
7022                    Self::prefix_preserves_keys(prefix) && entry.final_definition.is_some();
7023                let (matcher, specificity) = if final_enabled {
7024                    match admit_resource_template(&mounted_uri_template) {
7025                        Ok((matcher, specificity)) => (Some(matcher), specificity),
7026                        Err(error) => {
7027                            result.errors.push(format!(
7028                                "Mount rejected resource template; template_key={}; code={:?}",
7029                                safe_log_label(&mounted_uri_template),
7030                                error.code
7031                            ));
7032                            continue;
7033                        }
7034                    }
7035                } else {
7036                    let specificity = legacy_matcher.as_ref().map_or(
7037                        entry.specificity,
7038                        LegacyResourceTemplateMatcher::specificity,
7039                    );
7040                    (None, specificity)
7041                };
7042                let mounted_entry = ResourceTemplateEntry {
7043                    matcher,
7044                    legacy_matcher,
7045                    specificity,
7046                    template: mounted_template,
7047                    handler: mounted_handler,
7048                    // See the ordered-template path above: nonempty-prefixed
7049                    // template routes are legacy-only because their URI
7050                    // namespace is no longer absolute for exact final
7051                    // resource contents.
7052                    final_definition: if Self::prefix_preserves_keys(prefix) {
7053                        entry.final_definition.map(|mut definition| {
7054                            definition.uri_template = mounted_uri_template.clone();
7055                            definition
7056                        })
7057                    } else {
7058                        None
7059                    },
7060                    uri_use_policy: entry.uri_use_policy,
7061                    legacy_enabled: entry.legacy_enabled,
7062                };
7063
7064                self.resource_templates
7065                    .insert(mounted_uri_template.clone(), mounted_entry);
7066                if !existed
7067                    && !self
7068                        .resource_template_order
7069                        .iter()
7070                        .any(|t| t == &mounted_uri_template)
7071                {
7072                    self.resource_template_order
7073                        .push(mounted_uri_template.clone());
7074                }
7075                result.resource_templates += 1;
7076            }
7077        }
7078
7079        // Rebuild sorted keys if we added templates
7080        if result.resource_templates > 0 {
7081            self.rebuild_sorted_template_keys();
7082        }
7083
7084        result
7085    }
7086
7087    /// Mounts only prompts from a router.
7088    pub fn mount_prompts(&mut self, other: Router, prefix: Option<&str>) -> MountResult {
7089        self.mount_prompts_with_behavior(other, prefix, crate::DuplicateBehavior::Replace)
7090    }
7091
7092    /// Mounts only prompts using the specified duplicate behavior.
7093    pub fn mount_prompts_with_behavior(
7094        &mut self,
7095        other: Router,
7096        prefix: Option<&str>,
7097        behavior: crate::DuplicateBehavior,
7098    ) -> MountResult {
7099        let preflight = self.mount_preflight(&other, prefix, behavior, MountSelection::Prompts);
7100        if !preflight.is_success() {
7101            return preflight;
7102        }
7103        let preexisting_prompts = self.completion_prompt_keys();
7104        let Router {
7105            prompts,
7106            final_only_prompts,
7107            final_prompts,
7108            prompt_order,
7109            legacy_prompt_completion_handlers,
7110            final_prompt_completion_handlers,
7111            ..
7112        } = other;
7113        let mut result = preflight;
7114        result.merge(self.mount_prompts_from(
7115            prompts,
7116            final_only_prompts,
7117            final_prompts,
7118            prompt_order,
7119            prefix,
7120            behavior,
7121        ));
7122        self.mount_completion_providers_from(
7123            prefix,
7124            None,
7125            legacy_prompt_completion_handlers,
7126            HashMap::new(),
7127            final_prompt_completion_handlers,
7128            HashMap::new(),
7129            None,
7130            false,
7131            &preexisting_prompts,
7132            &HashSet::new(),
7133            behavior,
7134        );
7135        if result.has_components() {
7136            self.advance_final_catalog_revision();
7137        }
7138        result
7139    }
7140
7141    /// Internal: mount prompts from a HashMap.
7142    fn mount_prompts_from(
7143        &mut self,
7144        mut prompts: HashMap<String, BoxedPromptHandler>,
7145        final_only_prompts: HashSet<String>,
7146        mut final_prompts: HashMap<String, AdmittedFinalPromptRegistration>,
7147        prompt_order: Vec<String>,
7148        prefix: Option<&str>,
7149        behavior: crate::DuplicateBehavior,
7150    ) -> MountResult {
7151        use crate::handler::MountedPromptHandler;
7152
7153        let mut result = MountResult::default();
7154
7155        for name in prompt_order {
7156            let Some(handler) = prompts.remove(&name) else {
7157                continue;
7158            };
7159            let mounted_name = Self::apply_prefix(&name, prefix);
7160            trace!(
7161                target: targets::HANDLER,
7162                "mounting prompt; source_key={}; mounted_key={}",
7163                safe_log_label(&name),
7164                safe_log_label(&mounted_name)
7165            );
7166
7167            // Check for conflicts
7168            let existed = self.prompts.contains_key(&mounted_name);
7169            if existed
7170                && !Self::should_mount_duplicate(behavior, "Prompt", &mounted_name, &mut result)
7171            {
7172                continue;
7173            }
7174
7175            // Wrap with mounted name and insert
7176            let mounted = MountedPromptHandler::new(handler, mounted_name.clone());
7177            let needs_order_push =
7178                !existed && !self.prompt_order.iter().any(|n| n == &mounted_name);
7179            self.prompts.insert(mounted_name.clone(), Box::new(mounted));
7180            match final_prompts.remove(&name) {
7181                Some(mut final_registration) => {
7182                    final_registration.definition.name.clone_from(&mounted_name);
7183                    self.final_prompts
7184                        .insert(mounted_name.clone(), final_registration);
7185                }
7186                None => {
7187                    self.final_prompts.remove(&mounted_name);
7188                }
7189            }
7190            if final_only_prompts.contains(&name) {
7191                self.final_only_prompts.insert(mounted_name.clone());
7192            } else {
7193                self.final_only_prompts.remove(&mounted_name);
7194            }
7195            if needs_order_push {
7196                self.prompt_order.push(mounted_name);
7197            }
7198            result.prompts += 1;
7199        }
7200
7201        if !prompts.is_empty() {
7202            let mut remaining: Vec<(String, BoxedPromptHandler)> = prompts.into_iter().collect();
7203            remaining.sort_by(|a, b| a.0.cmp(&b.0));
7204            for (name, handler) in remaining {
7205                let mounted_name = Self::apply_prefix(&name, prefix);
7206
7207                let existed = self.prompts.contains_key(&mounted_name);
7208                if existed
7209                    && !Self::should_mount_duplicate(behavior, "Prompt", &mounted_name, &mut result)
7210                {
7211                    continue;
7212                }
7213
7214                let mounted = MountedPromptHandler::new(handler, mounted_name.clone());
7215                self.prompts.insert(mounted_name.clone(), Box::new(mounted));
7216                match final_prompts.remove(&name) {
7217                    Some(mut final_registration) => {
7218                        final_registration.definition.name.clone_from(&mounted_name);
7219                        self.final_prompts
7220                            .insert(mounted_name.clone(), final_registration);
7221                    }
7222                    None => {
7223                        self.final_prompts.remove(&mounted_name);
7224                    }
7225                }
7226                if final_only_prompts.contains(&name) {
7227                    self.final_only_prompts.insert(mounted_name.clone());
7228                } else {
7229                    self.final_only_prompts.remove(&mounted_name);
7230                }
7231                if !existed && !self.prompt_order.iter().any(|n| n == &mounted_name) {
7232                    self.prompt_order.push(mounted_name);
7233                }
7234                result.prompts += 1;
7235            }
7236        }
7237
7238        result
7239    }
7240
7241    /// Consumes the router and returns its internal handlers.
7242    ///
7243    /// This is used internally for mounting operations.
7244    #[must_use]
7245    #[allow(dead_code)]
7246    pub(crate) fn into_parts(
7247        self,
7248    ) -> (
7249        HashMap<String, BoxedToolHandler>,
7250        HashMap<String, BoxedResourceHandler>,
7251        HashMap<String, ResourceTemplateEntry>,
7252        HashMap<String, BoxedPromptHandler>,
7253    ) {
7254        let tools = self
7255            .tools
7256            .into_iter()
7257            .map(|(name, entry)| (name, entry.handler))
7258            .collect();
7259        (tools, self.resources, self.resource_templates, self.prompts)
7260    }
7261}
7262
7263struct ResolvedResource<'a> {
7264    handler: &'a BoxedResourceHandler,
7265    params: UriParams,
7266    final_enabled: bool,
7267    legacy_enabled: bool,
7268    uri_use_policy: ResourceUriUsePolicy,
7269}
7270
7271impl ResolvedResource<'_> {
7272    const fn is_enabled_in(&self, era: ProtocolEra) -> bool {
7273        match era {
7274            ProtocolEra::Legacy2024 => self.legacy_enabled,
7275            ProtocolEra::Modern2026 => self.final_enabled,
7276        }
7277    }
7278}
7279
7280/// Entry for a resource template with its matcher and optional handler.
7281pub(crate) struct ResourceTemplateEntry {
7282    pub(crate) matcher: Option<ReversibleResourceTemplate>,
7283    legacy_matcher: Option<LegacyResourceTemplateMatcher>,
7284    specificity: (usize, usize, usize),
7285    pub(crate) template: ResourceTemplate,
7286    pub(crate) handler: Option<BoxedResourceHandler>,
7287    final_definition: Option<FinalResourceTemplate>,
7288    uri_use_policy: ResourceUriUsePolicy,
7289    legacy_enabled: bool,
7290}
7291
7292/// The frozen, exact-2024 template matcher. Legacy registrations deliberately
7293/// do not inherit new RFC 6570 operators: their parameter surface is limited
7294/// to `{name}` and `{+name}`, with the historical non-empty, percent-decoded
7295/// capture rules retained for existing handlers.
7296#[derive(Debug, Clone)]
7297struct LegacyResourceTemplateMatcher {
7298    segments: Vec<LegacyResourceTemplateSegment>,
7299}
7300
7301#[derive(Debug, Clone)]
7302enum LegacyResourceTemplateSegment {
7303    Literal(String),
7304    Parameter(String),
7305}
7306
7307impl LegacyResourceTemplateMatcher {
7308    fn parse(pattern: &str) -> Result<Self, ()> {
7309        let mut segments = Vec::new();
7310        let mut literal = String::new();
7311        let mut chars = pattern.chars().peekable();
7312        let mut names = HashSet::new();
7313
7314        while let Some(character) = chars.next() {
7315            match character {
7316                '{' if matches!(chars.peek(), Some('{')) => {
7317                    let _ = chars.next();
7318                    literal.push('{');
7319                }
7320                '{' => {
7321                    if !literal.is_empty() {
7322                        segments.push(LegacyResourceTemplateSegment::Literal(std::mem::take(
7323                            &mut literal,
7324                        )));
7325                    }
7326                    let mut expression = String::new();
7327                    let mut closed = false;
7328                    for next in chars.by_ref() {
7329                        if next == '}' {
7330                            closed = true;
7331                            break;
7332                        }
7333                        expression.push(next);
7334                    }
7335                    if !closed {
7336                        return Err(());
7337                    }
7338
7339                    let name = expression.strip_prefix('+').unwrap_or(&expression);
7340                    if name.is_empty()
7341                        || name.starts_with('+')
7342                        || matches!(
7343                            expression.chars().next(),
7344                            Some('#' | '.' | '/' | ';' | '?' | '&')
7345                        )
7346                        || name
7347                            .chars()
7348                            .any(|character| matches!(character, '*' | ':' | ','))
7349                        || !names.insert(name.to_owned())
7350                    {
7351                        return Err(());
7352                    }
7353                    segments.push(LegacyResourceTemplateSegment::Parameter(name.to_owned()));
7354                }
7355                '}' if matches!(chars.peek(), Some('}')) => {
7356                    let _ = chars.next();
7357                    literal.push('}');
7358                }
7359                '}' => return Err(()),
7360                character => literal.push(character),
7361            }
7362        }
7363
7364        if !literal.is_empty() {
7365            segments.push(LegacyResourceTemplateSegment::Literal(literal));
7366        }
7367        Ok(Self { segments })
7368    }
7369
7370    fn specificity(&self) -> (usize, usize, usize) {
7371        let mut literal_bytes = 0usize;
7372        let mut literal_parts = 0usize;
7373        for segment in &self.segments {
7374            if let LegacyResourceTemplateSegment::Literal(literal) = segment {
7375                literal_bytes = literal_bytes.saturating_add(literal.len());
7376                literal_parts = literal_parts.saturating_add(1);
7377            }
7378        }
7379        (literal_bytes, literal_parts, self.segments.len())
7380    }
7381
7382    fn matches(&self, uri: &str) -> Option<UriParams> {
7383        let mut params = UriParams::new();
7384        let mut remainder = uri;
7385        let mut segments = self.segments.iter().peekable();
7386
7387        while let Some(segment) = segments.next() {
7388            match segment {
7389                LegacyResourceTemplateSegment::Literal(literal) => {
7390                    remainder = remainder.strip_prefix(literal)?;
7391                }
7392                LegacyResourceTemplateSegment::Parameter(name) => {
7393                    let next_literal = segments.peek().and_then(|next| match next {
7394                        LegacyResourceTemplateSegment::Literal(literal) => Some(literal.as_str()),
7395                        LegacyResourceTemplateSegment::Parameter(_) => None,
7396                    });
7397                    if next_literal.is_none() && segments.peek().is_some() {
7398                        return None;
7399                    }
7400
7401                    let value = if let Some(literal) = next_literal {
7402                        let index = remainder.find(literal)?;
7403                        let value = &remainder[..index];
7404                        remainder = &remainder[index..];
7405                        value
7406                    } else {
7407                        if remainder.is_empty() {
7408                            return None;
7409                        }
7410                        let parameter_count = self
7411                            .segments
7412                            .iter()
7413                            .filter(|segment| {
7414                                matches!(segment, LegacyResourceTemplateSegment::Parameter(_))
7415                            })
7416                            .count();
7417                        let end = if parameter_count == 1 {
7418                            remainder.len()
7419                        } else {
7420                            remainder.find('/').unwrap_or(remainder.len())
7421                        };
7422                        let value = &remainder[..end];
7423                        remainder = &remainder[end..];
7424                        value
7425                    };
7426                    if value.is_empty() {
7427                        return None;
7428                    }
7429                    params.insert(name.clone(), legacy_percent_decode(value)?);
7430                }
7431            }
7432        }
7433
7434        remainder.is_empty().then_some(params)
7435    }
7436}
7437
7438fn admit_legacy_resource_template(source: &str) -> McpResult<LegacyResourceTemplateMatcher> {
7439    LegacyResourceTemplateMatcher::parse(source).map_err(|()| {
7440        McpError::invalid_params(
7441            "exact-2024 resource templates only admit unmodified {name} and {+name} parameters",
7442        )
7443    })
7444}
7445
7446fn legacy_percent_decode(input: &str) -> Option<String> {
7447    if !input.as_bytes().contains(&b'%') {
7448        return Some(input.to_owned());
7449    }
7450    let bytes = input.as_bytes();
7451    let mut output = Vec::with_capacity(bytes.len());
7452    let mut index = 0usize;
7453    while index < bytes.len() {
7454        match bytes[index] {
7455            b'%' if index + 2 < bytes.len() => {
7456                let high = legacy_hex_value(bytes[index + 1])?;
7457                let low = legacy_hex_value(bytes[index + 2])?;
7458                output.push((high << 4) | low);
7459                index += 3;
7460            }
7461            b'%' => return None,
7462            byte => {
7463                output.push(byte);
7464                index += 1;
7465            }
7466        }
7467    }
7468    String::from_utf8(output).ok()
7469}
7470
7471const fn legacy_hex_value(byte: u8) -> Option<u8> {
7472    match byte {
7473        b'0'..=b'9' => Some(byte - b'0'),
7474        b'a'..=b'f' => Some(byte - b'a' + 10),
7475        b'A'..=b'F' => Some(byte - b'A' + 10),
7476        _ => None,
7477    }
7478}
7479
7480/// Parses a resource template with the canonical RFC 6570 implementation and
7481/// admits only templates the protocol can reverse-match exactly for routing.
7482fn admit_resource_template(
7483    source: &str,
7484) -> McpResult<(ReversibleResourceTemplate, (usize, usize, usize))> {
7485    let template = fastmcp_protocol::UriTemplate::parse(source)
7486        .map_err(|_| McpError::invalid_params("resource template is not valid RFC 6570"))?;
7487    let specificity = resource_template_specificity(&template);
7488    let matcher = template
7489        .compile_reversible()
7490        .map_err(|_| McpError::invalid_params("resource template cannot be matched reversibly"))?;
7491    Ok((matcher, specificity))
7492}
7493
7494fn resource_template_specificity(
7495    template: &fastmcp_protocol::UriTemplate,
7496) -> (usize, usize, usize) {
7497    let mut literal_bytes = 0usize;
7498    let mut literal_parts = 0usize;
7499    for part in template.parts() {
7500        if let fastmcp_protocol::UriTemplatePart::Literal(literal) = part {
7501            literal_bytes = literal_bytes.saturating_add(literal.len());
7502            literal_parts = literal_parts.saturating_add(1);
7503        }
7504    }
7505    (literal_bytes, literal_parts, template.parts().len())
7506}
7507
7508/// Returns whether two reversible template languages might intersect.
7509///
7510/// A literal prefix is emitted before any expression and therefore provides a
7511/// byte-exact proof of disjointness when the prefixes disagree. When the
7512/// prefixes are compatible, reject conservatively: an RFC 6570 capture can be
7513/// omitted or can absorb following literals, so accepting without a full
7514/// language-intersection proof would reintroduce dispatch-order authority.
7515fn reversible_templates_may_overlap(
7516    left: &ReversibleResourceTemplate,
7517    right: &ReversibleResourceTemplate,
7518) -> McpResult<bool> {
7519    let left_prefix = reversible_template_leading_literal_prefix(left)?;
7520    let right_prefix = reversible_template_leading_literal_prefix(right)?;
7521    Ok(left_prefix.starts_with(&right_prefix) || right_prefix.starts_with(&left_prefix))
7522}
7523
7524fn reversible_template_leading_literal_prefix(
7525    matcher: &ReversibleResourceTemplate,
7526) -> McpResult<String> {
7527    let mut literal = String::new();
7528    for part in matcher.template().parts() {
7529        let UriTemplatePart::Literal(part) = part else {
7530            break;
7531        };
7532        literal.push_str(part);
7533    }
7534    fastmcp_protocol::UriTemplate::parse(&literal)
7535        .and_then(|template| template.expand(&fastmcp_protocol::TemplateValues::new()))
7536        .map_err(|_| McpError::internal_error("admitted resource template lost literal prefix"))
7537}
7538
7539// ============================================================================
7540// Resource Reader Implementation
7541// ============================================================================
7542
7543use fastmcp_core::{
7544    MAX_RESOURCE_READ_DEPTH, ResourceContentItem, ResourceReadResult, ResourceReader,
7545    ToolCallResult, ToolContentItem,
7546};
7547use std::pin::Pin;
7548
7549fn nested_resource_content_from_final(contents: EmbeddedResourceContents) -> ResourceContentItem {
7550    match contents {
7551        EmbeddedResourceContents::Text {
7552            uri,
7553            text,
7554            mime_type,
7555            ..
7556        } => ResourceContentItem {
7557            uri: uri.as_str().to_owned(),
7558            mime_type,
7559            text: Some(text),
7560            blob: None,
7561        },
7562        EmbeddedResourceContents::Blob {
7563            uri,
7564            blob,
7565            mime_type,
7566            ..
7567        } => ResourceContentItem {
7568            uri: uri.as_str().to_owned(),
7569            mime_type,
7570            text: None,
7571            blob: Some(blob),
7572        },
7573    }
7574}
7575
7576fn nested_tool_content_from_final(block: ContentBlock) -> ToolContentItem {
7577    match block {
7578        ContentBlock::Text { text, .. } => ToolContentItem::Text { text },
7579        ContentBlock::Image {
7580            data, mime_type, ..
7581        } => ToolContentItem::Image { data, mime_type },
7582        ContentBlock::Audio {
7583            data, mime_type, ..
7584        } => ToolContentItem::Audio { data, mime_type },
7585        ContentBlock::Resource { resource, .. } => match resource {
7586            EmbeddedResourceContents::Text {
7587                uri,
7588                text,
7589                mime_type,
7590                ..
7591            } => ToolContentItem::Resource {
7592                uri: uri.as_str().to_owned(),
7593                mime_type,
7594                text: Some(text),
7595                blob: None,
7596            },
7597            EmbeddedResourceContents::Blob {
7598                uri,
7599                blob,
7600                mime_type,
7601                ..
7602            } => ToolContentItem::Resource {
7603                uri: uri.as_str().to_owned(),
7604                mime_type,
7605                text: None,
7606                blob: Some(blob),
7607            },
7608        },
7609        ContentBlock::ResourceLink { uri, mime_type, .. } => ToolContentItem::Resource {
7610            uri: uri.as_str().to_owned(),
7611            mime_type,
7612            text: None,
7613            blob: None,
7614        },
7615    }
7616}
7617
7618/// A wrapper that implements `ResourceReader` for a shared `Router`.
7619///
7620/// This allows handlers to read resources from within tool/resource/prompt
7621/// handlers, enabling cross-component access.
7622#[derive(Clone)]
7623enum RouterAccess {
7624    Shared(Arc<Router>),
7625    RequestScoped(Weak<Router>),
7626}
7627
7628impl RouterAccess {
7629    fn upgrade(&self) -> McpResult<Arc<Router>> {
7630        match self {
7631            Self::Shared(router) => Ok(Arc::clone(router)),
7632            Self::RequestScoped(router) => router.upgrade().ok_or_else(|| {
7633                McpError::new(
7634                    McpErrorCode::RequestCancelled,
7635                    "Request router is no longer available",
7636                )
7637            }),
7638        }
7639    }
7640}
7641
7642pub(crate) struct RouterResourceReader {
7643    /// Access to the router without extending a server request's lifetime.
7644    router: RouterAccess,
7645    /// Session state for handlers.
7646    session_state: SessionState,
7647}
7648
7649impl RouterResourceReader {
7650    /// Creates a new resource reader with the given router and session state.
7651    #[must_use]
7652    pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
7653        Self {
7654            router: RouterAccess::Shared(router),
7655            session_state,
7656        }
7657    }
7658
7659    pub(crate) fn request_scoped(router: Weak<Router>, session_state: SessionState) -> Self {
7660        Self {
7661            router: RouterAccess::RequestScoped(router),
7662            session_state,
7663        }
7664    }
7665
7666    fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
7667        Self {
7668            router,
7669            session_state,
7670        }
7671    }
7672
7673    async fn read_resource_legacy(
7674        router: &Router,
7675        parent_ctx: &McpContext,
7676        router_access: RouterAccess,
7677        session_state: SessionState,
7678        uri: String,
7679        depth: u32,
7680    ) -> McpResult<ResourceReadResult> {
7681        let operation_started_at = parent_ctx.cx().now();
7682        if let Some(error) = budget_error(parent_ctx) {
7683            return Err(error);
7684        }
7685        if !session_state.is_resource_enabled(&uri) {
7686            return Err(McpError::new(
7687                McpErrorCode::ResourceNotFound,
7688                format!("Resource '{uri}' is disabled for this session"),
7689            ));
7690        }
7691
7692        let resolved = router.resolve_resource(&uri).ok_or_else(|| {
7693            McpError::new(
7694                McpErrorCode::ResourceNotFound,
7695                format!("Resource not found: {uri}"),
7696            )
7697        })?;
7698        let handler_timeout = read_handler_timeout(parent_ctx.cx(), "resource_timeout", || {
7699            resolved.handler.timeout()
7700        })?;
7701        let effective_budget = compose_handler_budget(
7702            parent_ctx.cx().budget(),
7703            parent_ctx.budget(),
7704            handler_timeout,
7705            operation_started_at,
7706        );
7707        let child_ctx = with_nested_component_access(
7708            parent_ctx
7709                .clone()
7710                .with_operation_deadline(effective_budget.deadline),
7711            router_access.clone(),
7712            session_state.clone(),
7713            depth,
7714        );
7715        let outcome = run_handler_in_request(
7716            &child_ctx,
7717            parent_ctx.cx(),
7718            effective_budget,
7719            "resource",
7720            |request_cx| {
7721                resolved.handler.read_async_with_uri_in_request(
7722                    &child_ctx,
7723                    request_cx,
7724                    &uri,
7725                    &resolved.params,
7726                )
7727            },
7728        )
7729        .await?;
7730        let contents = match outcome {
7731            Outcome::Ok(contents) => contents,
7732            Outcome::Err(error) => {
7733                return Err(sanitize_handler_error(parent_ctx.cx(), "resource", error));
7734            }
7735            Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
7736            Outcome::Panicked(_payload) => {
7737                return Err(sanitized_handler_panic(parent_ctx.cx(), "resource"));
7738            }
7739        };
7740        Ok(ResourceReadResult::new(
7741            contents
7742                .into_iter()
7743                .map(|content| ResourceContentItem {
7744                    uri: content.uri,
7745                    mime_type: content.mime_type,
7746                    text: content.text,
7747                    blob: content.blob,
7748                })
7749                .collect(),
7750        ))
7751    }
7752
7753    async fn read_resource_final(
7754        router: &Router,
7755        parent_ctx: &McpContext,
7756        router_access: RouterAccess,
7757        session_state: SessionState,
7758        uri: String,
7759        depth: u32,
7760    ) -> McpResult<ResourceReadResult> {
7761        let operation_started_at = parent_ctx.cx().now();
7762        if let Some(error) = budget_error(parent_ctx) {
7763            return Err(error);
7764        }
7765        if !session_state.is_resource_enabled(&uri) || !parent_ctx.is_resource_enabled(&uri) {
7766            return Err(McpError::new(
7767                McpErrorCode::ResourceNotFound,
7768                format!("Resource '{uri}' is disabled for this session"),
7769            ));
7770        }
7771        let absolute = AbsoluteUri::parse(uri.clone()).map_err(|_| {
7772            McpError::with_data(
7773                McpErrorCode::InvalidParams,
7774                "Resource not found",
7775                serde_json::json!({ "uri": uri }),
7776            )
7777        })?;
7778        let resolved = router
7779            .resolve_resource_for_era(&uri, Some(ProtocolEra::Modern2026))
7780            .ok_or_else(|| {
7781                McpError::with_data(
7782                    McpErrorCode::InvalidParams,
7783                    "Resource not found",
7784                    serde_json::json!({ "uri": uri }),
7785                )
7786            })?;
7787        if !resolved.final_enabled {
7788            return Err(McpError::invalid_params(
7789                "resource is registered only for exact MCP 2024-11-05 dispatch",
7790            ));
7791        }
7792        if resolved.handler.declares_final_mrtr() && parent_ctx.session_cache_partition().is_none()
7793        {
7794            return Err(McpError::invalid_params(
7795                MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
7796            ));
7797        }
7798        admit_final_resource_uri(
7799            resolved.uri_use_policy,
7800            &absolute,
7801            FinalResourceUriUse::ResourceReadTarget,
7802        )?;
7803        let handler_timeout = read_handler_timeout(parent_ctx.cx(), "resource_timeout", || {
7804            resolved.handler.timeout()
7805        })?;
7806        let effective_budget = compose_handler_budget(
7807            parent_ctx.cx().budget(),
7808            parent_ctx.budget(),
7809            handler_timeout,
7810            operation_started_at,
7811        );
7812        let child_ctx = with_nested_component_access(
7813            parent_ctx
7814                .clone()
7815                .with_operation_deadline(effective_budget.deadline),
7816            router_access,
7817            session_state,
7818            depth,
7819        );
7820        let outcome = run_handler_in_request(
7821            &child_ctx,
7822            parent_ctx.cx(),
7823            effective_budget,
7824            "resource",
7825            |request_cx| {
7826                resolved
7827                    .handler
7828                    .read_final_outcome_async_with_uri_in_request(
7829                        &child_ctx,
7830                        request_cx,
7831                        &uri,
7832                        &resolved.params,
7833                    )
7834            },
7835        )
7836        .await?;
7837        let result = match outcome {
7838            Outcome::Ok(result) => result,
7839            Outcome::Err(error) => {
7840                return Err(sanitize_handler_error(parent_ctx.cx(), "resource", error));
7841            }
7842            Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
7843            Outcome::Panicked(_payload) => {
7844                return Err(sanitized_handler_panic(parent_ctx.cx(), "resource"));
7845            }
7846        };
7847        admit_final_resource_read_outcome(resolved.uri_use_policy, &result)?;
7848        let FinalMethodOutcome::Complete(complete) = result else {
7849            return Err(McpError::internal_error(
7850                "nested resource read cannot complete an input_required outcome",
7851            ));
7852        };
7853        Ok(ResourceReadResult::new(
7854            complete
7855                .payload
7856                .contents
7857                .into_iter()
7858                .map(nested_resource_content_from_final)
7859                .collect(),
7860        ))
7861    }
7862}
7863
7864impl ResourceReader for RouterResourceReader {
7865    fn read_resource<'a>(
7866        &'a self,
7867        parent_ctx: &'a McpContext,
7868        uri: &'a str,
7869        depth: u32,
7870    ) -> Pin<
7871        Box<
7872            dyn std::future::Future<Output = fastmcp_core::McpResult<ResourceReadResult>>
7873                + Send
7874                + 'a,
7875        >,
7876    > {
7877        // Check recursion depth
7878        if depth > MAX_RESOURCE_READ_DEPTH {
7879            return Box::pin(async move {
7880                Err(McpError::new(
7881                    McpErrorCode::InternalError,
7882                    format!(
7883                        "Maximum resource read depth ({}) exceeded",
7884                        MAX_RESOURCE_READ_DEPTH
7885                    ),
7886                ))
7887            });
7888        }
7889
7890        // Clone what we need for the async block
7891        let parent_ctx = parent_ctx.clone();
7892        let uri = uri.to_string();
7893        let router_access = self.router.clone();
7894        let session_state = self.session_state.clone();
7895        let final_surface = parent_ctx.is_final_request_surface();
7896
7897        Box::pin(async move {
7898            debug!(
7899                target: targets::HANDLER,
7900                "cross-component resource read; resource_key={}; depth={}; request={}; final_surface={}",
7901                safe_log_label(&uri),
7902                depth,
7903                parent_ctx.request_id(),
7904                final_surface
7905            );
7906            let router = router_access.upgrade()?;
7907            if final_surface {
7908                Self::read_resource_final(
7909                    &router,
7910                    &parent_ctx,
7911                    router_access,
7912                    session_state,
7913                    uri,
7914                    depth,
7915                )
7916                .await
7917            } else {
7918                Self::read_resource_legacy(
7919                    &router,
7920                    &parent_ctx,
7921                    router_access,
7922                    session_state,
7923                    uri,
7924                    depth,
7925                )
7926                .await
7927            }
7928        })
7929    }
7930}
7931
7932// ============================================================================
7933// Tool Caller Implementation
7934// ============================================================================
7935
7936use fastmcp_core::{MAX_TOOL_CALL_DEPTH, ToolCaller};
7937
7938/// A wrapper that implements `ToolCaller` for a shared `Router`.
7939///
7940/// This allows handlers to call other tools from within tool/resource/prompt
7941/// handlers, enabling cross-component access.
7942pub(crate) struct RouterToolCaller {
7943    /// Access to the router without extending a server request's lifetime.
7944    router: RouterAccess,
7945    /// Session state for handlers.
7946    session_state: SessionState,
7947}
7948
7949impl RouterToolCaller {
7950    /// Creates a new tool caller with the given router and session state.
7951    #[must_use]
7952    pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
7953        Self {
7954            router: RouterAccess::Shared(router),
7955            session_state,
7956        }
7957    }
7958
7959    pub(crate) fn request_scoped(router: Weak<Router>, session_state: SessionState) -> Self {
7960        Self {
7961            router: RouterAccess::RequestScoped(router),
7962            session_state,
7963        }
7964    }
7965
7966    fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
7967        Self {
7968            router,
7969            session_state,
7970        }
7971    }
7972
7973    async fn call_tool_legacy(
7974        router: &Router,
7975        parent_ctx: &McpContext,
7976        router_access: RouterAccess,
7977        session_state: SessionState,
7978        name: String,
7979        args: serde_json::Value,
7980        depth: u32,
7981    ) -> McpResult<ToolCallResult> {
7982        let operation_started_at = parent_ctx.cx().now();
7983        if let Some(error) = budget_error(parent_ctx) {
7984            return Err(error);
7985        }
7986        if !session_state.is_tool_enabled(&name) {
7987            return Err(McpError::new(
7988                McpErrorCode::MethodNotFound,
7989                format!("Tool '{name}' is disabled for this session"),
7990            ));
7991        }
7992        let entry = router
7993            .tools
7994            .get(&name)
7995            .ok_or_else(|| McpError::method_not_found(&format!("tool: {name}")))?;
7996        if !entry.legacy_enabled {
7997            return Err(McpError::method_not_found(&format!("tool: {name}")));
7998        }
7999        let handler = &entry.handler;
8000        let validation_result = if router.strict_input_validation {
8001            validate_strict(&entry.definition.input_schema, &args)
8002        } else {
8003            validate(&entry.definition.input_schema, &args)
8004        };
8005        if let Err(validation_errors) = validation_result {
8006            let error_messages: Vec<String> = validation_errors
8007                .iter()
8008                .map(|error| format!("{}: {}", error.path, error.message))
8009                .collect();
8010            return Err(McpError::invalid_params(format!(
8011                "Input validation failed: {}",
8012                error_messages.join("; ")
8013            )));
8014        }
8015        let handler_timeout =
8016            read_handler_timeout(parent_ctx.cx(), "tool_timeout", || handler.timeout())?;
8017        let effective_budget = compose_handler_budget(
8018            parent_ctx.cx().budget(),
8019            parent_ctx.budget(),
8020            handler_timeout,
8021            operation_started_at,
8022        );
8023        let child_ctx = with_nested_component_access(
8024            parent_ctx
8025                .clone()
8026                .with_operation_deadline(effective_budget.deadline),
8027            router_access.clone(),
8028            session_state.clone(),
8029            depth,
8030        );
8031        let outcome = run_handler_in_request(
8032            &child_ctx,
8033            parent_ctx.cx(),
8034            effective_budget,
8035            "tool",
8036            |request_cx| handler.call_async_in_request(&child_ctx, request_cx, args),
8037        )
8038        .await?;
8039        match outcome {
8040            Outcome::Ok(content) => Ok(ToolCallResult::success(
8041                content
8042                    .into_iter()
8043                    .map(|content| match content {
8044                        Content::Text { text } => ToolContentItem::Text { text },
8045                        Content::Image { data, mime_type } => {
8046                            ToolContentItem::Image { data, mime_type }
8047                        }
8048                        Content::Audio { data, mime_type } => {
8049                            ToolContentItem::Audio { data, mime_type }
8050                        }
8051                        Content::Resource { resource } => ToolContentItem::Resource {
8052                            uri: resource.uri,
8053                            mime_type: resource.mime_type,
8054                            text: resource.text,
8055                            blob: resource.blob,
8056                        },
8057                    })
8058                    .collect(),
8059            )),
8060            Outcome::Err(error) => {
8061                let error = sanitize_handler_error(parent_ctx.cx(), "tool", error);
8062                if is_framework_terminal_tool_error(error.code) {
8063                    return Err(error);
8064                }
8065                Ok(ToolCallResult::error(error.message))
8066            }
8067            Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
8068            Outcome::Panicked(_payload) => Err(sanitized_handler_panic(parent_ctx.cx(), "tool")),
8069        }
8070    }
8071
8072    async fn call_tool_final(
8073        router: &Router,
8074        parent_ctx: &McpContext,
8075        router_access: RouterAccess,
8076        session_state: SessionState,
8077        name: String,
8078        args: serde_json::Value,
8079        depth: u32,
8080    ) -> McpResult<ToolCallResult> {
8081        let operation_started_at = parent_ctx.cx().now();
8082        if let Some(error) = budget_error(parent_ctx) {
8083            return Err(error);
8084        }
8085        if !session_state.is_tool_enabled(&name) || !parent_ctx.is_tool_enabled(&name) {
8086            return Err(McpError::new(
8087                McpErrorCode::MethodNotFound,
8088                format!("Tool '{name}' is disabled for this session"),
8089            ));
8090        }
8091        let entry = router
8092            .tools
8093            .get(&name)
8094            .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {name}")))?;
8095        let final_registration = entry
8096            .final_registration
8097            .as_ref()
8098            .ok_or_else(|| McpError::invalid_params(format!("Unknown tool: {name}")))?;
8099        let handler = &entry.handler;
8100        if handler.declares_final_mrtr() && parent_ctx.session_cache_partition().is_none() {
8101            return Err(McpError::invalid_params(
8102                MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
8103            ));
8104        }
8105        let input_schema = final_registration.schemas.input.as_ref();
8106        let input_validation_failed = match input_schema {
8107            Some(schema) => {
8108                let validation = if router.strict_input_validation {
8109                    validate_strict(schema.schema(), &args)
8110                } else {
8111                    schema.validate(&args)
8112                };
8113                validation.is_err()
8114            }
8115            None if router.strict_input_validation => {
8116                validate_strict(&final_registration.final_definition.input_schema, &args).is_err()
8117            }
8118            None => false,
8119        };
8120        if input_validation_failed {
8121            return Ok(ToolCallResult::error(
8122                "Tool arguments do not match the declared input schema.",
8123            ));
8124        }
8125        let handler_timeout =
8126            read_handler_timeout(parent_ctx.cx(), "tool_timeout", || handler.timeout())?;
8127        let effective_budget = compose_handler_budget(
8128            parent_ctx.cx().budget(),
8129            parent_ctx.budget(),
8130            handler_timeout,
8131            operation_started_at,
8132        );
8133        let child_ctx = with_nested_component_access(
8134            parent_ctx
8135                .clone()
8136                .with_operation_deadline(effective_budget.deadline),
8137            router_access,
8138            session_state,
8139            depth,
8140        );
8141        let outcome = run_handler_in_request(
8142            &child_ctx,
8143            parent_ctx.cx(),
8144            effective_budget,
8145            "tool",
8146            |request_cx| {
8147                handler.call_final_outcome_async_resuming_in_request(
8148                    &child_ctx, request_cx, args, None,
8149                )
8150            },
8151        )
8152        .await?;
8153        match outcome {
8154            Outcome::Ok(result) => match result {
8155                FinalToolOutcome::Complete(complete) => Ok(ToolCallResult {
8156                    content: complete
8157                        .payload
8158                        .content
8159                        .into_iter()
8160                        .map(nested_tool_content_from_final)
8161                        .collect(),
8162                    is_error: complete.payload.is_error,
8163                }),
8164                FinalToolOutcome::InputRequired(_) => Err(McpError::internal_error(
8165                    "nested tool call cannot complete an input_required outcome",
8166                )),
8167                #[cfg(feature = "tasks")]
8168                FinalToolOutcome::CreateTask { .. } => Err(McpError::internal_error(
8169                    "nested tool call cannot create a final task",
8170                )),
8171            },
8172            Outcome::Err(error) => {
8173                let error = sanitize_handler_error(parent_ctx.cx(), "tool", error);
8174                if is_framework_terminal_tool_error(error.code) {
8175                    return Err(error);
8176                }
8177                Ok(ToolCallResult::error(error.message))
8178            }
8179            Outcome::Cancelled(_) => Err(McpError::request_cancelled()),
8180            Outcome::Panicked(_payload) => Err(sanitized_handler_panic(parent_ctx.cx(), "tool")),
8181        }
8182    }
8183}
8184
8185impl ToolCaller for RouterToolCaller {
8186    fn call_tool<'a>(
8187        &'a self,
8188        parent_ctx: &'a McpContext,
8189        name: &'a str,
8190        args: serde_json::Value,
8191        depth: u32,
8192    ) -> Pin<
8193        Box<dyn std::future::Future<Output = fastmcp_core::McpResult<ToolCallResult>> + Send + 'a>,
8194    > {
8195        // Check recursion depth
8196        if depth > MAX_TOOL_CALL_DEPTH {
8197            return Box::pin(async move {
8198                Err(McpError::new(
8199                    McpErrorCode::InternalError,
8200                    format!("Maximum tool call depth ({}) exceeded", MAX_TOOL_CALL_DEPTH),
8201                ))
8202            });
8203        }
8204
8205        // Clone what we need for the async block
8206        let parent_ctx = parent_ctx.clone();
8207        let name = name.to_string();
8208        let router_access = self.router.clone();
8209        let session_state = self.session_state.clone();
8210        let final_surface = parent_ctx.is_final_request_surface();
8211
8212        Box::pin(async move {
8213            debug!(
8214                target: targets::HANDLER,
8215                "cross-component tool call; tool_key={}; depth={}; request={}; final_surface={}",
8216                safe_log_label(&name),
8217                depth,
8218                parent_ctx.request_id(),
8219                final_surface
8220            );
8221            let router = router_access.upgrade()?;
8222            if final_surface {
8223                Self::call_tool_final(
8224                    &router,
8225                    &parent_ctx,
8226                    router_access,
8227                    session_state,
8228                    name,
8229                    args,
8230                    depth,
8231                )
8232                .await
8233            } else {
8234                Self::call_tool_legacy(
8235                    &router,
8236                    &parent_ctx,
8237                    router_access,
8238                    session_state,
8239                    name,
8240                    args,
8241                    depth,
8242                )
8243                .await
8244            }
8245        })
8246    }
8247}
8248
8249fn with_nested_component_access(
8250    ctx: McpContext,
8251    router: RouterAccess,
8252    state: SessionState,
8253    depth: u32,
8254) -> McpContext {
8255    ctx.with_resource_read_depth(depth)
8256        .with_tool_call_depth(depth)
8257        .with_prompt_get_depth(depth)
8258        .with_tool_caller(Arc::new(RouterToolCaller::from_access(
8259            router.clone(),
8260            state.clone(),
8261        )))
8262        .with_resource_reader(Arc::new(RouterResourceReader::from_access(
8263            router.clone(),
8264            state.clone(),
8265        )))
8266        .with_prompt_caller(Arc::new(RouterPromptCaller::from_access(router, state)))
8267}
8268
8269// ============================================================================
8270// Prompt Caller Implementation
8271// ============================================================================
8272
8273pub(crate) struct RouterPromptCaller {
8274    router: RouterAccess,
8275    session_state: SessionState,
8276}
8277
8278impl RouterPromptCaller {
8279    #[must_use]
8280    pub(crate) fn new(router: Arc<Router>, session_state: SessionState) -> Self {
8281        Self {
8282            router: RouterAccess::Shared(router),
8283            session_state,
8284        }
8285    }
8286
8287    fn from_access(router: RouterAccess, session_state: SessionState) -> Self {
8288        Self {
8289            router,
8290            session_state,
8291        }
8292    }
8293}
8294
8295impl PromptCaller for RouterPromptCaller {
8296    fn get_prompt<'a>(
8297        &'a self,
8298        parent_ctx: &'a McpContext,
8299        name: &'a str,
8300        arguments: HashMap<String, String>,
8301        depth: u32,
8302    ) -> Pin<
8303        Box<dyn std::future::Future<Output = fastmcp_core::McpResult<PromptGetResult>> + Send + 'a>,
8304    > {
8305        if depth > fastmcp_core::MAX_PROMPT_GET_DEPTH {
8306            return Box::pin(async move {
8307                Err(McpError::new(
8308                    McpErrorCode::InternalError,
8309                    format!(
8310                        "Maximum prompt get depth ({}) exceeded",
8311                        fastmcp_core::MAX_PROMPT_GET_DEPTH
8312                    ),
8313                ))
8314            });
8315        }
8316
8317        let parent_ctx = parent_ctx.clone();
8318        let name = name.to_string();
8319        let router_access = self.router.clone();
8320        let session_state = self.session_state.clone();
8321        let final_surface = parent_ctx.is_final_request_surface();
8322
8323        Box::pin(async move {
8324            debug!(
8325                target: targets::HANDLER,
8326                "cross-component prompt get; prompt_key={}; depth={}; request={}; final_surface={}",
8327                safe_log_label(&name),
8328                depth,
8329                parent_ctx.request_id(),
8330                final_surface
8331            );
8332            let router = router_access.upgrade()?;
8333            if final_surface {
8334                Self::get_prompt_final(
8335                    &router,
8336                    &parent_ctx,
8337                    router_access,
8338                    session_state,
8339                    name,
8340                    arguments,
8341                    depth,
8342                )
8343                .await
8344            } else {
8345                Self::get_prompt_legacy(
8346                    &router,
8347                    &parent_ctx,
8348                    router_access,
8349                    session_state,
8350                    name,
8351                    arguments,
8352                    depth,
8353                )
8354                .await
8355            }
8356        })
8357    }
8358}
8359
8360impl RouterPromptCaller {
8361    async fn get_prompt_legacy(
8362        router: &Router,
8363        parent_ctx: &McpContext,
8364        router_access: RouterAccess,
8365        session_state: SessionState,
8366        name: String,
8367        arguments: HashMap<String, String>,
8368        depth: u32,
8369    ) -> McpResult<PromptGetResult> {
8370        let operation_started_at = parent_ctx.cx().now();
8371        if let Some(error) = budget_error(parent_ctx) {
8372            return Err(error);
8373        }
8374        if !session_state.is_prompt_enabled(&name) {
8375            return Err(McpError::new(
8376                McpErrorCode::PromptNotFound,
8377                format!("Prompt '{name}' is disabled for this session"),
8378            ));
8379        }
8380        let handler = router.prompts.get(&name).ok_or_else(|| {
8381            McpError::new(
8382                McpErrorCode::PromptNotFound,
8383                format!("Prompt not found: {name}"),
8384            )
8385        })?;
8386        if router.final_only_prompts.contains(&name) {
8387            return Err(McpError::new(
8388                McpErrorCode::PromptNotFound,
8389                format!("Prompt not found: {name}"),
8390            ));
8391        }
8392        let description = crate::catch_extension_unwind(|| handler.definition().description)
8393            .map_err(|_payload| sanitized_handler_panic(parent_ctx.cx(), "prompt_definition"))?;
8394        let handler_timeout =
8395            read_handler_timeout(parent_ctx.cx(), "prompt_timeout", || handler.timeout())?;
8396        let effective_budget = compose_handler_budget(
8397            parent_ctx.cx().budget(),
8398            parent_ctx.budget(),
8399            handler_timeout,
8400            operation_started_at,
8401        );
8402        let child_ctx = with_nested_component_access(
8403            parent_ctx
8404                .clone()
8405                .with_operation_deadline(effective_budget.deadline),
8406            router_access,
8407            session_state,
8408            depth,
8409        );
8410        let outcome = run_handler_in_request(
8411            &child_ctx,
8412            parent_ctx.cx(),
8413            effective_budget,
8414            "prompt",
8415            |request_cx| handler.get_async_in_request(&child_ctx, request_cx, arguments),
8416        )
8417        .await?;
8418        let messages = match outcome {
8419            Outcome::Ok(messages) => messages,
8420            Outcome::Err(error) => {
8421                return Err(sanitize_handler_error(parent_ctx.cx(), "prompt", error));
8422            }
8423            Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
8424            Outcome::Panicked(_payload) => {
8425                return Err(sanitized_handler_panic(parent_ctx.cx(), "prompt"));
8426            }
8427        };
8428        Ok(PromptGetResult {
8429            description,
8430            messages: messages
8431                .into_iter()
8432                .map(|message| PromptMessageItem {
8433                    role: match message.role {
8434                        Role::User => PromptMessageRole::User,
8435                        Role::Assistant => PromptMessageRole::Assistant,
8436                    },
8437                    text: match message.content {
8438                        Content::Text { text } => Some(text),
8439                        _ => None,
8440                    },
8441                })
8442                .collect(),
8443        })
8444    }
8445
8446    async fn get_prompt_final(
8447        router: &Router,
8448        parent_ctx: &McpContext,
8449        router_access: RouterAccess,
8450        session_state: SessionState,
8451        name: String,
8452        arguments: HashMap<String, String>,
8453        depth: u32,
8454    ) -> McpResult<PromptGetResult> {
8455        let operation_started_at = parent_ctx.cx().now();
8456        if let Some(error) = budget_error(parent_ctx) {
8457            return Err(error);
8458        }
8459        if !session_state.is_prompt_enabled(&name) || !parent_ctx.is_prompt_enabled(&name) {
8460            return Err(McpError::new(
8461                McpErrorCode::PromptNotFound,
8462                format!("Prompt '{name}' is disabled for this session"),
8463            ));
8464        }
8465        let handler = router
8466            .prompts
8467            .get(&name)
8468            .ok_or_else(|| McpError::invalid_params(format!("Unknown prompt: {name}")))?;
8469        let final_registration = router.final_prompts.get(&name).ok_or_else(|| {
8470            McpError::invalid_params("prompt is registered only for exact MCP 2024-11-05 dispatch")
8471        })?;
8472        if handler.declares_final_mrtr() && parent_ctx.session_cache_partition().is_none() {
8473            return Err(McpError::invalid_params(
8474                MRTR_REQUIRES_BOUND_MODERN_CONNECTION,
8475            ));
8476        }
8477        admit_declared_final_prompt_arguments(
8478            final_registration
8479                .definition
8480                .arguments
8481                .as_deref()
8482                .unwrap_or_default(),
8483            arguments.keys(),
8484        )?;
8485        let handler_timeout =
8486            read_handler_timeout(parent_ctx.cx(), "prompt_timeout", || handler.timeout())?;
8487        let effective_budget = compose_handler_budget(
8488            parent_ctx.cx().budget(),
8489            parent_ctx.budget(),
8490            handler_timeout,
8491            operation_started_at,
8492        );
8493        let child_ctx = with_nested_component_access(
8494            parent_ctx
8495                .clone()
8496                .with_operation_deadline(effective_budget.deadline),
8497            router_access,
8498            session_state,
8499            depth,
8500        );
8501        let outcome = run_handler_in_request(
8502            &child_ctx,
8503            parent_ctx.cx(),
8504            effective_budget,
8505            "prompt",
8506            |request_cx| {
8507                handler.get_final_outcome_async_in_request(&child_ctx, request_cx, arguments)
8508            },
8509        )
8510        .await?;
8511        let result = match outcome {
8512            Outcome::Ok(result) => result,
8513            Outcome::Err(error) => {
8514                return Err(sanitize_handler_error(parent_ctx.cx(), "prompt", error));
8515            }
8516            Outcome::Cancelled(_) => return Err(McpError::request_cancelled()),
8517            Outcome::Panicked(_payload) => {
8518                return Err(sanitized_handler_panic(parent_ctx.cx(), "prompt"));
8519            }
8520        };
8521        admit_final_prompt_outcome(final_registration.uri_use_policy, &result)?;
8522        let FinalMethodOutcome::Complete(complete) = result else {
8523            return Err(McpError::internal_error(
8524                "nested prompt get cannot complete an input_required outcome",
8525            ));
8526        };
8527        Ok(PromptGetResult {
8528            description: complete.payload.description,
8529            messages: complete
8530                .payload
8531                .messages
8532                .into_iter()
8533                .map(|message| PromptMessageItem {
8534                    role: match message.role {
8535                        Role::User => PromptMessageRole::User,
8536                        Role::Assistant => PromptMessageRole::Assistant,
8537                    },
8538                    text: match message.content {
8539                        fastmcp_protocol::common_types::ContentBlock::Text { text, .. } => {
8540                            Some(text)
8541                        }
8542                        _ => None,
8543                    },
8544                })
8545                .collect(),
8546        })
8547    }
8548}
8549
8550#[cfg(test)]
8551mod safe_log_label_tests {
8552    use super::{LOG_LABEL_HASH_INPUT_LIMIT, safe_log_label};
8553
8554    #[test]
8555    fn safe_label_is_deterministic_non_verbatim_metadata() {
8556        let canary = "router-log-canary-secret";
8557        let first = format!("{}", safe_log_label(canary));
8558        let second = format!("{:?}", safe_log_label(canary));
8559
8560        assert_eq!(first, second);
8561        assert!(first.contains(&format!("bytes={}", canary.len())));
8562        assert!(first.contains("sha256_prefix="));
8563        assert!(!first.contains(canary));
8564        assert_ne!(first, format!("{}", safe_log_label("different-label")));
8565    }
8566
8567    #[test]
8568    fn safe_label_hashing_is_bounded_for_oversized_input() {
8569        let oversized = "x".repeat(LOG_LABEL_HASH_INPUT_LIMIT + 37);
8570        let rendered = format!("{}", safe_log_label(&oversized));
8571
8572        assert!(rendered.contains(&format!("bytes={}", oversized.len())));
8573        assert!(rendered.contains(&format!("hashed_prefix_bytes={LOG_LABEL_HASH_INPUT_LIMIT}")));
8574        assert!(!rendered.contains(&oversized));
8575    }
8576
8577    #[test]
8578    fn source_has_no_verbatim_argument_or_label_log_formats() {
8579        let source = include_str!("router.rs");
8580        let forbidden = [
8581            concat!("Tool ", "arguments:"),
8582            concat!("Prompt ", "arguments:"),
8583            concat!("Calling ", "tool: {}"),
8584            concat!("Reading ", "resource: {}"),
8585            concat!("Getting ", "prompt: {}"),
8586            concat!("Cross-component tool ", "call: {}"),
8587            concat!("Cross-component resource ", "read: {}"),
8588            concat!("Invalid URI template ", "'{}'"),
8589        ];
8590
8591        for format in forbidden {
8592            assert!(!source.contains(format), "raw log format remains: {format}");
8593        }
8594    }
8595}
8596
8597#[cfg(test)]
8598mod cursor_tests {
8599    use super::{
8600        FinalCatalogKind, FinalCatalogQuery, decode_cursor_offset,
8601        decode_final_catalog_cursor_offset, encode_cursor_offset, encode_final_catalog_cursor,
8602    };
8603
8604    #[test]
8605    fn roundtrip_zero() {
8606        let encoded = encode_cursor_offset(0);
8607        let decoded = decode_cursor_offset(Some(&encoded)).unwrap();
8608        assert_eq!(decoded, 0);
8609    }
8610
8611    #[test]
8612    fn roundtrip_large_offset() {
8613        let encoded = encode_cursor_offset(12345);
8614        let decoded = decode_cursor_offset(Some(&encoded)).unwrap();
8615        assert_eq!(decoded, 12345);
8616    }
8617
8618    #[test]
8619    fn none_cursor_returns_zero() {
8620        assert_eq!(decode_cursor_offset(None).unwrap(), 0);
8621    }
8622
8623    #[test]
8624    fn invalid_base64_returns_error() {
8625        let err = decode_cursor_offset(Some("not-valid-base64!!!")).unwrap_err();
8626        assert!(err.message.contains("base64"));
8627    }
8628
8629    #[test]
8630    fn valid_base64_but_not_json_returns_error() {
8631        let encoded =
8632            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"not json");
8633        let err = decode_cursor_offset(Some(&encoded)).unwrap_err();
8634        assert!(err.message.contains("JSON"));
8635    }
8636
8637    #[test]
8638    fn valid_json_but_no_offset_returns_error() {
8639        let payload = serde_json::json!({"other": 1});
8640        let bytes = serde_json::to_vec(&payload).unwrap();
8641        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes);
8642        let err = decode_cursor_offset(Some(&encoded)).unwrap_err();
8643        assert!(err.message.contains("offset"));
8644    }
8645
8646    #[test]
8647    fn final_catalog_cursor_binds_the_catalog_revision_and_query() {
8648        let include_tags = vec!["Visible".to_owned(), "visible".to_owned()];
8649        let exclude_tags = vec!["excluded".to_owned()];
8650        let query = FinalCatalogQuery::from_tag_filters(Some(&include_tags), Some(&exclude_tags));
8651        let cursor = encode_final_catalog_cursor(FinalCatalogKind::Resources, 41, &query, 7, None);
8652        let equivalent_include_tags = vec!["visible".to_owned()];
8653        let equivalent_query = FinalCatalogQuery::from_tag_filters(
8654            Some(&equivalent_include_tags),
8655            Some(&exclude_tags),
8656        );
8657        assert_eq!(
8658            decode_final_catalog_cursor_offset(
8659                Some(&cursor),
8660                FinalCatalogKind::Resources,
8661                41,
8662                &equivalent_query,
8663                None,
8664                8,
8665            )
8666            .expect("a final cursor accepts a semantically equivalent canonical query"),
8667            7
8668        );
8669        let stale = decode_final_catalog_cursor_offset(
8670            Some(&cursor),
8671            FinalCatalogKind::Resources,
8672            42,
8673            &query,
8674            None,
8675            8,
8676        )
8677        .expect_err("changing only the catalog revision rejects a stale continuation");
8678        assert!(stale.message.contains("stale catalog revision"));
8679
8680        let wrong_kind = decode_final_catalog_cursor_offset(
8681            Some(&cursor),
8682            FinalCatalogKind::Prompts,
8683            41,
8684            &query,
8685            None,
8686            8,
8687        )
8688        .expect_err("changing only the list method rejects a cross-catalog continuation");
8689        assert!(wrong_kind.message.contains("another list method"));
8690
8691        let other_include_tags = vec!["other".to_owned()];
8692        let other_query =
8693            FinalCatalogQuery::from_tag_filters(Some(&other_include_tags), Some(&exclude_tags));
8694        let wrong_query = decode_final_catalog_cursor_offset(
8695            Some(&cursor),
8696            FinalCatalogKind::Resources,
8697            41,
8698            &other_query,
8699            None,
8700            8,
8701        )
8702        .expect_err("changing only the request filters rejects the continuation");
8703        assert!(wrong_query.message.contains("query filters"));
8704
8705        let out_of_range =
8706            encode_final_catalog_cursor(FinalCatalogKind::Resources, 41, &query, 8, None);
8707        let range_error = decode_final_catalog_cursor_offset(
8708            Some(&out_of_range),
8709            FinalCatalogKind::Resources,
8710            41,
8711            &query,
8712            None,
8713            8,
8714        )
8715        .expect_err("an offset at the end of a catalog is never a router-minted continuation");
8716        assert!(
8717            range_error
8718                .message
8719                .contains("outside the requested catalog page")
8720        );
8721
8722        let visible =
8723            encode_final_catalog_cursor(FinalCatalogKind::Tools, 41, &query, 0, Some("digest-a"));
8724        let visibility_error = decode_final_catalog_cursor_offset(
8725            Some(&visible),
8726            FinalCatalogKind::Tools,
8727            41,
8728            &query,
8729            Some("digest-b"),
8730            2,
8731        )
8732        .expect_err("changing only session visibility rejects the continuation");
8733        assert!(visibility_error.message.contains("session visibility"));
8734        assert_eq!(
8735            decode_final_catalog_cursor_offset(
8736                Some(&visible),
8737                FinalCatalogKind::Tools,
8738                41,
8739                &query,
8740                Some("digest-a"),
8741                2,
8742            )
8743            .expect("matching session visibility admits the continuation"),
8744            0
8745        );
8746    }
8747}
8748
8749#[cfg(test)]
8750mod tag_filter_tests {
8751    use super::TagFilters;
8752
8753    #[test]
8754    fn no_filters_matches_anything() {
8755        let f = TagFilters::default();
8756        assert!(f.matches(&[]));
8757        assert!(f.matches(&["a".to_string()]));
8758    }
8759
8760    #[test]
8761    fn include_filter_requires_all_tags() {
8762        let include = vec!["a".to_string(), "b".to_string()];
8763        let f = TagFilters::new(Some(&include), None);
8764        assert!(f.matches(&["a".to_string(), "b".to_string(), "c".to_string()]));
8765        assert!(!f.matches(&["a".to_string()])); // missing "b"
8766    }
8767
8768    #[test]
8769    fn exclude_filter_rejects_any_tag() {
8770        let exclude = vec!["x".to_string()];
8771        let f = TagFilters::new(None, Some(&exclude));
8772        assert!(f.matches(&["a".to_string(), "b".to_string()]));
8773        assert!(!f.matches(&["a".to_string(), "x".to_string()]));
8774    }
8775
8776    #[test]
8777    fn include_and_exclude_combined() {
8778        let include = vec!["a".to_string()];
8779        let exclude = vec!["b".to_string()];
8780        let f = TagFilters::new(Some(&include), Some(&exclude));
8781        assert!(f.matches(&["a".to_string()]));
8782        assert!(!f.matches(&["a".to_string(), "b".to_string()])); // excluded
8783        assert!(!f.matches(&["c".to_string()])); // missing "a"
8784    }
8785
8786    #[test]
8787    fn case_insensitive_matching() {
8788        let include = vec!["Alpha".to_string()];
8789        let f = TagFilters::new(Some(&include), None);
8790        assert!(f.matches(&["alpha".to_string()]));
8791        assert!(f.matches(&["ALPHA".to_string()]));
8792    }
8793
8794    #[test]
8795    fn empty_include_array_passes_all() {
8796        let include: Vec<String> = vec![];
8797        let f = TagFilters::new(Some(&include), None);
8798        assert!(f.matches(&[]));
8799        assert!(f.matches(&["anything".to_string()]));
8800    }
8801
8802    #[test]
8803    fn tag_filters_debug() {
8804        let f = TagFilters::default();
8805        let debug = format!("{:?}", f);
8806        assert!(debug.contains("TagFilters"));
8807    }
8808}
8809
8810#[cfg(test)]
8811mod router_tests {
8812    use super::*;
8813    use crate::bidirectional::MrtrInputResponse;
8814    use crate::handler::{
8815        CompletionHandler, DEFAULT_FINAL_RESOURCE_TTL_MS, FinalElicitationContextExt,
8816        FinalToolSchemaAuthority, PromptHandler, ResourceHandler, ToolHandler,
8817        UpstreamFinalToolSchemaRegistration,
8818    };
8819    use crate::http_admission::{HttpAdmissionLimits, HttpEndpointConfig, admit_modern_post};
8820    #[cfg(feature = "tasks")]
8821    use crate::tasks::{
8822        ApplicationTaskSupervisor, FinalTaskSupervisorFuture, FinalTaskSupervisorHandoff,
8823        FinalTaskWorkDescriptor,
8824    };
8825    #[cfg(feature = "tasks")]
8826    use crate::{FinalTaskRuntimeConfig, FinalTaskStore, InMemoryFinalTaskStore};
8827    use asupersync::channel::oneshot;
8828    use asupersync::runtime::{RuntimeBuilder, RuntimeHandle};
8829    use asupersync::types::CancelKind;
8830    use fastmcp_core::{ClientCapabilityInfo, McpContext, McpResult, SessionState};
8831    use fastmcp_protocol::common_types::{
8832        Annotations, ContentBlock, EmbeddedResourceContents, OpenMetadata, RawIcon,
8833    };
8834    use fastmcp_protocol::{
8835        CompleteResult, CompletionValues, Content, CoreResultDiscriminatorPolicy, DecodedResult,
8836        FinalCallToolResult, FinalCompletionParams, FinalGetPromptResult, FinalPromptMessage,
8837        LegacyCompletionParams, LegacyResourceContent, Prompt, PromptArgument, PromptMessage,
8838        Resource, ResourceContent, ResourceTemplate, ResultPeerEra, Tool, decode_peer_result,
8839    };
8840    use std::collections::BTreeMap;
8841    use std::fmt;
8842    #[cfg(feature = "tasks")]
8843    use std::future::Future;
8844    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
8845    use std::sync::{Arc, Mutex};
8846    use std::task::Poll;
8847
8848    fn request_context(
8849        cx: &Cx,
8850        request_id: u64,
8851        budget: Budget,
8852        state: &SessionState,
8853    ) -> McpContext {
8854        McpContext::with_state(cx.clone(), request_id, state.clone()).with_budget_ceiling(budget)
8855    }
8856
8857    async fn yield_once() {
8858        let mut yielded = false;
8859        std::future::poll_fn(|task_cx| {
8860            if std::mem::replace(&mut yielded, true) {
8861                Poll::Ready(())
8862            } else {
8863                task_cx.waker().wake_by_ref();
8864                Poll::Pending
8865            }
8866        })
8867        .await;
8868    }
8869
8870    #[test]
8871    fn legacy_result_adapters_emit_exact_2024_defaults() {
8872        let tool = CallToolResult {
8873            content: legacy_contents_from_handler(vec![
8874                Content::Text {
8875                    text: "ready".to_owned(),
8876                },
8877                Content::Image {
8878                    data: "aGVsbG8=".to_owned(),
8879                    mime_type: "image/png".to_owned(),
8880                },
8881                Content::Resource {
8882                    resource: ResourceContent {
8883                        uri: "file:///tool.txt".to_owned(),
8884                        mime_type: Some("text/plain".to_owned()),
8885                        text: Some("tool resource".to_owned()),
8886                        blob: None,
8887                    },
8888                },
8889            ])
8890            .expect("handler content is representable by the exact legacy union"),
8891            is_error: false,
8892            meta: None,
8893            additional: BTreeMap::new(),
8894        };
8895        assert_eq!(
8896            serde_json::to_value(tool).expect("legacy tool result serializes"),
8897            serde_json::json!({
8898                "content": [
8899                    {"type": "text", "text": "ready"},
8900                    {"type": "image", "data": "aGVsbG8=", "mimeType": "image/png"},
8901                    {
8902                        "type": "resource",
8903                        "resource": {
8904                            "uri": "file:///tool.txt",
8905                            "mimeType": "text/plain",
8906                            "text": "tool resource"
8907                        }
8908                    }
8909                ]
8910            })
8911        );
8912
8913        let resource = ReadResourceResult {
8914            contents: legacy_resource_contents_from_handler(vec![ResourceContent {
8915                uri: "file:///report.bin".to_owned(),
8916                mime_type: Some("application/octet-stream".to_owned()),
8917                text: None,
8918                blob: Some("AAEC".to_owned()),
8919            }])
8920            .expect("handler resource is representable by the exact legacy union"),
8921            meta: None,
8922            additional: BTreeMap::new(),
8923        };
8924        assert_eq!(
8925            serde_json::to_value(resource).expect("legacy resource result serializes"),
8926            serde_json::json!({
8927                "contents": [{
8928                    "uri": "file:///report.bin",
8929                    "mimeType": "application/octet-stream",
8930                    "blob": "AAEC"
8931                }]
8932            })
8933        );
8934
8935        let prompt = GetPromptResult {
8936            description: Some("ask a question".to_owned()),
8937            messages: legacy_prompt_messages_from_handler(vec![PromptMessage {
8938                role: fastmcp_protocol::Role::User,
8939                content: Content::Text {
8940                    text: "summarize".to_owned(),
8941                },
8942            }])
8943            .expect("handler prompt message is representable by the exact legacy union"),
8944            meta: None,
8945            additional: BTreeMap::new(),
8946        };
8947        assert_eq!(
8948            serde_json::to_value(prompt).expect("legacy prompt result serializes"),
8949            serde_json::json!({
8950                "description": "ask a question",
8951                "messages": [{
8952                    "role": "user",
8953                    "content": {"type": "text", "text": "summarize"}
8954                }]
8955            })
8956        );
8957    }
8958
8959    #[test]
8960    fn legacy_resource_adapter_preserves_open_members_when_promoted() {
8961        let resource = LegacyResourceContent::Text {
8962            uri: "file:///open.txt".to_owned(),
8963            text: "preserve me".to_owned(),
8964            mime_type: Some("text/plain".to_owned()),
8965            additional: BTreeMap::from([
8966                (
8967                    "_meta".to_owned(),
8968                    serde_json::json!({"legacy": "uninterpreted"}),
8969                ),
8970                (
8971                    "com.example/legacy".to_owned(),
8972                    serde_json::json!({"retained": true}),
8973                ),
8974            ]),
8975        };
8976
8977        let promoted = promote_legacy_resource_content(resource)
8978            .expect("schema-valid legacy resource is promotable");
8979        assert_eq!(
8980            serde_json::to_value(promoted).expect("promoted resource serializes"),
8981            serde_json::json!({
8982                "uri": "file:///open.txt",
8983                "mimeType": "text/plain",
8984                "text": "preserve me",
8985                "_meta": {"legacy": "uninterpreted"},
8986                "com.example/legacy": {"retained": true}
8987            })
8988        );
8989    }
8990
8991    #[test]
8992    fn legacy_content_adapter_rejects_only_audio_without_mutating_baseline() {
8993        let baseline = vec![Content::Image {
8994            data: "aGVsbG8=".to_owned(),
8995            mime_type: "image/png".to_owned(),
8996        }];
8997        let baseline_wire = serde_json::to_value(
8998            legacy_contents_from_handler(baseline.clone())
8999                .expect("baseline content is representable by the legacy adapter"),
9000        )
9001        .expect("baseline legacy content serializes");
9002        let planted = vec![Content::Audio {
9003            data: "aGVsbG8=".to_owned(),
9004            mime_type: "image/png".to_owned(),
9005        }];
9006
9007        assert!(
9008            legacy_contents_from_handler(planted).is_err(),
9009            "changing only the content discriminator to audio rejects the legacy adapter"
9010        );
9011        assert_eq!(
9012            serde_json::to_value(
9013                legacy_contents_from_handler(baseline)
9014                    .expect("baseline remains representable after rejection"),
9015            )
9016            .expect("baseline legacy content remains serializable"),
9017            baseline_wire,
9018            "rejected legacy conversion cannot mutate the accepted baseline"
9019        );
9020    }
9021
9022    fn spawn_owned_modern_request(
9023        runtime: &RuntimeHandle,
9024        router: Arc<Router>,
9025        request_context_id: u64,
9026        wire_id: &'static str,
9027        label: &'static str,
9028        control_sender: Option<oneshot::Sender<Cx>>,
9029    ) -> oneshot::Receiver<McpResult<serde_json::Value>> {
9030        let (response_sender, response_receiver) = oneshot::channel();
9031        runtime
9032            .try_spawn_with_cx(move |request_cx| {
9033                if let Some(control_sender) = control_sender {
9034                    control_sender
9035                        .send_blocking(request_cx.clone())
9036                        .expect("the cancellation controller remains available");
9037                }
9038                // Modern tools/call computes an MRTR exchange binding, which
9039                // requires a session cache partition on the request context.
9040                let request_ctx =
9041                    McpContext::with_state(request_cx, request_context_id, SessionState::new());
9042                let request = JsonRpcRequest::new(
9043                    "tools/call",
9044                    Some(serde_json::json!({
9045                        "_meta": {
9046                            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9047                            "io.modelcontextprotocol/clientCapabilities": {},
9048                        },
9049                        "name": "concurrent-modern-tool",
9050                        "arguments": {"request": label},
9051                    })),
9052                    wire_id,
9053                );
9054                async move {
9055                    let result = router.dispatch_stateless_owned(request_ctx, request).await;
9056                    response_sender
9057                        .send_blocking(result)
9058                        .expect("the modern dispatch observer remains available");
9059                }
9060            })
9061            .expect("the runtime admits the request owner");
9062        response_receiver
9063    }
9064
9065    // ── Stub handlers ──────────────────────────────────────────────────
9066
9067    struct NamedTool {
9068        name: String,
9069        tags: Vec<String>,
9070    }
9071
9072    impl NamedTool {
9073        fn new(name: &str) -> Self {
9074            Self {
9075                name: name.to_string(),
9076                tags: vec![],
9077            }
9078        }
9079        fn with_tags(name: &str, tags: Vec<String>) -> Self {
9080            Self {
9081                name: name.to_string(),
9082                tags,
9083            }
9084        }
9085    }
9086
9087    impl ToolHandler for NamedTool {
9088        fn definition(&self) -> Tool {
9089            Tool {
9090                name: self.name.clone(),
9091                description: Some(format!("Tool {}", self.name)),
9092                input_schema: serde_json::json!({"type": "object"}),
9093                output_schema: None,
9094                icon: None,
9095                version: None,
9096                tags: self.tags.clone(),
9097                annotations: None,
9098            }
9099        }
9100        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9101            Ok(vec![Content::text(format!("called {}", self.name))])
9102        }
9103    }
9104
9105    struct RouterProgressTool;
9106
9107    impl ToolHandler for RouterProgressTool {
9108        fn definition(&self) -> Tool {
9109            Tool {
9110                name: "router-progress-tool".to_owned(),
9111                description: None,
9112                input_schema: serde_json::json!({
9113                    "type": "object",
9114                    "required": ["total"],
9115                    "properties": {"total": {"type": "number"}},
9116                    "additionalProperties": false,
9117                }),
9118                output_schema: None,
9119                icon: None,
9120                version: None,
9121                tags: Vec::new(),
9122                annotations: None,
9123            }
9124        }
9125
9126        fn call(&self, ctx: &McpContext, args: serde_json::Value) -> McpResult<Vec<Content>> {
9127            let total = args
9128                .get("total")
9129                .and_then(serde_json::Value::as_f64)
9130                .ok_or_else(|| McpError::invalid_params("router progress total is required"))?;
9131            ctx.report_progress_with_total(12_000.0, total, Some("router-final"));
9132            Ok(vec![Content::text("progress emitted")])
9133        }
9134    }
9135
9136    struct DuplicateInvariantTool {
9137        label: &'static str,
9138        schema_property: &'static str,
9139        legacy_calls: Arc<AtomicUsize>,
9140        final_calls: Arc<AtomicUsize>,
9141    }
9142
9143    impl ToolHandler for DuplicateInvariantTool {
9144        fn definition(&self) -> Tool {
9145            Tool {
9146                name: "duplicate-invariant-tool".to_owned(),
9147                description: Some(self.label.to_owned()),
9148                input_schema: serde_json::json!({
9149                    "type": "object",
9150                    "properties": {
9151                        (self.schema_property): {"type": "boolean"}
9152                    },
9153                    "additionalProperties": false
9154                }),
9155                output_schema: None,
9156                icon: None,
9157                version: None,
9158                tags: vec![self.label.to_owned()],
9159                annotations: None,
9160            }
9161        }
9162
9163        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9164            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9165            Ok(vec![Content::text(self.label)])
9166        }
9167
9168        fn call_final_outcome(
9169            &self,
9170            _ctx: &McpContext,
9171            _args: serde_json::Value,
9172        ) -> McpResult<FinalToolOutcome> {
9173            self.final_calls.fetch_add(1, Ordering::SeqCst);
9174            Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9175                FinalCallToolResult {
9176                    content: vec![ContentBlock::text(self.label)],
9177                    is_error: false,
9178                    structured_content: None,
9179                },
9180            )))
9181        }
9182    }
9183
9184    struct EraBoundaryResource {
9185        legacy_calls: Arc<AtomicUsize>,
9186        final_calls: Arc<AtomicUsize>,
9187    }
9188
9189    impl ResourceHandler for EraBoundaryResource {
9190        fn definition(&self) -> Resource {
9191            Resource {
9192                uri: "mcp://era-boundary".to_owned(),
9193                name: "era-boundary".to_owned(),
9194                description: None,
9195                mime_type: Some("text/plain".to_owned()),
9196                icon: None,
9197                version: None,
9198                tags: Vec::new(),
9199            }
9200        }
9201
9202        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
9203            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9204            Ok(vec![ResourceContent {
9205                uri: "mcp://era-boundary".to_owned(),
9206                mime_type: Some("text/plain".to_owned()),
9207                text: Some("legacy resource result".to_owned()),
9208                blob: None,
9209            }])
9210        }
9211
9212        fn read_final(
9213            &self,
9214            _ctx: &McpContext,
9215        ) -> McpResult<CompleteResult<FinalReadResourceResult>> {
9216            self.final_calls.fetch_add(1, Ordering::SeqCst);
9217            Ok(CompleteResult::new(
9218                FinalReadResourceResult {
9219                    contents: vec![EmbeddedResourceContents::Text {
9220                        uri: AbsoluteUri::parse("mcp://era-boundary")
9221                            .expect("era-boundary resource URI is valid"),
9222                        text: "final resource result".to_owned(),
9223                        mime_type: Some("text/plain".to_owned()),
9224                        meta: None,
9225                        additional: BTreeMap::new(),
9226                    }],
9227                    ttl_ms: CacheTtl::milliseconds(DEFAULT_FINAL_RESOURCE_TTL_MS),
9228                    cache_scope: CacheScope::Private,
9229                },
9230                empty_final_result_meta()?,
9231            ))
9232        }
9233    }
9234
9235    /// A normal-registration candidate whose final output-schema field is
9236    /// invalid. It is used to prove admission rejects without catalog change.
9237    struct InvalidFinalSchemaNamedTool {
9238        name: String,
9239        tags: Vec<String>,
9240    }
9241
9242    impl InvalidFinalSchemaNamedTool {
9243        fn with_tags(name: &str, tags: Vec<String>) -> Self {
9244            Self {
9245                name: name.to_owned(),
9246                tags,
9247            }
9248        }
9249    }
9250
9251    impl ToolHandler for InvalidFinalSchemaNamedTool {
9252        fn definition(&self) -> Tool {
9253            Tool {
9254                name: self.name.clone(),
9255                description: Some(format!("Invalid-schema tool {}", self.name)),
9256                input_schema: serde_json::json!({"type": "object"}),
9257                // The final field itself must be a JSON object. This scalar
9258                // schema document is deliberately invalid for local normal
9259                // registration.
9260                output_schema: Some(serde_json::json!(false)),
9261                icon: None,
9262                version: None,
9263                tags: self.tags.clone(),
9264                annotations: None,
9265            }
9266        }
9267
9268        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9269            Ok(vec![Content::text(format!("called {}", self.name))])
9270        }
9271    }
9272
9273    fn final_tools_list_request(
9274        cursor: Option<&str>,
9275        include_tags: Option<Vec<&str>>,
9276        exclude_tags: Option<Vec<&str>>,
9277        id: i64,
9278    ) -> JsonRpcRequest {
9279        let mut params = serde_json::Map::new();
9280        params.insert(
9281            "_meta".to_owned(),
9282            serde_json::json!({
9283                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9284                "io.modelcontextprotocol/clientCapabilities": {},
9285            }),
9286        );
9287        if let Some(cursor) = cursor {
9288            params.insert("cursor".to_owned(), serde_json::json!(cursor));
9289        }
9290        if let Some(include_tags) = include_tags {
9291            params.insert("includeTags".to_owned(), serde_json::json!(include_tags));
9292        }
9293        if let Some(exclude_tags) = exclude_tags {
9294            params.insert("excludeTags".to_owned(), serde_json::json!(exclude_tags));
9295        }
9296        JsonRpcRequest::new("tools/list", Some(serde_json::Value::Object(params)), id)
9297    }
9298
9299    fn mounted_tool_router() -> Router {
9300        let mut source = Router::new();
9301        source
9302            .add_tool(NamedTool::with_tags("first", vec!["visible".to_owned()]))
9303            .expect("tool registration succeeds");
9304        source
9305            .add_tool(NamedTool::with_tags("second", vec!["visible".to_owned()]))
9306            .expect("tool registration succeeds");
9307        source
9308            .add_tool(NamedTool::with_tags(
9309                "excluded",
9310                vec!["visible".to_owned(), "excluded".to_owned()],
9311            ))
9312            .expect("tool registration succeeds");
9313        source
9314            .add_tool(NamedTool::with_tags("other", vec!["other".to_owned()]))
9315            .expect("tool registration succeeds");
9316
9317        let mut router = Router::new();
9318        let mounted = router.mount_tools(source, Some("peer"));
9319        assert_eq!(mounted.tools, 4);
9320        router
9321    }
9322
9323    static MACRO_DUAL_ERA_TOOL_CALLS: AtomicUsize = AtomicUsize::new(0);
9324    /// Serializes the tests that reset and assert the shared call counter;
9325    /// concurrent resets interleave and turn the absolute counts flaky.
9326    static MACRO_DUAL_ERA_TOOL_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
9327
9328    fn final_tool_complete_result(
9329        payload: FinalCallToolResult,
9330    ) -> CompleteResult<FinalCallToolResult> {
9331        let params = serde_json::json!({
9332            "_meta": {
9333                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9334                "io.modelcontextprotocol/clientCapabilities": {},
9335            },
9336            "name": "macro_dual_era_tool",
9337        });
9338        let request = CoreRequest::decode(ProtocolEra::Modern2026, "tools/call", Some(&params))
9339            .expect("test final tools/call request");
9340        let mut wire = serde_json::to_value(payload).expect("final tool payload serializes");
9341        wire.as_object_mut()
9342            .expect("final tool payload is an object")
9343            .insert("resultType".to_owned(), serde_json::json!("complete"));
9344        let encoded = serde_json::to_string(&wire).expect("final tool wire serializes");
9345        let CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) = request
9346            .decode_result(&encoded)
9347            .expect("typed final tools/call result")
9348        else {
9349            panic!("typed final tools/call result is selected");
9350        };
9351        result
9352    }
9353
9354    #[fastmcp_derive::tool]
9355    fn macro_dual_era_tool() -> CompleteResult<FinalCallToolResult> {
9356        MACRO_DUAL_ERA_TOOL_CALLS.fetch_add(1, Ordering::SeqCst);
9357        final_tool_complete_result(FinalCallToolResult {
9358            content: vec![ContentBlock::text("macro final tool result")],
9359            is_error: false,
9360            structured_content: Some(serde_json::json!({"weather":"clear"})),
9361        })
9362    }
9363
9364    struct SchemaBoundaryTool {
9365        final_calls: Arc<AtomicUsize>,
9366        legacy_calls: Arc<AtomicUsize>,
9367        output_matches_schema: bool,
9368        output_is_error: bool,
9369        output_has_unevaluated_property: bool,
9370        invalid_final_input_schema: bool,
9371        missing_final_input_object_type: bool,
9372        invalid_final_output_schema: bool,
9373    }
9374
9375    impl ToolHandler for SchemaBoundaryTool {
9376        fn definition(&self) -> Tool {
9377            Tool {
9378                name: "schema-boundary-tool".to_owned(),
9379                description: None,
9380                input_schema: if self.invalid_final_input_schema {
9381                    serde_json::json!(42)
9382                } else if self.missing_final_input_object_type {
9383                    serde_json::json!({"properties": {"value": {"type": "string"}}})
9384                } else {
9385                    serde_json::json!({
9386                        "$schema": "https://json-schema.org/draft/2020-12/schema",
9387                        "type": "object",
9388                        "required": ["value"],
9389                        "properties": {"value": {"type": "string"}},
9390                        "unevaluatedProperties": false,
9391                    })
9392                },
9393                output_schema: Some(if self.invalid_final_output_schema {
9394                    serde_json::json!(42)
9395                } else {
9396                    serde_json::json!({
9397                        "$schema": "https://json-schema.org/draft/2020-12/schema",
9398                        "type": "object",
9399                        "properties": {
9400                            "accepted": {"type": "boolean"},
9401                            "error": {
9402                                "enum": ["input-validation", "handler"]
9403                            }
9404                        },
9405                        "anyOf": [
9406                            {"required": ["accepted"]},
9407                            {"required": ["error"]}
9408                        ],
9409                        "unevaluatedProperties": false,
9410                    })
9411                }),
9412                icon: None,
9413                version: None,
9414                tags: Vec::new(),
9415                annotations: None,
9416            }
9417        }
9418
9419        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9420            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9421            Ok(vec![Content::text("legacy schema-boundary result")])
9422        }
9423
9424        fn final_tool_error_structured_content(
9425            &self,
9426            kind: ToolErrorKind,
9427        ) -> Option<serde_json::Value> {
9428            Some(match kind {
9429                ToolErrorKind::InputValidation => {
9430                    serde_json::json!({"error": "input-validation"})
9431                }
9432                ToolErrorKind::Handler => serde_json::json!({"error": "handler"}),
9433            })
9434        }
9435
9436        fn call_final_outcome(
9437            &self,
9438            _ctx: &McpContext,
9439            _args: serde_json::Value,
9440        ) -> McpResult<FinalToolOutcome> {
9441            self.final_calls.fetch_add(1, Ordering::SeqCst);
9442            let structured_content = if !self.output_matches_schema {
9443                serde_json::json!({"accepted": "not-a-boolean"})
9444            } else if self.output_has_unevaluated_property {
9445                serde_json::json!({"accepted": true, "unexpected": true})
9446            } else {
9447                serde_json::json!({"accepted": true})
9448            };
9449            Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9450                FinalCallToolResult {
9451                    content: vec![ContentBlock::text("final schema-boundary result")],
9452                    is_error: self.output_is_error,
9453                    structured_content: Some(structured_content),
9454                },
9455            )))
9456        }
9457    }
9458
9459    struct UpstreamScalarSchemaTool {
9460        registered_proxy: bool,
9461    }
9462
9463    impl ToolHandler for UpstreamScalarSchemaTool {
9464        fn definition(&self) -> Tool {
9465            Tool {
9466                name: "upstream-scalar-schema-tool".to_owned(),
9467                description: None,
9468                input_schema: serde_json::json!({"type": "object"}),
9469                output_schema: Some(serde_json::json!(false)),
9470                icon: None,
9471                version: None,
9472                tags: Vec::new(),
9473                annotations: None,
9474            }
9475        }
9476
9477        fn final_tool_schema_authority(&self) -> FinalToolSchemaAuthority {
9478            // This public, forgeable label must not bypass local validation.
9479            FinalToolSchemaAuthority::Upstream
9480        }
9481
9482        fn upstream_final_tool_schema_registration(
9483            &self,
9484        ) -> Option<UpstreamFinalToolSchemaRegistration> {
9485            self.registered_proxy
9486                .then(UpstreamFinalToolSchemaRegistration::exact_proxy)
9487        }
9488
9489        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9490            Ok(vec![Content::text("legacy")])
9491        }
9492
9493        fn call_final_outcome(
9494            &self,
9495            _ctx: &McpContext,
9496            _args: serde_json::Value,
9497        ) -> McpResult<FinalToolOutcome> {
9498            Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9499                FinalCallToolResult {
9500                    content: vec![ContentBlock::text("upstream")],
9501                    is_error: false,
9502                    structured_content: Some(serde_json::json!({"upstream": true})),
9503                },
9504            )))
9505        }
9506    }
9507
9508    /// A dual-era tool whose admitted output schema and handlers deliberately
9509    /// differ across registrations. It proves a successful replacement commits
9510    /// both the handler and final schema together.
9511    struct AdmittedSchemaReplacementTool {
9512        legacy_calls: Arc<AtomicUsize>,
9513        final_calls: Arc<AtomicUsize>,
9514        legacy_label: &'static str,
9515        output_schema: serde_json::Value,
9516        structured_content: Option<serde_json::Value>,
9517    }
9518
9519    impl ToolHandler for AdmittedSchemaReplacementTool {
9520        fn definition(&self) -> Tool {
9521            Tool {
9522                name: "admitted-schema-replacement-tool".to_owned(),
9523                description: Some(self.legacy_label.to_owned()),
9524                input_schema: serde_json::json!({
9525                    "$schema": "https://json-schema.org/draft/2020-12/schema",
9526                    "type": "object",
9527                    "unevaluatedProperties": false,
9528                }),
9529                output_schema: Some(self.output_schema.clone()),
9530                icon: None,
9531                version: None,
9532                tags: Vec::new(),
9533                annotations: None,
9534            }
9535        }
9536
9537        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9538            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
9539            Ok(vec![Content::text(self.legacy_label)])
9540        }
9541
9542        fn final_tool_error_structured_content(
9543            &self,
9544            kind: ToolErrorKind,
9545        ) -> Option<serde_json::Value> {
9546            match self
9547                .output_schema
9548                .get("type")
9549                .and_then(serde_json::Value::as_str)
9550            {
9551                Some("string") => Some(serde_json::json!(match kind {
9552                    ToolErrorKind::InputValidation => "input-validation-error",
9553                    ToolErrorKind::Handler => "handler-error",
9554                })),
9555                Some("boolean") => Some(serde_json::json!(matches!(kind, ToolErrorKind::Handler))),
9556                Some("null") => Some(serde_json::Value::Null),
9557                Some("object") => Some(serde_json::json!({
9558                    "error": match kind {
9559                        ToolErrorKind::InputValidation => "input-validation",
9560                        ToolErrorKind::Handler => "handler",
9561                    }
9562                })),
9563                _ => None,
9564            }
9565        }
9566
9567        fn call_final_outcome(
9568            &self,
9569            _ctx: &McpContext,
9570            _args: serde_json::Value,
9571        ) -> McpResult<FinalToolOutcome> {
9572            self.final_calls.fetch_add(1, Ordering::SeqCst);
9573            Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9574                FinalCallToolResult {
9575                    content: vec![ContentBlock::text(self.legacy_label)],
9576                    is_error: false,
9577                    structured_content: self.structured_content.clone(),
9578                },
9579            )))
9580        }
9581    }
9582
9583    #[derive(Clone, Copy)]
9584    enum ErrorMapperMode {
9585        Complete,
9586        MissingHandler,
9587        InvalidHandler,
9588        OversizedHandler,
9589    }
9590
9591    struct ErrorMappedTool {
9592        mode: ErrorMapperMode,
9593        calls: Arc<AtomicUsize>,
9594    }
9595
9596    impl ToolHandler for ErrorMappedTool {
9597        fn definition(&self) -> Tool {
9598            Tool {
9599                name: "error-mapped-tool".to_owned(),
9600                description: None,
9601                input_schema: serde_json::json!({
9602                    "type": "object",
9603                    "required": ["value"],
9604                    "properties": {"value": {"type": "string"}},
9605                    "additionalProperties": false
9606                }),
9607                output_schema: Some(serde_json::json!({
9608                    "type": "object",
9609                    "required": ["error"],
9610                    "properties": {"error": {"type": "string"}},
9611                    "additionalProperties": false
9612                })),
9613                icon: None,
9614                version: None,
9615                tags: Vec::new(),
9616                annotations: None,
9617            }
9618        }
9619
9620        fn final_tool_error_structured_content(
9621            &self,
9622            kind: ToolErrorKind,
9623        ) -> Option<serde_json::Value> {
9624            match (self.mode, kind) {
9625                (ErrorMapperMode::MissingHandler, ToolErrorKind::Handler) => None,
9626                (ErrorMapperMode::InvalidHandler, ToolErrorKind::Handler) => {
9627                    Some(serde_json::json!({"error": 7}))
9628                }
9629                (ErrorMapperMode::OversizedHandler, ToolErrorKind::Handler) => Some(
9630                    serde_json::json!({"error": "x".repeat(MAX_FINAL_TOOL_ERROR_STRUCTURED_CONTENT_BYTES)}),
9631                ),
9632                (_, ToolErrorKind::InputValidation) => {
9633                    Some(serde_json::json!({"error": "input-validation"}))
9634                }
9635                (_, ToolErrorKind::Handler) => Some(serde_json::json!({"error": "handler"})),
9636            }
9637        }
9638
9639        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9640            self.calls.fetch_add(1, Ordering::SeqCst);
9641            Err(McpError::new(
9642                McpErrorCode::ToolExecutionError,
9643                "mapped handler failure",
9644            ))
9645        }
9646    }
9647
9648    fn final_tools_call_request(
9649        name: &str,
9650        arguments: serde_json::Value,
9651        id: i64,
9652    ) -> JsonRpcRequest {
9653        JsonRpcRequest::new(
9654            "tools/call",
9655            Some(serde_json::json!({
9656                "_meta": {
9657                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9658                    "io.modelcontextprotocol/clientCapabilities": {},
9659                },
9660                "name": name,
9661                "arguments": arguments,
9662            })),
9663            id,
9664        )
9665    }
9666
9667    fn admit_http_wire(
9668        method: &str,
9669        target: &str,
9670        body: &[u8],
9671    ) -> (JsonRpcRequest, Option<Arc<str>>) {
9672        let endpoint = HttpEndpointConfig::new(
9673            "/mcp",
9674            HttpAdmissionLimits::new(16, 8_192, 65_536).expect("nonzero HTTP limits"),
9675        )
9676        .expect("HTTP endpoint config");
9677        let headers = vec![
9678            ("Content-Type".to_owned(), "application/json".to_owned()),
9679            ("Accept".to_owned(), "application/json".to_owned()),
9680            ("MCP-Protocol-Version".to_owned(), "2026-07-28".to_owned()),
9681            ("Mcp-Method".to_owned(), method.to_owned()),
9682            ("Mcp-Name".to_owned(), target.to_owned()),
9683        ];
9684        admit_modern_post(&endpoint, "POST", "/mcp", &headers, body)
9685            .expect("wire request admits through HTTP")
9686            .into_request_and_raw_params()
9687    }
9688
9689    #[test]
9690    fn final_router_progress_admits_one_smaller_total_without_replacing_an_outer_runtime() {
9691        let mut router = Router::new();
9692        router
9693            .add_tool(RouterProgressTool)
9694            .expect("router progress tool registers for both eras");
9695        let cx = Cx::for_testing();
9696        let state = SessionState::new();
9697        let request_ctx = request_context(&cx, 177, Budget::INFINITE, &state);
9698        let sent = Arc::new(Mutex::new(Vec::new()));
9699        let sent_clone = Arc::clone(&sent);
9700        let notification_sender: NotificationSender = Arc::new(move |notification| {
9701            sent_clone
9702                .lock()
9703                .expect("notification collection is not poisoned")
9704                .push(notification);
9705        });
9706        let baseline = serde_json::json!({
9707            "_meta": {
9708                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9709                "io.modelcontextprotocol/clientCapabilities": {},
9710                "progressToken": "router-final-progress",
9711            },
9712            "name": "router-progress-tool",
9713            "arguments": {"total": 12000.0},
9714        });
9715        let mut planted = baseline.clone();
9716        planted["arguments"]["total"] = serde_json::json!(11999.0);
9717        assert_eq!(
9718            baseline["_meta"], planted["_meta"],
9719            "the progress total is the sole planted request dimension"
9720        );
9721        assert_eq!(baseline["name"], planted["name"]);
9722
9723        let baseline: FinalCallToolParams =
9724            serde_json::from_value(baseline).expect("baseline final request is valid");
9725        let outcome = block_on(router.handle_tools_call_final_in_request(
9726            &request_ctx,
9727            request_ctx.cx(),
9728            baseline,
9729            state.clone(),
9730            Some(&notification_sender),
9731            None,
9732            None,
9733        ))
9734        .expect("ordinary final router dispatch completes");
9735        assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
9736        let notification = sent
9737            .lock()
9738            .expect("notification collection is not poisoned")[0]
9739            .clone();
9740        let wire = serde_json::to_string(
9741            notification
9742                .params
9743                .as_ref()
9744                .expect("ordinary final progress has parameters"),
9745        )
9746        .expect("ordinary final progress parameters serialize");
9747        assert!(wire.contains("\"progress\":12000"));
9748        assert!(wire.contains("\"total\":12000"));
9749
9750        let planted: FinalCallToolParams =
9751            serde_json::from_value(planted).expect("one-variable planted request is valid");
9752        let outcome = block_on(router.handle_tools_call_final_in_request(
9753            &request_ctx,
9754            request_ctx.cx(),
9755            planted,
9756            state,
9757            Some(&notification_sender),
9758            None,
9759            None,
9760        ))
9761        .expect("the handler result remains valid when only total is smaller");
9762        assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
9763        assert_eq!(
9764            sent.lock()
9765                .expect("notification collection is not poisoned")
9766                .len(),
9767            2,
9768            "a smaller total is not a final-progress violation"
9769        );
9770        let wire = serde_json::to_string(
9771            sent.lock()
9772                .expect("notification collection is not poisoned")[1]
9773                .params
9774                .as_ref()
9775                .expect("second final progress has parameters"),
9776        )
9777        .expect("second final progress parameters serialize");
9778        assert!(wire.contains("\"progress\":12000"));
9779        assert!(wire.contains("\"total\":11999"));
9780    }
9781
9782    #[test]
9783    fn final_router_preserves_an_outer_final_progress_runtime() {
9784        let mut router = Router::new();
9785        router
9786            .add_tool(RouterProgressTool)
9787            .expect("router progress tool registers for both eras");
9788        let cx = Cx::for_testing();
9789        let state = SessionState::new();
9790        let sent = Arc::new(Mutex::new(Vec::new()));
9791        let sent_clone = Arc::clone(&sent);
9792        let outer_runtime = Arc::new(crate::handler::FinalProgressRuntime::new(
9793            ProgressMarker::from("outer-owned-marker"),
9794            move |notification| {
9795                sent_clone
9796                    .lock()
9797                    .expect("notification collection is not poisoned")
9798                    .push(notification);
9799            },
9800        ));
9801        let request_ctx =
9802            McpContext::with_progress(cx, 178, Arc::clone(&outer_runtime).into_reporter());
9803        let params: FinalCallToolParams = serde_json::from_value(serde_json::json!({
9804            "_meta": {
9805                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
9806                "io.modelcontextprotocol/clientCapabilities": {},
9807                "progressToken": "handler-supplied-marker",
9808            },
9809            "name": "router-progress-tool",
9810            "arguments": {"total": 11999.0},
9811        }))
9812        .expect("final tool parameters are valid");
9813        let notification_sender: NotificationSender =
9814            Arc::new(|_| panic!("router must not replace an installed final-progress runtime"));
9815
9816        let outcome = block_on(router.handle_tools_call_final_in_request(
9817            &request_ctx,
9818            request_ctx.cx(),
9819            params,
9820            state,
9821            Some(&notification_sender),
9822            None,
9823            None,
9824        ))
9825        .expect("outer final progress runtime remains usable");
9826        assert!(matches!(outcome, FinalToolOutcome::Complete(_)));
9827        assert!(outer_runtime.flush_pending());
9828
9829        let sent = sent
9830            .lock()
9831            .expect("notification collection is not poisoned");
9832        assert_eq!(sent.len(), 1);
9833        let wire = serde_json::to_string(
9834            sent[0]
9835                .params
9836                .as_ref()
9837                .expect("outer runtime notification has parameters"),
9838        )
9839        .expect("outer runtime parameters serialize");
9840        assert!(wire.contains("\"progressToken\":\"outer-owned-marker\""));
9841        assert!(wire.contains("\"progress\":12000"));
9842        assert!(wire.contains("\"total\":11999"));
9843    }
9844
9845    #[cfg(feature = "tasks")]
9846    struct TaskCapableRouterTool {
9847        final_calls: Arc<AtomicUsize>,
9848    }
9849
9850    #[cfg(feature = "tasks")]
9851    impl ToolHandler for TaskCapableRouterTool {
9852        fn definition(&self) -> Tool {
9853            Tool {
9854                name: "task-capable-router-tool".to_owned(),
9855                description: None,
9856                input_schema: serde_json::json!({
9857                    "$schema": "https://json-schema.org/draft/2020-12/schema",
9858                    "type": "object",
9859                    "unevaluatedProperties": false,
9860                }),
9861                output_schema: None,
9862                icon: None,
9863                version: None,
9864                tags: Vec::new(),
9865                annotations: None,
9866            }
9867        }
9868
9869        fn declares_final_tasks(&self) -> bool {
9870            true
9871        }
9872
9873        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9874            Ok(vec![Content::text("legacy task-capable router result")])
9875        }
9876
9877        fn call_final_outcome(
9878            &self,
9879            _ctx: &McpContext,
9880            _args: serde_json::Value,
9881        ) -> McpResult<FinalToolOutcome> {
9882            self.final_calls.fetch_add(1, Ordering::SeqCst);
9883            Ok(FinalToolOutcome::CreateTask {
9884                work_descriptor: FinalTaskWorkDescriptor::new(serde_json::json!({
9885                    "operation": "task-capable-router-tool"
9886                }))?,
9887                status_message: Some("router task created".to_owned()),
9888            })
9889        }
9890    }
9891
9892    /// A single declared task-capable handler whose result branch is selected
9893    /// only by the `createTask` argument. It proves registration is not itself
9894    /// a Tasks operation.
9895    #[cfg(feature = "tasks")]
9896    struct ConditionalTaskCapableRouterTool {
9897        final_calls: Arc<AtomicUsize>,
9898    }
9899
9900    #[cfg(feature = "tasks")]
9901    impl ToolHandler for ConditionalTaskCapableRouterTool {
9902        fn definition(&self) -> Tool {
9903            Tool {
9904                name: "conditional-task-capable-router-tool".to_owned(),
9905                description: None,
9906                input_schema: serde_json::json!({
9907                    "$schema": "https://json-schema.org/draft/2020-12/schema",
9908                    "type": "object",
9909                    "properties": {"createTask": {"type": "boolean"}},
9910                    "required": ["createTask"],
9911                    "unevaluatedProperties": false,
9912                }),
9913                output_schema: None,
9914                icon: None,
9915                version: None,
9916                tags: Vec::new(),
9917                annotations: None,
9918            }
9919        }
9920
9921        fn declares_final_tasks(&self) -> bool {
9922            true
9923        }
9924
9925        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9926            Ok(vec![Content::text(
9927                "legacy conditional task-capable router result",
9928            )])
9929        }
9930
9931        fn call_final_outcome(
9932            &self,
9933            _ctx: &McpContext,
9934            args: serde_json::Value,
9935        ) -> McpResult<FinalToolOutcome> {
9936            self.final_calls.fetch_add(1, Ordering::SeqCst);
9937            if args
9938                .get("createTask")
9939                .and_then(serde_json::Value::as_bool)
9940                .unwrap_or(false)
9941            {
9942                return Ok(FinalToolOutcome::CreateTask {
9943                    work_descriptor: FinalTaskWorkDescriptor::new(serde_json::json!({
9944                        "operation": "conditional-task-capable-router-tool"
9945                    }))?,
9946                    status_message: None,
9947                });
9948            }
9949            Ok(FinalToolOutcome::Complete(final_tool_complete_result(
9950                FinalCallToolResult {
9951                    content: vec![ContentBlock::text("ordinary final result")],
9952                    is_error: false,
9953                    structured_content: None,
9954                },
9955            )))
9956        }
9957    }
9958
9959    /// Simulates a handler that overrides the request-owned hook and bypasses
9960    /// the trait's ordinary declaration guard. The router must still prevent
9961    /// its undeclared task outcome from reaching task creation.
9962    #[cfg(feature = "tasks")]
9963    struct UndeclaredTaskOutcomeRouterTool {
9964        final_calls: Arc<AtomicUsize>,
9965    }
9966
9967    #[cfg(feature = "tasks")]
9968    impl ToolHandler for UndeclaredTaskOutcomeRouterTool {
9969        fn definition(&self) -> Tool {
9970            Tool {
9971                name: "undeclared-task-outcome-router-tool".to_owned(),
9972                description: None,
9973                input_schema: serde_json::json!({
9974                    "$schema": "https://json-schema.org/draft/2020-12/schema",
9975                    "type": "object",
9976                    "unevaluatedProperties": false,
9977                }),
9978                output_schema: None,
9979                icon: None,
9980                version: None,
9981                tags: Vec::new(),
9982                annotations: None,
9983            }
9984        }
9985
9986        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
9987            Ok(vec![Content::text("legacy undeclared-task outcome")])
9988        }
9989
9990        fn call_final_outcome_async_in_request<'a>(
9991            &'a self,
9992            _ctx: &'a McpContext,
9993            _request_cx: &'a Cx,
9994            _args: serde_json::Value,
9995        ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
9996            self.final_calls.fetch_add(1, Ordering::SeqCst);
9997            let work_descriptor = FinalTaskWorkDescriptor::new(serde_json::json!({
9998                "operation": "undeclared-task-outcome-router-tool"
9999            }));
10000            Box::pin(async move {
10001                match work_descriptor {
10002                    Ok(work_descriptor) => Outcome::Ok(FinalToolOutcome::CreateTask {
10003                        work_descriptor,
10004                        status_message: None,
10005                    }),
10006                    Err(error) => Outcome::Err(error),
10007                }
10008            })
10009        }
10010    }
10011
10012    #[cfg(feature = "tasks")]
10013    struct NoopFinalTaskSupervisor;
10014
10015    #[cfg(feature = "tasks")]
10016    impl ApplicationTaskSupervisor for NoopFinalTaskSupervisor {
10017        fn resume<'a>(
10018            &'a self,
10019            _cx: &'a Cx,
10020            _handoff: FinalTaskSupervisorHandoff,
10021        ) -> FinalTaskSupervisorFuture<'a> {
10022            Box::pin(async { Ok(()) })
10023        }
10024    }
10025
10026    #[cfg(feature = "tasks")]
10027    fn task_runtime_for_router(store: Arc<InMemoryFinalTaskStore>) -> FinalTaskRuntime {
10028        let store: Arc<dyn FinalTaskStore> = store;
10029        FinalTaskRuntime::new(
10030            store,
10031            FinalTaskRuntimeConfig::new(60_000, Some(5_000))
10032                .expect("a finite final Task policy is valid"),
10033            Arc::new(|_notification| {}),
10034        )
10035    }
10036
10037    #[cfg(feature = "tasks")]
10038    fn final_task_capable_tool_request(id: i64) -> JsonRpcRequest {
10039        JsonRpcRequest::new(
10040            "tools/call",
10041            Some(serde_json::json!({
10042                "_meta": {
10043                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
10044                    "io.modelcontextprotocol/clientCapabilities": {
10045                        "extensions": {
10046                            "io.modelcontextprotocol/tasks": {}
10047                        }
10048                    },
10049                },
10050                "name": "task-capable-router-tool",
10051                "arguments": {},
10052            })),
10053            id,
10054        )
10055    }
10056
10057    struct FinalCatalogTool {
10058        metadata: OpenMetadata,
10059        icons: Vec<RawIcon>,
10060    }
10061
10062    impl ToolHandler for FinalCatalogTool {
10063        fn definition(&self) -> Tool {
10064            Tool {
10065                name: "final-catalog-tool".to_owned(),
10066                description: Some("final catalog description".to_owned()),
10067                input_schema: serde_json::json!({"type": "object"}),
10068                output_schema: Some(serde_json::json!({"type": "object"})),
10069                icon: Some(fastmcp_protocol::Icon::new("https://legacy.test/icon.png")),
10070                version: Some("legacy-version".to_owned()),
10071                tags: vec!["legacy-tag".to_owned()],
10072                annotations: None,
10073            }
10074        }
10075
10076        fn final_title(&self) -> Option<&str> {
10077            Some("Final Catalog Tool")
10078        }
10079
10080        fn final_icons(&self) -> Option<&[RawIcon]> {
10081            Some(&self.icons)
10082        }
10083
10084        fn final_metadata(&self) -> Option<&OpenMetadata> {
10085            Some(&self.metadata)
10086        }
10087
10088        fn final_definition(&self) -> Option<FinalTool> {
10089            Some(FinalTool {
10090                name: "final-catalog-tool".to_owned(),
10091                title: Some("Exact Final Catalog Tool".to_owned()),
10092                description: Some("exact final catalog description".to_owned()),
10093                input_schema: serde_json::json!({"type": "object"}),
10094                output_schema: Some(serde_json::json!({"type": "object"})),
10095                annotations: Some(FinalToolAnnotations {
10096                    title: Some("Exact annotation title".to_owned()),
10097                    destructive: Some(false),
10098                    idempotent: Some(true),
10099                    read_only: Some(true),
10100                    open_world_hint: Some(false),
10101                }),
10102                icons: Some(self.icons.clone()),
10103                meta: Some(self.metadata.clone()),
10104            })
10105        }
10106
10107        fn final_tool_error_structured_content(
10108            &self,
10109            kind: ToolErrorKind,
10110        ) -> Option<serde_json::Value> {
10111            Some(serde_json::json!({
10112                "error": match kind {
10113                    ToolErrorKind::InputValidation => "input-validation",
10114                    ToolErrorKind::Handler => "handler",
10115                }
10116            }))
10117        }
10118
10119        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10120            Ok(vec![Content::text("legacy final catalog result")])
10121        }
10122    }
10123
10124    struct FinalCatalogResource {
10125        metadata: OpenMetadata,
10126        icons: Vec<RawIcon>,
10127        annotations: Annotations,
10128    }
10129
10130    impl ResourceHandler for FinalCatalogResource {
10131        fn definition(&self) -> Resource {
10132            Resource {
10133                uri: "file:///final-catalog-resource".to_owned(),
10134                name: "final-catalog-resource".to_owned(),
10135                description: Some("final resource description".to_owned()),
10136                mime_type: Some("text/plain".to_owned()),
10137                icon: None,
10138                version: None,
10139                tags: Vec::new(),
10140            }
10141        }
10142
10143        fn final_title(&self) -> Option<&str> {
10144            Some("Final Catalog Resource")
10145        }
10146
10147        fn final_icons(&self) -> Option<&[RawIcon]> {
10148            Some(&self.icons)
10149        }
10150
10151        fn final_annotations(&self) -> Option<&Annotations> {
10152            Some(&self.annotations)
10153        }
10154
10155        fn final_metadata(&self) -> Option<&OpenMetadata> {
10156            Some(&self.metadata)
10157        }
10158
10159        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10160            Ok(Vec::new())
10161        }
10162    }
10163
10164    struct FinalCatalogResourceTemplate {
10165        metadata: OpenMetadata,
10166        icons: Vec<RawIcon>,
10167        annotations: Annotations,
10168    }
10169
10170    impl ResourceHandler for FinalCatalogResourceTemplate {
10171        fn definition(&self) -> Resource {
10172            Resource {
10173                uri: "template://placeholder".to_owned(),
10174                name: "final-catalog-template".to_owned(),
10175                description: None,
10176                mime_type: None,
10177                icon: None,
10178                version: None,
10179                tags: Vec::new(),
10180            }
10181        }
10182
10183        fn template(&self) -> Option<ResourceTemplate> {
10184            Some(ResourceTemplate {
10185                uri_template: "template://{id}".to_owned(),
10186                name: "final-catalog-template".to_owned(),
10187                description: Some("final template description".to_owned()),
10188                mime_type: Some("application/json".to_owned()),
10189                icon: None,
10190                version: None,
10191                tags: Vec::new(),
10192            })
10193        }
10194
10195        fn final_template_title(&self) -> Option<&str> {
10196            Some("Final Catalog Template")
10197        }
10198
10199        fn final_template_icons(&self) -> Option<&[RawIcon]> {
10200            Some(&self.icons)
10201        }
10202
10203        fn final_template_annotations(&self) -> Option<&Annotations> {
10204            Some(&self.annotations)
10205        }
10206
10207        fn final_template_metadata(&self) -> Option<&OpenMetadata> {
10208            Some(&self.metadata)
10209        }
10210
10211        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10212            Ok(Vec::new())
10213        }
10214    }
10215
10216    struct FinalCatalogPrompt {
10217        metadata: OpenMetadata,
10218        icons: Vec<RawIcon>,
10219    }
10220
10221    impl PromptHandler for FinalCatalogPrompt {
10222        fn definition(&self) -> Prompt {
10223            Prompt {
10224                name: "final-catalog-prompt".to_owned(),
10225                description: Some("final prompt description".to_owned()),
10226                arguments: vec![PromptArgument {
10227                    name: "optional-argument".to_owned(),
10228                    description: Some("must remain explicitly false".to_owned()),
10229                    required: false,
10230                }],
10231                icon: None,
10232                version: None,
10233                tags: Vec::new(),
10234            }
10235        }
10236
10237        fn final_title(&self) -> Option<&str> {
10238            Some("Final Catalog Prompt")
10239        }
10240
10241        fn final_icons(&self) -> Option<&[RawIcon]> {
10242            Some(&self.icons)
10243        }
10244
10245        fn final_metadata(&self) -> Option<&OpenMetadata> {
10246            Some(&self.metadata)
10247        }
10248
10249        fn get(
10250            &self,
10251            _ctx: &McpContext,
10252            _args: std::collections::HashMap<String, String>,
10253        ) -> McpResult<Vec<PromptMessage>> {
10254            Ok(Vec::new())
10255        }
10256    }
10257
10258    struct DirectFinalPrompt {
10259        final_calls: Arc<AtomicUsize>,
10260    }
10261
10262    impl PromptHandler for DirectFinalPrompt {
10263        fn definition(&self) -> Prompt {
10264            Prompt {
10265                name: "direct-final-prompt".to_owned(),
10266                description: Some("legacy prompt definition".to_owned()),
10267                arguments: vec![],
10268                icon: None,
10269                version: None,
10270                tags: Vec::new(),
10271            }
10272        }
10273
10274        fn get(
10275            &self,
10276            _ctx: &McpContext,
10277            _args: std::collections::HashMap<String, String>,
10278        ) -> McpResult<Vec<PromptMessage>> {
10279            Err(McpError::internal_error(
10280                "legacy prompt projection must not service a final request",
10281            ))
10282        }
10283
10284        fn get_final(
10285            &self,
10286            _ctx: &McpContext,
10287            _args: std::collections::HashMap<String, String>,
10288        ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
10289            self.final_calls.fetch_add(1, Ordering::SeqCst);
10290            let content_meta = OpenMetadata::try_from_entries([(
10291                "com.example/direct-prompt".to_owned(),
10292                serde_json::json!({"source": "final-handler"}),
10293            )])
10294            .expect("direct prompt content metadata is valid");
10295            Ok(CompleteResult::new(
10296                FinalGetPromptResult {
10297                    description: Some("direct final prompt description".to_owned()),
10298                    messages: vec![FinalPromptMessage {
10299                        role: fastmcp_protocol::Role::Assistant,
10300                        content: ContentBlock::Audio {
10301                            data: "aGVsbG8=".to_owned(),
10302                            mime_type: "audio/mpeg".to_owned(),
10303                            annotations: None,
10304                            meta: Some(content_meta),
10305                            additional: BTreeMap::from([(
10306                                "com.example/direct-field".to_owned(),
10307                                serde_json::json!(true),
10308                            )]),
10309                        },
10310                    }],
10311                },
10312                empty_final_result_meta()?,
10313            ))
10314        }
10315    }
10316
10317    struct PromptArgumentBoundary {
10318        final_calls: Arc<AtomicUsize>,
10319        legacy_calls: Arc<AtomicUsize>,
10320    }
10321
10322    impl PromptHandler for PromptArgumentBoundary {
10323        fn definition(&self) -> Prompt {
10324            Prompt {
10325                name: "prompt-argument-boundary".to_owned(),
10326                description: None,
10327                arguments: vec![PromptArgument {
10328                    name: "topic".to_owned(),
10329                    description: Some("required modern prompt topic".to_owned()),
10330                    required: true,
10331                }],
10332                icon: None,
10333                version: None,
10334                tags: Vec::new(),
10335            }
10336        }
10337
10338        fn get(
10339            &self,
10340            _ctx: &McpContext,
10341            _args: std::collections::HashMap<String, String>,
10342        ) -> McpResult<Vec<PromptMessage>> {
10343            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10344            Ok(vec![PromptMessage {
10345                role: fastmcp_protocol::Role::Assistant,
10346                content: Content::text("legacy prompt-argument-boundary result"),
10347            }])
10348        }
10349
10350        fn get_final(
10351            &self,
10352            _ctx: &McpContext,
10353            _args: std::collections::HashMap<String, String>,
10354        ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
10355            self.final_calls.fetch_add(1, Ordering::SeqCst);
10356            Ok(CompleteResult::new(
10357                FinalGetPromptResult {
10358                    description: None,
10359                    messages: vec![FinalPromptMessage {
10360                        role: fastmcp_protocol::Role::Assistant,
10361                        content: ContentBlock::text("final prompt-argument-boundary result"),
10362                    }],
10363                },
10364                empty_final_result_meta()?,
10365            ))
10366        }
10367    }
10368
10369    /// Deliberately changes its legacy definition after registration. Modern
10370    /// prompt validation must use the admission snapshot instead.
10371    struct MutablePromptDefinition {
10372        expose_admitted_argument: Arc<AtomicBool>,
10373        final_calls: Arc<AtomicUsize>,
10374    }
10375
10376    impl PromptHandler for MutablePromptDefinition {
10377        fn definition(&self) -> Prompt {
10378            let argument = if self.expose_admitted_argument.load(Ordering::SeqCst) {
10379                PromptArgument {
10380                    name: "topic".to_owned(),
10381                    description: Some("admitted required argument".to_owned()),
10382                    required: true,
10383                }
10384            } else {
10385                PromptArgument {
10386                    name: "mutated".to_owned(),
10387                    description: Some("must not affect final validation".to_owned()),
10388                    required: false,
10389                }
10390            };
10391            Prompt {
10392                name: "mutable-prompt-definition".to_owned(),
10393                description: None,
10394                arguments: vec![argument],
10395                icon: None,
10396                version: None,
10397                tags: Vec::new(),
10398            }
10399        }
10400
10401        fn get(
10402            &self,
10403            _ctx: &McpContext,
10404            _args: std::collections::HashMap<String, String>,
10405        ) -> McpResult<Vec<PromptMessage>> {
10406            Ok(Vec::new())
10407        }
10408
10409        fn get_final(
10410            &self,
10411            _ctx: &McpContext,
10412            _args: std::collections::HashMap<String, String>,
10413        ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
10414            self.final_calls.fetch_add(1, Ordering::SeqCst);
10415            Ok(CompleteResult::new(
10416                FinalGetPromptResult {
10417                    description: None,
10418                    messages: Vec::new(),
10419                },
10420                empty_final_result_meta()?,
10421            ))
10422        }
10423    }
10424
10425    fn input_required_result(forged_request_state: &str) -> InputRequiredResult {
10426        let encoded = serde_json::json!({
10427            "resultType": "input_required",
10428            "inputRequests": {"roots": {"method": "roots/list"}},
10429            "requestState": forged_request_state,
10430        })
10431        .to_string();
10432        let (decoded, diagnostic) = decode_peer_result(
10433            &encoded,
10434            ResultPeerEra::Modern,
10435            &CoreResultDiscriminatorPolicy,
10436        )
10437        .expect("test input-required result decodes");
10438        assert!(diagnostic.is_none());
10439        let DecodedResult::InputRequired(result) = decoded else {
10440            panic!("test result is input_required");
10441        };
10442        result
10443    }
10444
10445    fn sampling_input_required_result(forged_request_state: &str) -> InputRequiredResult {
10446        let encoded = serde_json::json!({
10447            "resultType": "input_required",
10448            "inputRequests": {
10449                "sample": {
10450                    "method": "sampling/createMessage",
10451                    "params": {
10452                        "messages": [{
10453                            "role": "assistant",
10454                            "content": {
10455                                "type": "tool_use",
10456                                "id": "weather-1",
10457                                "name": "weather",
10458                                "input": {"city": "Boston"},
10459                            },
10460                        }],
10461                        "maxTokens": 16,
10462                        "tools": [{"name": "weather", "inputSchema": {"type": "object"}}],
10463                        "toolChoice": {"mode": "required"},
10464                    },
10465                },
10466            },
10467            "requestState": forged_request_state,
10468        })
10469        .to_string();
10470        let (decoded, diagnostic) = decode_peer_result(
10471            &encoded,
10472            ResultPeerEra::Modern,
10473            &CoreResultDiscriminatorPolicy,
10474        )
10475        .expect("final sampling input-required result decodes");
10476        assert!(diagnostic.is_none());
10477        let DecodedResult::InputRequired(result) = decoded else {
10478            panic!("test result is sampling input_required");
10479        };
10480        result
10481    }
10482
10483    fn state_only_input_required_result(forged_request_state: &str) -> InputRequiredResult {
10484        let encoded = serde_json::json!({
10485            "resultType": "input_required",
10486            "requestState": forged_request_state,
10487        })
10488        .to_string();
10489        let (decoded, diagnostic) = decode_peer_result(
10490            &encoded,
10491            ResultPeerEra::Modern,
10492            &CoreResultDiscriminatorPolicy,
10493        )
10494        .expect("test state-only input-required result decodes");
10495        assert!(diagnostic.is_none());
10496        let DecodedResult::InputRequired(result) = decoded else {
10497            panic!("test result is state-only input_required");
10498        };
10499        assert!(result.input_requests().is_none());
10500        result
10501    }
10502
10503    fn router_roots_response_wire() -> serde_json::Value {
10504        serde_json::to_value(
10505            MrtrInputResponse::roots(fastmcp_protocol::ListRootsResult::empty())
10506                .expect("roots response serializes"),
10507        )
10508        .expect("roots response converts to a wire value")
10509    }
10510
10511    struct InputRequiredTool {
10512        legacy_calls: Arc<AtomicUsize>,
10513        final_calls: Arc<AtomicUsize>,
10514    }
10515
10516    struct StateOnlyInputRequiredTool {
10517        initial_calls: Arc<AtomicUsize>,
10518        resumed_calls: Arc<AtomicUsize>,
10519    }
10520
10521    impl ToolHandler for StateOnlyInputRequiredTool {
10522        fn definition(&self) -> Tool {
10523            Tool {
10524                name: "state-only-input-required-tool".to_owned(),
10525                description: None,
10526                input_schema: serde_json::json!({"type": "object"}),
10527                output_schema: None,
10528                icon: None,
10529                version: None,
10530                tags: Vec::new(),
10531                annotations: None,
10532            }
10533        }
10534
10535        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10536            Ok(vec![Content::text(
10537                "legacy state-only input-required result",
10538            )])
10539        }
10540
10541        fn declares_final_mrtr(&self) -> bool {
10542            true
10543        }
10544
10545        fn call_final_outcome(
10546            &self,
10547            _ctx: &McpContext,
10548            _args: serde_json::Value,
10549        ) -> McpResult<FinalToolOutcome> {
10550            self.initial_calls.fetch_add(1, Ordering::SeqCst);
10551            Ok(FinalToolOutcome::InputRequired(
10552                state_only_input_required_result("handler-forged-state"),
10553            ))
10554        }
10555
10556        fn call_final_outcome_async_resuming_in_request<'a>(
10557            &'a self,
10558            ctx: &'a McpContext,
10559            _request_cx: &'a Cx,
10560            arguments: serde_json::Value,
10561            resume_inputs: Option<&'a MrtrCompletedInputs>,
10562        ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10563            Box::pin(async move {
10564                // None marks the initial invocation under the unified
10565                // resuming hook; the retry must carry admitted inputs.
10566                let Some(resume_inputs) = resume_inputs else {
10567                    return match self.call_final_outcome(ctx, arguments) {
10568                        Ok(result) => Outcome::Ok(result),
10569                        Err(error) => Outcome::Err(error),
10570                    };
10571                };
10572                if !resume_inputs.responses().is_empty() {
10573                    return Outcome::Err(McpError::internal_error(
10574                        "state-only MRTR resume unexpectedly carried inputs",
10575                    ));
10576                }
10577                self.resumed_calls.fetch_add(1, Ordering::SeqCst);
10578                Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
10579                    FinalCallToolResult {
10580                        content: vec![ContentBlock::text("state-only resumed")],
10581                        is_error: false,
10582                        structured_content: None,
10583                    },
10584                )))
10585            })
10586        }
10587    }
10588
10589    impl ToolHandler for InputRequiredTool {
10590        fn definition(&self) -> Tool {
10591            Tool {
10592                name: "input-required-tool".to_owned(),
10593                description: None,
10594                input_schema: serde_json::json!({"type": "object"}),
10595                output_schema: None,
10596                icon: None,
10597                version: None,
10598                tags: Vec::new(),
10599                annotations: None,
10600            }
10601        }
10602
10603        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10604            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10605            Ok(vec![Content::text("legacy tool result")])
10606        }
10607
10608        fn declares_final_mrtr(&self) -> bool {
10609            true
10610        }
10611
10612        fn call_final_outcome(
10613            &self,
10614            _ctx: &McpContext,
10615            _args: serde_json::Value,
10616        ) -> McpResult<FinalToolOutcome> {
10617            self.final_calls.fetch_add(1, Ordering::SeqCst);
10618            Ok(FinalToolOutcome::InputRequired(input_required_result(
10619                "tool-retry-state",
10620            )))
10621        }
10622
10623        fn call_final_outcome_async_resuming_in_request<'a>(
10624            &'a self,
10625            ctx: &'a McpContext,
10626            _request_cx: &'a Cx,
10627            arguments: serde_json::Value,
10628            resume_inputs: Option<&'a MrtrCompletedInputs>,
10629        ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10630            Box::pin(async move {
10631                // None marks the initial invocation under the unified
10632                // resuming hook; the retry must carry admitted inputs.
10633                let Some(resume_inputs) = resume_inputs else {
10634                    return match self.call_final_outcome(ctx, arguments) {
10635                        Ok(result) => Outcome::Ok(result),
10636                        Err(error) => Outcome::Err(error),
10637                    };
10638                };
10639                match resume_inputs.roots("roots") {
10640                    Ok(Some(_)) => match self.call_final_outcome(ctx, arguments) {
10641                        Ok(result) => Outcome::Ok(result),
10642                        Err(error) => Outcome::Err(error),
10643                    },
10644                    Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
10645                    Err(error) => Outcome::Err(error),
10646                }
10647            })
10648        }
10649    }
10650
10651    struct OneShotMrtrTool {
10652        calls: Arc<AtomicUsize>,
10653    }
10654
10655    struct ContextElicitationTool {
10656        initial_calls: Arc<AtomicUsize>,
10657        resumed_calls: Arc<AtomicUsize>,
10658    }
10659
10660    struct ContextSamplingTool {
10661        calls: Arc<AtomicUsize>,
10662    }
10663
10664    impl ToolHandler for ContextElicitationTool {
10665        fn definition(&self) -> Tool {
10666            Tool {
10667                name: "context-elicitation-tool".to_owned(),
10668                description: None,
10669                input_schema: serde_json::json!({"type": "object"}),
10670                output_schema: None,
10671                icon: None,
10672                version: None,
10673                tags: Vec::new(),
10674                annotations: None,
10675            }
10676        }
10677
10678        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10679            Ok(vec![Content::text("legacy result")])
10680        }
10681
10682        fn declares_final_mrtr(&self) -> bool {
10683            true
10684        }
10685
10686        fn call_final_outcome(
10687            &self,
10688            ctx: &McpContext,
10689            _args: serde_json::Value,
10690        ) -> McpResult<FinalToolOutcome> {
10691            self.initial_calls.fetch_add(1, Ordering::SeqCst);
10692            let elicitation = ctx.final_elicitation_form(
10693                "approval",
10694                "Approve this operation",
10695                serde_json::json!({
10696                    "type": "object",
10697                    "properties": {"approved": {"type": "boolean"}},
10698                    "required": ["approved"],
10699                }),
10700            )?;
10701            Ok(FinalToolOutcome::InputRequired(
10702                elicitation.into_input_required()?,
10703            ))
10704        }
10705
10706        fn call_final_outcome_async_resuming_in_request<'a>(
10707            &'a self,
10708            ctx: &'a McpContext,
10709            _request_cx: &'a Cx,
10710            arguments: serde_json::Value,
10711            resume_inputs: Option<&'a MrtrCompletedInputs>,
10712        ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10713            Box::pin(async move {
10714                let Some(resume_inputs) = resume_inputs else {
10715                    return match self.call_final_outcome(ctx, arguments) {
10716                        Ok(result) => Outcome::Ok(result),
10717                        Err(error) => Outcome::Err(error),
10718                    };
10719                };
10720                let response = match resume_inputs.elicitation("approval") {
10721                    Ok(Some(response)) => response,
10722                    Ok(None) => {
10723                        return Outcome::Err(McpError::internal_error(
10724                            "MRTR elicitation response was lost",
10725                        ));
10726                    }
10727                    Err(error) => return Outcome::Err(error),
10728                };
10729                if response.action != fastmcp_protocol::ElicitAction::Accept
10730                    || response
10731                        .content
10732                        .as_ref()
10733                        .and_then(|content| content.get("approved"))
10734                        != Some(&fastmcp_protocol::ElicitContentValue::Bool(true))
10735                {
10736                    return Outcome::Err(McpError::invalid_params(
10737                        "MRTR elicitation response did not approve the operation",
10738                    ));
10739                }
10740                self.resumed_calls.fetch_add(1, Ordering::SeqCst);
10741                Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
10742                    FinalCallToolResult {
10743                        content: vec![ContentBlock::text("approved")],
10744                        is_error: false,
10745                        structured_content: None,
10746                    },
10747                )))
10748            })
10749        }
10750    }
10751
10752    impl ToolHandler for ContextSamplingTool {
10753        fn definition(&self) -> Tool {
10754            Tool {
10755                name: "context-sampling-tool".to_owned(),
10756                description: None,
10757                input_schema: serde_json::json!({"type": "object"}),
10758                output_schema: None,
10759                icon: None,
10760                version: None,
10761                tags: Vec::new(),
10762                annotations: None,
10763            }
10764        }
10765
10766        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10767            Ok(vec![Content::text("legacy result")])
10768        }
10769
10770        fn declares_final_mrtr(&self) -> bool {
10771            true
10772        }
10773
10774        fn call_final_outcome(
10775            &self,
10776            _ctx: &McpContext,
10777            _args: serde_json::Value,
10778        ) -> McpResult<FinalToolOutcome> {
10779            self.calls.fetch_add(1, Ordering::SeqCst);
10780            Ok(FinalToolOutcome::InputRequired(
10781                sampling_input_required_result("handler-forged-state"),
10782            ))
10783        }
10784    }
10785
10786    impl ToolHandler for OneShotMrtrTool {
10787        fn definition(&self) -> Tool {
10788            Tool {
10789                name: "one-shot-mrtr-tool".to_owned(),
10790                description: None,
10791                input_schema: serde_json::json!({"type": "object"}),
10792                output_schema: None,
10793                icon: None,
10794                version: None,
10795                tags: Vec::new(),
10796                annotations: None,
10797            }
10798        }
10799
10800        fn declares_final_mrtr(&self) -> bool {
10801            true
10802        }
10803
10804        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10805            Ok(vec![Content::text("one-shot legacy result")])
10806        }
10807
10808        fn call_final_outcome(
10809            &self,
10810            _ctx: &McpContext,
10811            _args: serde_json::Value,
10812        ) -> McpResult<FinalToolOutcome> {
10813            self.calls.fetch_add(1, Ordering::SeqCst);
10814            Ok(FinalToolOutcome::InputRequired(input_required_result(
10815                "one-shot-handler-state",
10816            )))
10817        }
10818
10819        fn call_final_outcome_async_resuming_in_request<'a>(
10820            &'a self,
10821            ctx: &'a McpContext,
10822            _request_cx: &'a Cx,
10823            arguments: serde_json::Value,
10824            resume_inputs: Option<&'a MrtrCompletedInputs>,
10825        ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
10826            Box::pin(async move {
10827                let Some(resume_inputs) = resume_inputs else {
10828                    return match self.call_final_outcome(ctx, arguments) {
10829                        Ok(result) => Outcome::Ok(result),
10830                        Err(error) => Outcome::Err(error),
10831                    };
10832                };
10833                match resume_inputs.roots("roots") {
10834                    Ok(Some(_)) => {
10835                        self.calls.fetch_add(1, Ordering::SeqCst);
10836                        Outcome::Ok(FinalToolOutcome::Complete(final_tool_complete_result(
10837                            FinalCallToolResult {
10838                                content: vec![ContentBlock::text("one-shot resumed")],
10839                                is_error: false,
10840                                structured_content: None,
10841                            },
10842                        )))
10843                    }
10844                    Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
10845                    Err(error) => Outcome::Err(error),
10846                }
10847            })
10848        }
10849    }
10850
10851    #[cfg(feature = "tasks")]
10852    struct TaskCapableInputRequiredTool {
10853        final_calls: Arc<AtomicUsize>,
10854    }
10855
10856    #[cfg(feature = "tasks")]
10857    impl ToolHandler for TaskCapableInputRequiredTool {
10858        fn definition(&self) -> Tool {
10859            Tool {
10860                name: "task-capable-input-required-tool".to_owned(),
10861                description: None,
10862                input_schema: serde_json::json!({"type": "object"}),
10863                output_schema: None,
10864                icon: None,
10865                version: None,
10866                tags: Vec::new(),
10867                annotations: None,
10868            }
10869        }
10870
10871        fn declares_final_tasks(&self) -> bool {
10872            true
10873        }
10874
10875        fn declares_final_mrtr(&self) -> bool {
10876            true
10877        }
10878
10879        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
10880            Ok(vec![Content::text(
10881                "legacy task-capable input-required result",
10882            )])
10883        }
10884
10885        fn call_final_outcome(
10886            &self,
10887            _ctx: &McpContext,
10888            _args: serde_json::Value,
10889        ) -> McpResult<FinalToolOutcome> {
10890            self.final_calls.fetch_add(1, Ordering::SeqCst);
10891            Ok(FinalToolOutcome::InputRequired(input_required_result(
10892                "task-capable-tool-retry-state",
10893            )))
10894        }
10895    }
10896
10897    struct InputRequiredResource {
10898        legacy_calls: Arc<AtomicUsize>,
10899        final_calls: Arc<AtomicUsize>,
10900    }
10901
10902    impl ResourceHandler for InputRequiredResource {
10903        fn definition(&self) -> Resource {
10904            Resource {
10905                uri: "file:///input-required-resource".to_owned(),
10906                name: "input-required-resource".to_owned(),
10907                description: None,
10908                mime_type: Some("text/plain".to_owned()),
10909                icon: None,
10910                version: None,
10911                tags: Vec::new(),
10912            }
10913        }
10914
10915        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
10916            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10917            Ok(vec![ResourceContent {
10918                uri: "file:///input-required-resource".to_owned(),
10919                mime_type: Some("text/plain".to_owned()),
10920                text: Some("legacy resource result".to_owned()),
10921                blob: None,
10922            }])
10923        }
10924
10925        fn declares_final_mrtr(&self) -> bool {
10926            true
10927        }
10928
10929        fn read_final_outcome(
10930            &self,
10931            _ctx: &McpContext,
10932        ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
10933            self.final_calls.fetch_add(1, Ordering::SeqCst);
10934            Ok(FinalMethodOutcome::InputRequired(input_required_result(
10935                "resource-retry-state",
10936            )))
10937        }
10938
10939        fn read_final_outcome_async_with_uri_resuming_in_request<'a>(
10940            &'a self,
10941            ctx: &'a McpContext,
10942            _request_cx: &'a Cx,
10943            _uri: &'a str,
10944            _params: &'a UriParams,
10945            resume_inputs: Option<&'a MrtrCompletedInputs>,
10946        ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
10947            Box::pin(async move {
10948                let Some(resume_inputs) = resume_inputs else {
10949                    return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
10950                };
10951                match resume_inputs.roots("roots") {
10952                    Ok(Some(_)) => match self.read_final_outcome(ctx) {
10953                        Ok(result) => Outcome::Ok(result),
10954                        Err(error) => Outcome::Err(error),
10955                    },
10956                    Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
10957                    Err(error) => Outcome::Err(error),
10958                }
10959            })
10960        }
10961    }
10962
10963    struct InputRequiredPrompt {
10964        legacy_calls: Arc<AtomicUsize>,
10965        final_calls: Arc<AtomicUsize>,
10966    }
10967
10968    impl PromptHandler for InputRequiredPrompt {
10969        fn definition(&self) -> Prompt {
10970            Prompt {
10971                name: "input-required-prompt".to_owned(),
10972                description: None,
10973                arguments: Vec::new(),
10974                icon: None,
10975                version: None,
10976                tags: Vec::new(),
10977            }
10978        }
10979
10980        fn get(
10981            &self,
10982            _ctx: &McpContext,
10983            _args: std::collections::HashMap<String, String>,
10984        ) -> McpResult<Vec<PromptMessage>> {
10985            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
10986            Ok(Vec::new())
10987        }
10988
10989        fn declares_final_mrtr(&self) -> bool {
10990            true
10991        }
10992
10993        fn get_final_outcome(
10994            &self,
10995            _ctx: &McpContext,
10996            _args: std::collections::HashMap<String, String>,
10997        ) -> McpResult<FinalMethodOutcome<FinalGetPromptResult>> {
10998            self.final_calls.fetch_add(1, Ordering::SeqCst);
10999            Ok(FinalMethodOutcome::InputRequired(input_required_result(
11000                "prompt-retry-state",
11001            )))
11002        }
11003
11004        fn get_final_outcome_async_resuming_in_request<'a>(
11005            &'a self,
11006            ctx: &'a McpContext,
11007            _request_cx: &'a Cx,
11008            arguments: std::collections::HashMap<String, String>,
11009            resume_inputs: Option<&'a MrtrCompletedInputs>,
11010        ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalGetPromptResult>>> {
11011            Box::pin(async move {
11012                let Some(resume_inputs) = resume_inputs else {
11013                    return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
11014                };
11015                match resume_inputs.roots("roots") {
11016                    Ok(Some(_)) => match self.get_final_outcome(ctx, arguments) {
11017                        Ok(result) => Outcome::Ok(result),
11018                        Err(error) => Outcome::Err(error),
11019                    },
11020                    Ok(None) => Outcome::Err(McpError::internal_error("MRTR roots input was lost")),
11021                    Err(error) => Outcome::Err(error),
11022                }
11023            })
11024        }
11025    }
11026
11027    fn direct_final_prompt_request(id: i64) -> JsonRpcRequest {
11028        JsonRpcRequest::new(
11029            "prompts/get",
11030            Some(serde_json::json!({
11031                "_meta": {
11032                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11033                    "io.modelcontextprotocol/clientCapabilities": {},
11034                },
11035                "name": "direct-final-prompt",
11036            })),
11037            id,
11038        )
11039    }
11040
11041    fn final_prompt_get_request(
11042        name: &str,
11043        arguments: serde_json::Value,
11044        id: i64,
11045    ) -> JsonRpcRequest {
11046        JsonRpcRequest::new(
11047            "prompts/get",
11048            Some(serde_json::json!({
11049                "_meta": {
11050                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11051                    "io.modelcontextprotocol/clientCapabilities": {},
11052                },
11053                "name": name,
11054                "arguments": arguments,
11055            })),
11056            id,
11057        )
11058    }
11059
11060    struct DirectFinalResource {
11061        legacy_calls: Arc<AtomicUsize>,
11062        final_calls: Arc<AtomicUsize>,
11063    }
11064
11065    impl ResourceHandler for DirectFinalResource {
11066        fn definition(&self) -> Resource {
11067            Resource {
11068                uri: "file:///direct-final-resource".to_owned(),
11069                name: "direct-final-resource".to_owned(),
11070                description: Some("legacy resource definition".to_owned()),
11071                mime_type: Some("text/plain".to_owned()),
11072                icon: None,
11073                version: None,
11074                tags: Vec::new(),
11075            }
11076        }
11077
11078        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11079            self.legacy_calls.fetch_add(1, Ordering::SeqCst);
11080            Ok(vec![ResourceContent {
11081                uri: "file:///direct-final-resource".to_owned(),
11082                mime_type: Some("text/plain".to_owned()),
11083                text: Some("legacy resource result".to_owned()),
11084                blob: None,
11085            }])
11086        }
11087
11088        fn read_final(
11089            &self,
11090            _ctx: &McpContext,
11091        ) -> McpResult<CompleteResult<FinalReadResourceResult>> {
11092            self.final_calls.fetch_add(1, Ordering::SeqCst);
11093            let content_meta = OpenMetadata::try_from_entries([(
11094                "com.example/direct-resource".to_owned(),
11095                serde_json::json!({"source": "final-handler"}),
11096            )])
11097            .expect("direct resource content metadata is valid");
11098            Ok(CompleteResult::new(
11099                FinalReadResourceResult {
11100                    contents: vec![EmbeddedResourceContents::Text {
11101                        uri: AbsoluteUri::parse("file:///direct-final-resource")
11102                            .expect("routed direct resource URI is valid"),
11103                        text: "direct final resource result".to_owned(),
11104                        mime_type: Some("text/markdown".to_owned()),
11105                        meta: Some(content_meta),
11106                        additional: BTreeMap::from([(
11107                            "com.example/direct-field".to_owned(),
11108                            serde_json::json!(true),
11109                        )]),
11110                    }],
11111                    ttl_ms: CacheTtl::milliseconds(321),
11112                    cache_scope: CacheScope::Public,
11113                },
11114                empty_final_result_meta()?,
11115            ))
11116        }
11117
11118        fn final_resource_read_cache_hint_provenance(
11119            &self,
11120        ) -> FinalResourceReadCacheHintProvenance {
11121            FinalResourceReadCacheHintProvenance::Explicit
11122        }
11123    }
11124
11125    struct HttpsCatalogResource {
11126        client_direct_https: bool,
11127    }
11128
11129    impl ResourceHandler for HttpsCatalogResource {
11130        fn definition(&self) -> Resource {
11131            Resource {
11132                uri: "https://client.example.test/catalog.txt".to_owned(),
11133                name: "client-direct-catalog".to_owned(),
11134                description: None,
11135                mime_type: Some("text/plain".to_owned()),
11136                icon: None,
11137                version: None,
11138                tags: Vec::new(),
11139            }
11140        }
11141
11142        fn final_client_direct_https(&self) -> bool {
11143            self.client_direct_https
11144        }
11145
11146        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11147            Ok(Vec::new())
11148        }
11149    }
11150
11151    struct ClientDirectHttpsPrompt;
11152
11153    impl PromptHandler for ClientDirectHttpsPrompt {
11154        fn definition(&self) -> Prompt {
11155            Prompt {
11156                name: "client-direct-https-prompt".to_owned(),
11157                description: None,
11158                arguments: Vec::new(),
11159                icon: None,
11160                version: None,
11161                tags: Vec::new(),
11162            }
11163        }
11164
11165        fn final_client_direct_https(&self) -> bool {
11166            true
11167        }
11168
11169        fn get(
11170            &self,
11171            _ctx: &McpContext,
11172            _args: std::collections::HashMap<String, String>,
11173        ) -> McpResult<Vec<PromptMessage>> {
11174            Ok(Vec::new())
11175        }
11176
11177        fn get_final(
11178            &self,
11179            _ctx: &McpContext,
11180            _args: std::collections::HashMap<String, String>,
11181        ) -> McpResult<CompleteResult<FinalGetPromptResult>> {
11182            Ok(CompleteResult::new(
11183                FinalGetPromptResult {
11184                    description: None,
11185                    messages: vec![FinalPromptMessage {
11186                        role: fastmcp_protocol::Role::Assistant,
11187                        content: ContentBlock::ResourceLink {
11188                            icons: None,
11189                            name: "client-direct-link".to_owned(),
11190                            title: None,
11191                            uri: AbsoluteUri::parse("https://client.example.test/prompt-link")
11192                                .expect("test HTTPS URI is valid"),
11193                            description: None,
11194                            mime_type: Some("text/plain".to_owned()),
11195                            annotations: None,
11196                            size: None,
11197                            meta: None,
11198                            additional: BTreeMap::new(),
11199                        },
11200                    }],
11201                },
11202                empty_final_result_meta()?,
11203            ))
11204        }
11205    }
11206
11207    struct MrtrHttpsEmbeddedResource {
11208        initial_calls: Arc<AtomicUsize>,
11209        resumed_calls: Arc<AtomicUsize>,
11210    }
11211
11212    impl ResourceHandler for MrtrHttpsEmbeddedResource {
11213        fn definition(&self) -> Resource {
11214            Resource {
11215                uri: "mcp://uri-policy/mrtr".to_owned(),
11216                name: "uri-policy-mrtr".to_owned(),
11217                description: None,
11218                mime_type: Some("text/plain".to_owned()),
11219                icon: None,
11220                version: None,
11221                tags: Vec::new(),
11222            }
11223        }
11224
11225        fn final_client_direct_https(&self) -> bool {
11226            true
11227        }
11228
11229        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11230            Ok(Vec::new())
11231        }
11232
11233        fn read_final_outcome(
11234            &self,
11235            _ctx: &McpContext,
11236        ) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
11237            self.initial_calls.fetch_add(1, Ordering::SeqCst);
11238            Ok(FinalMethodOutcome::InputRequired(input_required_result(
11239                "uri-policy-mrtr-state",
11240            )))
11241        }
11242
11243        fn read_final_outcome_async_with_uri_resuming_in_request<'a>(
11244            &'a self,
11245            _ctx: &'a McpContext,
11246            _request_cx: &'a Cx,
11247            _uri: &'a str,
11248            _params: &'a UriParams,
11249            resume_inputs: Option<&'a MrtrCompletedInputs>,
11250        ) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
11251            Box::pin(async move {
11252                let Some(resume_inputs) = resume_inputs else {
11253                    return Outcome::Err(McpError::internal_error("MRTR resume inputs were lost"));
11254                };
11255                if !matches!(resume_inputs.roots("roots"), Ok(Some(_))) {
11256                    return Outcome::Err(McpError::internal_error(
11257                        "MRTR roots input was not preserved",
11258                    ));
11259                }
11260                self.resumed_calls.fetch_add(1, Ordering::SeqCst);
11261                Outcome::Ok(FinalMethodOutcome::Complete(CompleteResult::new(
11262                    FinalReadResourceResult {
11263                        contents: vec![EmbeddedResourceContents::Text {
11264                            uri: AbsoluteUri::parse("https://client.example.test/mrtr-content")
11265                                .expect("test HTTPS URI is valid"),
11266                            text: "must not be emitted as embedded content".to_owned(),
11267                            mime_type: Some("text/plain".to_owned()),
11268                            meta: None,
11269                            additional: BTreeMap::new(),
11270                        }],
11271                        ttl_ms: CacheTtl::milliseconds(1),
11272                        cache_scope: CacheScope::Private,
11273                    },
11274                    empty_final_result_meta().expect("empty final metadata is valid"),
11275                )))
11276            })
11277        }
11278    }
11279
11280    struct SentinelHintResource {
11281        provenance: FinalResourceReadCacheHintProvenance,
11282    }
11283
11284    impl ResourceHandler for SentinelHintResource {
11285        fn definition(&self) -> Resource {
11286            Resource {
11287                uri: "file:///sentinel-hint-resource".to_owned(),
11288                name: "sentinel-hint-resource".to_owned(),
11289                description: None,
11290                mime_type: Some("text/plain".to_owned()),
11291                icon: None,
11292                version: None,
11293                tags: Vec::new(),
11294            }
11295        }
11296
11297        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11298            Ok(Vec::new())
11299        }
11300
11301        fn read_final(
11302            &self,
11303            _ctx: &McpContext,
11304        ) -> McpResult<CompleteResult<FinalReadResourceResult>> {
11305            Ok(CompleteResult::new(
11306                FinalReadResourceResult {
11307                    contents: Vec::new(),
11308                    // This is intentionally the former sentinel value. Only
11309                    // the explicit provenance may preserve it.
11310                    ttl_ms: CacheTtl::milliseconds(DEFAULT_FINAL_RESOURCE_TTL_MS),
11311                    cache_scope: CacheScope::Private,
11312                },
11313                empty_final_result_meta()?,
11314            ))
11315        }
11316
11317        fn final_resource_read_cache_hint_provenance(
11318            &self,
11319        ) -> FinalResourceReadCacheHintProvenance {
11320            self.provenance
11321        }
11322    }
11323
11324    fn direct_final_resource_request(id: i64) -> JsonRpcRequest {
11325        JsonRpcRequest::new(
11326            "resources/read",
11327            Some(serde_json::json!({
11328                "_meta": {
11329                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
11330                    "io.modelcontextprotocol/clientCapabilities": {},
11331                },
11332                "uri": "file:///direct-final-resource",
11333            })),
11334            id,
11335        )
11336    }
11337
11338    struct EchoCompletion;
11339
11340    impl CompletionHandler for EchoCompletion {
11341        fn complete_legacy(
11342            &self,
11343            _ctx: &McpContext,
11344            params: LegacyCompletionParams,
11345        ) -> McpResult<CompletionValues> {
11346            Ok(CompletionValues {
11347                values: vec![format!("{}ging", params.argument.value)],
11348                total: Some(1),
11349                has_more: Some(false),
11350            })
11351        }
11352
11353        fn complete_final(
11354            &self,
11355            _ctx: &McpContext,
11356            params: FinalCompletionParams,
11357        ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
11358            Ok(fastmcp_protocol::FinalCompletionValues {
11359                values: vec![format!("{}ging", params.argument.value)],
11360                total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
11361                has_more: Some(false),
11362            })
11363        }
11364    }
11365
11366    struct CountingCompletion {
11367        final_calls: Arc<AtomicUsize>,
11368    }
11369
11370    impl CompletionHandler for CountingCompletion {
11371        fn complete_legacy(
11372            &self,
11373            _ctx: &McpContext,
11374            _params: LegacyCompletionParams,
11375        ) -> McpResult<CompletionValues> {
11376            Ok(CompletionValues {
11377                values: vec!["legacy".to_owned()],
11378                total: Some(1),
11379                has_more: Some(false),
11380            })
11381        }
11382
11383        fn complete_final(
11384            &self,
11385            _ctx: &McpContext,
11386            params: FinalCompletionParams,
11387        ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
11388            self.final_calls.fetch_add(1, Ordering::SeqCst);
11389            Ok(fastmcp_protocol::FinalCompletionValues {
11390                values: vec![format!("{}ging", params.argument.value)],
11391                total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
11392                has_more: Some(false),
11393            })
11394        }
11395    }
11396
11397    struct ProviderCompletion {
11398        value: &'static str,
11399        final_calls: Arc<AtomicUsize>,
11400    }
11401
11402    impl CompletionHandler for ProviderCompletion {
11403        fn complete_legacy(
11404            &self,
11405            _ctx: &McpContext,
11406            _params: LegacyCompletionParams,
11407        ) -> McpResult<CompletionValues> {
11408            Ok(CompletionValues {
11409                values: vec![self.value.to_owned()],
11410                total: Some(1),
11411                has_more: Some(false),
11412            })
11413        }
11414
11415        fn complete_final(
11416            &self,
11417            _ctx: &McpContext,
11418            _params: FinalCompletionParams,
11419        ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
11420            self.final_calls.fetch_add(1, Ordering::SeqCst);
11421            Ok(fastmcp_protocol::FinalCompletionValues {
11422                values: vec![self.value.to_owned()],
11423                total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
11424                has_more: Some(false),
11425            })
11426        }
11427    }
11428
11429    struct ReversibleLevelFourTemplateResource {
11430        read_calls: Arc<AtomicUsize>,
11431    }
11432
11433    impl ResourceHandler for ReversibleLevelFourTemplateResource {
11434        fn definition(&self) -> Resource {
11435            Resource {
11436                uri: "mcp://resource/template".to_owned(),
11437                name: "level-four-template".to_owned(),
11438                description: None,
11439                mime_type: Some("text/plain".to_owned()),
11440                icon: None,
11441                version: None,
11442                tags: Vec::new(),
11443            }
11444        }
11445
11446        fn template(&self) -> Option<ResourceTemplate> {
11447            Some(marked_template(
11448                "mcp://resource{/collection*}/manifest{?revision*}",
11449                "level-four-template",
11450            ))
11451        }
11452
11453        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11454            unreachable!("templated reads receive their URI parameters")
11455        }
11456
11457        fn read_with_uri(
11458            &self,
11459            _ctx: &McpContext,
11460            uri: &str,
11461            params: &UriParams,
11462        ) -> McpResult<Vec<ResourceContent>> {
11463            self.read_calls.fetch_add(1, Ordering::SeqCst);
11464            Ok(vec![ResourceContent {
11465                uri: uri.to_owned(),
11466                mime_type: Some("text/plain".to_owned()),
11467                text: Some(format!(
11468                    "{}:{}",
11469                    params.get("collection").expect("collection is captured"),
11470                    params.get("revision").expect("revision is captured")
11471                )),
11472                blob: None,
11473            }])
11474        }
11475    }
11476
11477    struct LegacyTemplateResource {
11478        read_calls: Arc<AtomicUsize>,
11479    }
11480
11481    impl ResourceHandler for LegacyTemplateResource {
11482        fn definition(&self) -> Resource {
11483            Resource {
11484                uri: "mcp://resource/legacy-template".to_owned(),
11485                name: "legacy-template".to_owned(),
11486                description: None,
11487                mime_type: Some("text/plain".to_owned()),
11488                icon: None,
11489                version: None,
11490                tags: Vec::new(),
11491            }
11492        }
11493
11494        fn template(&self) -> Option<ResourceTemplate> {
11495            Some(marked_template(
11496                "mcp://resource/{collection}/manifest?revision={revision}",
11497                "legacy-template",
11498            ))
11499        }
11500
11501        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11502            unreachable!("templated reads receive their URI parameters")
11503        }
11504
11505        fn read_with_uri(
11506            &self,
11507            _ctx: &McpContext,
11508            uri: &str,
11509            params: &UriParams,
11510        ) -> McpResult<Vec<ResourceContent>> {
11511            self.read_calls.fetch_add(1, Ordering::SeqCst);
11512            Ok(vec![ResourceContent {
11513                uri: uri.to_owned(),
11514                mime_type: Some("text/plain".to_owned()),
11515                text: Some(format!(
11516                    "{}:{}",
11517                    params.get("collection").expect("collection is captured"),
11518                    params.get("revision").expect("revision is captured")
11519                )),
11520                blob: None,
11521            }])
11522        }
11523    }
11524
11525    struct CompletionValueBoundary {
11526        final_calls: Arc<AtomicUsize>,
11527    }
11528
11529    impl CompletionHandler for CompletionValueBoundary {
11530        fn complete_legacy(
11531            &self,
11532            _ctx: &McpContext,
11533            _params: LegacyCompletionParams,
11534        ) -> McpResult<CompletionValues> {
11535            Ok(CompletionValues {
11536                values: Vec::new(),
11537                total: None,
11538                has_more: None,
11539            })
11540        }
11541
11542        fn complete_final(
11543            &self,
11544            _ctx: &McpContext,
11545            params: FinalCompletionParams,
11546        ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
11547            self.final_calls.fetch_add(1, Ordering::SeqCst);
11548            let value_count = if params.argument.value == "one-over" {
11549                fastmcp_protocol::MAX_COMPLETION_VALUES + 1
11550            } else {
11551                fastmcp_protocol::MAX_COMPLETION_VALUES
11552            };
11553            Ok(fastmcp_protocol::FinalCompletionValues {
11554                values: (0..value_count)
11555                    .map(|index| format!("completion-{index}"))
11556                    .collect(),
11557                total: (params.argument.value == "negative-total")
11558                    .then(|| fastmcp_protocol::JsonInteger::from(-1_i64)),
11559                has_more: None,
11560            })
11561        }
11562    }
11563
11564    struct ConcurrentModernTool {
11565        started: Arc<AtomicUsize>,
11566        completed: Arc<Mutex<Vec<String>>>,
11567    }
11568
11569    impl ConcurrentModernTool {
11570        fn new(started: Arc<AtomicUsize>, completed: Arc<Mutex<Vec<String>>>) -> Self {
11571            Self { started, completed }
11572        }
11573    }
11574
11575    impl ToolHandler for ConcurrentModernTool {
11576        fn definition(&self) -> Tool {
11577            Tool {
11578                name: "concurrent-modern-tool".to_string(),
11579                description: Some("deterministic modern dispatch probe".to_string()),
11580                input_schema: serde_json::json!({
11581                    "type": "object",
11582                    "required": ["request"],
11583                    "properties": {"request": {"type": "string"}},
11584                }),
11585                output_schema: None,
11586                icon: None,
11587                version: None,
11588                tags: vec![],
11589                annotations: None,
11590            }
11591        }
11592
11593        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11594            Err(McpError::internal_error(
11595                "concurrent modern dispatch requires the async request hook",
11596            ))
11597        }
11598
11599        // The modern stateless in-request path consults the disjoint final
11600        // outcome hook, not `call_async_in_request`; override the hook the
11601        // router actually dispatches through.
11602        fn call_final_outcome_async_in_request<'a>(
11603            &'a self,
11604            ctx: &'a McpContext,
11605            request_cx: &'a Cx,
11606            arguments: serde_json::Value,
11607        ) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
11608            Box::pin(async move {
11609                let label = arguments
11610                    .get("request")
11611                    .and_then(serde_json::Value::as_str)
11612                    .unwrap_or("missing")
11613                    .to_string();
11614                self.started.fetch_add(1, Ordering::SeqCst);
11615
11616                while self.started.load(Ordering::SeqCst) < 2 {
11617                    if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
11618                        return Outcome::Cancelled(asupersync::CancelReason::user(
11619                            "request cancelled before concurrent admission",
11620                        ));
11621                    }
11622                    yield_once().await;
11623                }
11624
11625                if label == "cancelled" {
11626                    loop {
11627                        if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
11628                            return Outcome::Cancelled(asupersync::CancelReason::user(
11629                                "request cancellation observed by child Cx",
11630                            ));
11631                        }
11632                        yield_once().await;
11633                    }
11634                }
11635
11636                if ctx.checkpoint().is_err() || request_cx.is_cancel_requested() {
11637                    return Outcome::Cancelled(asupersync::CancelReason::user(
11638                        "request cancelled before completion",
11639                    ));
11640                }
11641                self.completed
11642                    .lock()
11643                    .expect("completion probe lock is not poisoned")
11644                    .push(label.clone());
11645                match crate::handler::promote_legacy_tool_content(vec![Content::text(label)]) {
11646                    Ok(result) => Outcome::Ok(FinalToolOutcome::Complete(result)),
11647                    Err(error) => Outcome::Err(error),
11648                }
11649            })
11650        }
11651    }
11652
11653    struct ErrorTool {
11654        name: &'static str,
11655        code: McpErrorCode,
11656    }
11657
11658    impl ToolHandler for ErrorTool {
11659        fn definition(&self) -> Tool {
11660            Tool {
11661                name: self.name.to_string(),
11662                description: None,
11663                input_schema: serde_json::json!({"type": "object"}),
11664                output_schema: None,
11665                icon: None,
11666                version: None,
11667                tags: vec![],
11668                annotations: None,
11669            }
11670        }
11671
11672        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11673            Err(McpError::new(self.code, "nested tool error"))
11674        }
11675    }
11676
11677    struct AlternatingTool {
11678        calls: Arc<AtomicU64>,
11679    }
11680
11681    impl ToolHandler for AlternatingTool {
11682        fn definition(&self) -> Tool {
11683            Tool {
11684                name: "alternating_tool".to_string(),
11685                description: None,
11686                input_schema: serde_json::json!({"type": "object"}),
11687                output_schema: None,
11688                icon: None,
11689                version: None,
11690                tags: vec![],
11691                annotations: None,
11692            }
11693        }
11694
11695        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11696            Err(McpError::internal_error("async alternating tool only"))
11697        }
11698
11699        fn call_async<'a>(
11700            &'a self,
11701            ctx: &'a McpContext,
11702            _arguments: serde_json::Value,
11703        ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
11704            Box::pin(async move {
11705                self.calls.fetch_add(1, Ordering::Relaxed);
11706                match ctx.read_resource("loop://resource").await {
11707                    Ok(_) => Outcome::Ok(vec![Content::text("unexpected completion")]),
11708                    Err(error) => Outcome::Err(error),
11709                }
11710            })
11711        }
11712    }
11713
11714    struct NamedResource {
11715        uri: String,
11716        tags: Vec<String>,
11717    }
11718
11719    impl NamedResource {
11720        fn new(uri: &str) -> Self {
11721            Self {
11722                uri: uri.to_string(),
11723                tags: vec![],
11724            }
11725        }
11726        fn with_tags(uri: &str, tags: Vec<String>) -> Self {
11727            Self {
11728                uri: uri.to_string(),
11729                tags,
11730            }
11731        }
11732    }
11733
11734    impl ResourceHandler for NamedResource {
11735        fn definition(&self) -> Resource {
11736            Resource {
11737                uri: self.uri.clone(),
11738                name: self.uri.clone(),
11739                description: None,
11740                mime_type: Some("text/plain".to_string()),
11741                icon: None,
11742                version: None,
11743                tags: self.tags.clone(),
11744            }
11745        }
11746        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11747            Ok(vec![ResourceContent {
11748                uri: self.uri.clone(),
11749                mime_type: Some("text/plain".to_string()),
11750                text: Some("content".to_string()),
11751                blob: None,
11752            }])
11753        }
11754    }
11755
11756    struct AlternatingResource {
11757        calls: Arc<AtomicU64>,
11758    }
11759
11760    impl ResourceHandler for AlternatingResource {
11761        fn definition(&self) -> Resource {
11762            Resource {
11763                uri: "loop://resource".to_string(),
11764                name: "alternating-resource".to_string(),
11765                description: None,
11766                mime_type: Some("text/plain".to_string()),
11767                icon: None,
11768                version: None,
11769                tags: vec![],
11770            }
11771        }
11772
11773        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11774            Err(McpError::internal_error("async alternating resource only"))
11775        }
11776
11777        fn read_async<'a>(
11778            &'a self,
11779            ctx: &'a McpContext,
11780        ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
11781            Box::pin(async move {
11782                self.calls.fetch_add(1, Ordering::Relaxed);
11783                match ctx
11784                    .call_tool("alternating_tool", serde_json::json!({}))
11785                    .await
11786                {
11787                    Ok(_) => Outcome::Ok(vec![ResourceContent {
11788                        uri: "loop://resource".to_string(),
11789                        mime_type: Some("text/plain".to_string()),
11790                        text: Some("unexpected completion".to_string()),
11791                        blob: None,
11792                    }]),
11793                    Err(error) => Outcome::Err(error),
11794                }
11795            })
11796        }
11797    }
11798
11799    struct CostLedgerTool {
11800        remaining_after_parent_debit: Arc<AtomicU64>,
11801        remaining_after_nested_read: Arc<AtomicU64>,
11802    }
11803
11804    impl ToolHandler for CostLedgerTool {
11805        fn definition(&self) -> Tool {
11806            Tool {
11807                name: "cost_ledger_tool".to_string(),
11808                description: None,
11809                input_schema: serde_json::json!({"type": "object"}),
11810                output_schema: None,
11811                icon: None,
11812                version: None,
11813                tags: vec![],
11814                annotations: None,
11815            }
11816        }
11817
11818        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
11819            Err(McpError::internal_error("async cost-ledger tool only"))
11820        }
11821
11822        fn call_async<'a>(
11823            &'a self,
11824            ctx: &'a McpContext,
11825            _arguments: serde_json::Value,
11826        ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
11827            Box::pin(async move {
11828                if ctx.consume_cost(1).is_err() {
11829                    return Outcome::Err(McpError::request_cancelled());
11830                }
11831                self.remaining_after_parent_debit.store(
11832                    ctx.budget()
11833                        .cost_quota
11834                        .expect("test request has finite cost quota"),
11835                    Ordering::Relaxed,
11836                );
11837
11838                if let Err(error) = ctx.read_resource("cost://nested").await {
11839                    return Outcome::Err(error);
11840                }
11841                self.remaining_after_nested_read.store(
11842                    ctx.budget()
11843                        .cost_quota
11844                        .expect("test request has finite cost quota"),
11845                    Ordering::Relaxed,
11846                );
11847                Outcome::Ok(vec![Content::text("shared ledger")])
11848            })
11849        }
11850    }
11851
11852    struct CostLedgerResource {
11853        remaining_after_nested_debit: Arc<AtomicU64>,
11854    }
11855
11856    impl ResourceHandler for CostLedgerResource {
11857        fn definition(&self) -> Resource {
11858            Resource {
11859                uri: "cost://nested".to_string(),
11860                name: "cost-ledger-resource".to_string(),
11861                description: None,
11862                mime_type: Some("text/plain".to_string()),
11863                icon: None,
11864                version: None,
11865                tags: vec![],
11866            }
11867        }
11868
11869        fn read(&self, ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
11870            ctx.consume_cost(1)
11871                .map_err(|_| McpError::request_cancelled())?;
11872            self.remaining_after_nested_debit.store(
11873                ctx.budget()
11874                    .cost_quota
11875                    .expect("test request has finite cost quota"),
11876                Ordering::Relaxed,
11877            );
11878            Ok(vec![ResourceContent {
11879                uri: "cost://nested".to_string(),
11880                mime_type: Some("text/plain".to_string()),
11881                text: Some("nested debit".to_string()),
11882                blob: None,
11883            }])
11884        }
11885    }
11886
11887    struct NamedPrompt {
11888        name: String,
11889        tags: Vec<String>,
11890    }
11891
11892    impl NamedPrompt {
11893        fn new(name: &str) -> Self {
11894            Self {
11895                name: name.to_string(),
11896                tags: vec![],
11897            }
11898        }
11899        fn with_tags(name: &str, tags: Vec<String>) -> Self {
11900            Self {
11901                name: name.to_string(),
11902                tags,
11903            }
11904        }
11905    }
11906
11907    impl PromptHandler for NamedPrompt {
11908        fn definition(&self) -> Prompt {
11909            Prompt {
11910                name: self.name.clone(),
11911                description: Some(format!("Prompt {}", self.name)),
11912                arguments: vec![],
11913                icon: None,
11914                version: None,
11915                tags: self.tags.clone(),
11916            }
11917        }
11918        fn get(
11919            &self,
11920            _ctx: &McpContext,
11921            _args: std::collections::HashMap<String, String>,
11922        ) -> McpResult<Vec<PromptMessage>> {
11923            Ok(vec![])
11924        }
11925    }
11926
11927    fn marked_template(uri_template: &str, marker: &str) -> ResourceTemplate {
11928        ResourceTemplate {
11929            uri_template: uri_template.to_string(),
11930            name: marker.to_string(),
11931            description: Some(marker.to_string()),
11932            mime_type: None,
11933            icon: None,
11934            version: None,
11935            tags: vec![marker.to_string()],
11936        }
11937    }
11938
11939    fn marked_router(marker: &str) -> Router {
11940        let mut router = Router::new();
11941        router
11942            .add_tool(NamedTool::with_tags(
11943                "duplicate_tool",
11944                vec![marker.to_string()],
11945            ))
11946            .expect("tool registration succeeds");
11947        router.add_resource(NamedResource::with_tags(
11948            "duplicate://resource",
11949            vec![marker.to_string()],
11950        ));
11951        router
11952            .add_resource_template_with_behavior(
11953                marked_template("mcp://duplicate-item/{item}", marker),
11954                crate::DuplicateBehavior::Replace,
11955            )
11956            .expect("marked resource template is disjoint from the marked exact resource");
11957        router.add_prompt(NamedPrompt::with_tags(
11958            "duplicate_prompt",
11959            vec![marker.to_string()],
11960        ));
11961        router
11962    }
11963
11964    fn assert_router_marker(router: &Router, marker: &str) {
11965        assert_eq!(
11966            router
11967                .get_tool("duplicate_tool")
11968                .expect("tool exists")
11969                .definition()
11970                .tags,
11971            vec![marker.to_string()]
11972        );
11973        assert_eq!(
11974            router
11975                .get_resource("duplicate://resource")
11976                .expect("resource exists")
11977                .definition()
11978                .tags,
11979            vec![marker.to_string()]
11980        );
11981        assert_eq!(
11982            router
11983                .get_resource_template("mcp://duplicate-item/{item}")
11984                .expect("resource template exists")
11985                .tags,
11986            vec![marker.to_string()]
11987        );
11988        assert_eq!(
11989            router
11990                .get_prompt("duplicate_prompt")
11991                .expect("prompt exists")
11992                .definition()
11993                .tags,
11994            vec![marker.to_string()]
11995        );
11996    }
11997
11998    struct BudgetProbeTool {
11999        timeout: Option<Duration>,
12000        delay: Duration,
12001        observed_deadline: Arc<Mutex<Option<Time>>>,
12002        timeout_read: Arc<std::sync::atomic::AtomicBool>,
12003    }
12004
12005    impl ToolHandler for BudgetProbeTool {
12006        fn definition(&self) -> Tool {
12007            Tool {
12008                name: "budget_probe".to_string(),
12009                description: None,
12010                input_schema: serde_json::json!({"type": "object"}),
12011                output_schema: None,
12012                icon: None,
12013                version: None,
12014                tags: vec![],
12015                annotations: None,
12016            }
12017        }
12018
12019        fn timeout(&self) -> Option<Duration> {
12020            self.timeout_read.store(true, Ordering::Relaxed);
12021            self.timeout
12022        }
12023
12024        fn call(&self, ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12025            eprintln!(
12026                "WM-BUDGET call entered deadline={:?}",
12027                ctx.budget().deadline
12028            );
12029            *self
12030                .observed_deadline
12031                .lock()
12032                .unwrap_or_else(std::sync::PoisonError::into_inner) = ctx.budget().deadline;
12033            if !self.delay.is_zero() {
12034                std::thread::sleep(self.delay);
12035            }
12036            Ok(vec![Content::text("completed")])
12037        }
12038    }
12039
12040    struct SlowDefinitionTool {
12041        definition_reads: Arc<AtomicU64>,
12042        called: Arc<std::sync::atomic::AtomicBool>,
12043    }
12044
12045    impl ToolHandler for SlowDefinitionTool {
12046        fn definition(&self) -> Tool {
12047            if self.definition_reads.fetch_add(1, Ordering::Relaxed) > 0 {
12048                eprintln!(
12049                    "WM-PROBE requery backtrace:\n{}",
12050                    std::backtrace::Backtrace::force_capture()
12051                );
12052                std::thread::sleep(Duration::from_millis(15));
12053            }
12054            Tool {
12055                name: "slow_definition".to_string(),
12056                description: None,
12057                input_schema: serde_json::json!({"type": "object"}),
12058                output_schema: None,
12059                icon: None,
12060                version: None,
12061                tags: vec![],
12062                annotations: None,
12063            }
12064        }
12065
12066        fn timeout(&self) -> Option<Duration> {
12067            Some(Duration::from_millis(1))
12068        }
12069
12070        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12071            self.called.store(true, Ordering::Relaxed);
12072            Ok(vec![Content::text("must not run")])
12073        }
12074    }
12075
12076    const PANIC_CANARY: &str = "Bearer peer-secret\n\u{001b}[31mred\u{001b}[0m\u{001b}]8;;https://invalid\u{0007}link\u{202e}";
12077
12078    struct PanickingDisplay(String);
12079
12080    impl fmt::Display for PanickingDisplay {
12081        fn fmt(&self, _formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
12082            panic!("display-must-not-run: {}", self.0);
12083        }
12084    }
12085
12086    struct UnwindingPanicTool {
12087        payload: String,
12088        non_string: bool,
12089    }
12090
12091    impl ToolHandler for UnwindingPanicTool {
12092        fn definition(&self) -> Tool {
12093            Tool {
12094                name: "panic_tool".to_string(),
12095                description: None,
12096                input_schema: serde_json::json!({"type": "object"}),
12097                output_schema: None,
12098                icon: None,
12099                version: None,
12100                tags: vec![],
12101                annotations: None,
12102            }
12103        }
12104
12105        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12106            unreachable!("the async override is the handler boundary under test")
12107        }
12108
12109        fn call_async<'a>(
12110            &'a self,
12111            _ctx: &'a McpContext,
12112            _arguments: serde_json::Value,
12113        ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
12114            Box::pin(async move {
12115                if self.non_string {
12116                    std::panic::panic_any(PanickingDisplay(self.payload.clone()));
12117                }
12118                panic!("{}", self.payload);
12119            })
12120        }
12121    }
12122
12123    struct OutcomePanicTool(String);
12124
12125    impl ToolHandler for OutcomePanicTool {
12126        fn definition(&self) -> Tool {
12127            Tool {
12128                name: "outcome_panic_tool".to_string(),
12129                description: None,
12130                input_schema: serde_json::json!({"type": "object"}),
12131                output_schema: None,
12132                icon: None,
12133                version: None,
12134                tags: vec![],
12135                annotations: None,
12136            }
12137        }
12138
12139        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12140            unreachable!("the async override is the handler boundary under test")
12141        }
12142
12143        fn call_async<'a>(
12144            &'a self,
12145            _ctx: &'a McpContext,
12146            _arguments: serde_json::Value,
12147        ) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
12148            Box::pin(async move {
12149                Outcome::Panicked(asupersync::types::PanicPayload::new(self.0.clone()))
12150            })
12151        }
12152    }
12153
12154    struct OpaqueInternalTool;
12155
12156    impl ToolHandler for OpaqueInternalTool {
12157        fn definition(&self) -> Tool {
12158            Tool {
12159                name: "opaque_internal_tool".to_string(),
12160                description: None,
12161                input_schema: serde_json::json!({"type": "object"}),
12162                output_schema: None,
12163                icon: None,
12164                version: None,
12165                tags: vec![],
12166                annotations: None,
12167            }
12168        }
12169
12170        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12171            Err(McpError::with_data(
12172                McpErrorCode::InternalError,
12173                PANIC_CANARY,
12174                serde_json::json!({"secret": PANIC_CANARY}),
12175            ))
12176        }
12177    }
12178
12179    struct OpaqueInternalResource;
12180
12181    impl ResourceHandler for OpaqueInternalResource {
12182        fn definition(&self) -> Resource {
12183            Resource {
12184                uri: "opaque://internal".to_string(),
12185                name: "opaque-internal-resource".to_string(),
12186                description: None,
12187                mime_type: None,
12188                icon: None,
12189                version: None,
12190                tags: vec![],
12191            }
12192        }
12193
12194        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12195            Err(McpError::with_data(
12196                McpErrorCode::InternalError,
12197                PANIC_CANARY,
12198                serde_json::json!({"secret": PANIC_CANARY}),
12199            ))
12200        }
12201    }
12202
12203    struct OpaqueInternalPrompt;
12204
12205    impl PromptHandler for OpaqueInternalPrompt {
12206        fn definition(&self) -> Prompt {
12207            Prompt {
12208                name: "opaque_internal_prompt".to_string(),
12209                description: None,
12210                arguments: vec![],
12211                icon: None,
12212                version: None,
12213                tags: vec![],
12214            }
12215        }
12216
12217        fn get(
12218            &self,
12219            _ctx: &McpContext,
12220            _args: std::collections::HashMap<String, String>,
12221        ) -> McpResult<Vec<PromptMessage>> {
12222            Err(McpError::with_data(
12223                McpErrorCode::InternalError,
12224                PANIC_CANARY,
12225                serde_json::json!({"secret": PANIC_CANARY}),
12226            ))
12227        }
12228    }
12229
12230    struct PanicResource;
12231
12232    impl ResourceHandler for PanicResource {
12233        fn definition(&self) -> Resource {
12234            Resource {
12235                uri: "panic://resource".to_string(),
12236                name: "panic-resource".to_string(),
12237                description: None,
12238                mime_type: None,
12239                icon: None,
12240                version: None,
12241                tags: vec![],
12242            }
12243        }
12244
12245        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12246            panic!("{PANIC_CANARY}")
12247        }
12248    }
12249
12250    struct PanicPrompt;
12251
12252    impl PromptHandler for PanicPrompt {
12253        fn definition(&self) -> Prompt {
12254            Prompt {
12255                name: "panic_prompt".to_string(),
12256                description: None,
12257                arguments: vec![],
12258                icon: None,
12259                version: None,
12260                tags: vec![],
12261            }
12262        }
12263
12264        fn get(
12265            &self,
12266            _ctx: &McpContext,
12267            _args: std::collections::HashMap<String, String>,
12268        ) -> McpResult<Vec<PromptMessage>> {
12269            panic!("{PANIC_CANARY}")
12270        }
12271    }
12272
12273    struct DefinitionPanicTool(std::sync::atomic::AtomicBool);
12274
12275    impl ToolHandler for DefinitionPanicTool {
12276        fn definition(&self) -> Tool {
12277            if self.0.swap(true, Ordering::Relaxed) {
12278                panic!("{PANIC_CANARY}");
12279            }
12280            Tool {
12281                name: "definition_panic_tool".to_string(),
12282                description: None,
12283                input_schema: serde_json::json!({"type": "object"}),
12284                output_schema: None,
12285                icon: None,
12286                version: None,
12287                tags: vec![],
12288                annotations: None,
12289            }
12290        }
12291
12292        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
12293            Ok(vec![])
12294        }
12295    }
12296
12297    struct DefinitionPanicResource(std::sync::atomic::AtomicBool);
12298
12299    impl ResourceHandler for DefinitionPanicResource {
12300        fn definition(&self) -> Resource {
12301            if self.0.swap(true, Ordering::Relaxed) {
12302                panic!("{PANIC_CANARY}");
12303            }
12304            Resource {
12305                uri: "panic://definition".to_string(),
12306                name: "definition-panic-resource".to_string(),
12307                description: None,
12308                mime_type: None,
12309                icon: None,
12310                version: None,
12311                tags: vec![],
12312            }
12313        }
12314
12315        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12316            Ok(vec![])
12317        }
12318    }
12319
12320    struct TemplatePanicResource(std::sync::atomic::AtomicBool);
12321
12322    impl ResourceHandler for TemplatePanicResource {
12323        fn definition(&self) -> Resource {
12324            Resource {
12325                uri: "panic-template://placeholder".to_string(),
12326                name: "template-panic-resource".to_string(),
12327                description: None,
12328                mime_type: None,
12329                icon: None,
12330                version: None,
12331                tags: vec![],
12332            }
12333        }
12334
12335        fn template(&self) -> Option<ResourceTemplate> {
12336            if self.0.swap(true, Ordering::Relaxed) {
12337                panic!("{PANIC_CANARY}");
12338            }
12339            Some(ResourceTemplate {
12340                uri_template: "panic-template://{id}".to_string(),
12341                name: "template-panic-resource".to_string(),
12342                description: None,
12343                mime_type: None,
12344                icon: None,
12345                version: None,
12346                tags: vec![],
12347            })
12348        }
12349
12350        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
12351            unreachable!("templated reads use read_with_uri")
12352        }
12353
12354        fn read_with_uri(
12355            &self,
12356            _ctx: &McpContext,
12357            uri: &str,
12358            _params: &UriParams,
12359        ) -> McpResult<Vec<ResourceContent>> {
12360            Ok(vec![ResourceContent {
12361                uri: uri.to_string(),
12362                mime_type: None,
12363                text: Some("template-content".to_string()),
12364                blob: None,
12365            }])
12366        }
12367    }
12368
12369    struct DefinitionPanicPrompt(std::sync::atomic::AtomicBool);
12370
12371    impl PromptHandler for DefinitionPanicPrompt {
12372        fn definition(&self) -> Prompt {
12373            if self.0.swap(true, Ordering::Relaxed) {
12374                panic!("{PANIC_CANARY}");
12375            }
12376            Prompt {
12377                name: "definition_panic_prompt".to_string(),
12378                description: None,
12379                arguments: vec![],
12380                icon: None,
12381                version: None,
12382                tags: vec![],
12383            }
12384        }
12385
12386        fn get(
12387            &self,
12388            _ctx: &McpContext,
12389            _args: std::collections::HashMap<String, String>,
12390        ) -> McpResult<Vec<PromptMessage>> {
12391            Ok(vec![])
12392        }
12393    }
12394
12395    // ── Router::new ────────────────────────────────────────────────────
12396
12397    #[test]
12398    fn new_router_is_empty() {
12399        let r = Router::new();
12400        assert_eq!(r.tools_count(), 0);
12401        assert_eq!(r.resources_count(), 0);
12402        assert_eq!(r.resource_templates_count(), 0);
12403        assert_eq!(r.prompts_count(), 0);
12404        assert!(r.tools().is_empty());
12405        assert!(r.resources().is_empty());
12406        assert!(r.resource_templates().is_empty());
12407        assert!(r.prompts().is_empty());
12408    }
12409
12410    #[test]
12411    fn default_router_is_empty() {
12412        let r = Router::default();
12413        assert_eq!(r.tools_count(), 0);
12414    }
12415
12416    // ── add_tool / get_tool ────────────────────────────────────────────
12417
12418    #[test]
12419    fn add_and_get_tool() {
12420        let mut r = Router::new();
12421        r.add_tool(NamedTool::new("my_tool"))
12422            .expect("tool registration succeeds");
12423        assert_eq!(r.tools_count(), 1);
12424        assert!(r.get_tool("my_tool").is_some());
12425        assert!(r.get_tool("other").is_none());
12426    }
12427
12428    #[test]
12429    fn add_tool_replace_on_duplicate() {
12430        let mut r = Router::new();
12431        r.add_tool(NamedTool::new("t"))
12432            .expect("initial registration succeeds");
12433        r.add_tool(NamedTool::new("t"))
12434            .expect("valid replacement succeeds");
12435        assert_eq!(r.tools_count(), 1);
12436        // Order preserved (only one entry)
12437        assert_eq!(r.tools().len(), 1);
12438    }
12439
12440    #[test]
12441    fn tools_returns_definitions_in_order() {
12442        let mut r = Router::new();
12443        r.add_tool(NamedTool::new("b"))
12444            .expect("tool registration succeeds");
12445        r.add_tool(NamedTool::new("a"))
12446            .expect("tool registration succeeds");
12447        let names: Vec<_> = r.tools().iter().map(|t| t.name.clone()).collect();
12448        assert_eq!(names, vec!["b", "a"]); // insertion order
12449    }
12450
12451    #[test]
12452    fn explicit_legacy_tool_is_callable_only_on_exact_2024_routes() {
12453        let mut router = Router::new();
12454        router
12455            .add_legacy_tool(InvalidFinalSchemaNamedTool::with_tags(
12456                "legacy-only-tool",
12457                vec!["legacy".to_owned()],
12458            ))
12459            .expect("explicit legacy registration does not claim final admission");
12460        assert_eq!(
12461            router
12462                .tools()
12463                .into_iter()
12464                .map(|tool| tool.name)
12465                .collect::<Vec<_>>(),
12466            vec!["legacy-only-tool"]
12467        );
12468        assert!(
12469            !router
12470                .server_discovery_behavior_registry()
12471                .contains(ServerBehavior::ToolsList),
12472            "a legacy-only catalog cannot produce a modern discovery claim"
12473        );
12474
12475        let cx = Cx::for_testing();
12476        let state = SessionState::new();
12477        let request_ctx = request_context(&cx, 78, Budget::INFINITE, &state);
12478        let legacy = router
12479            .handle_tools_call(
12480                &request_ctx,
12481                CallToolParams {
12482                    name: "legacy-only-tool".to_owned(),
12483                    arguments: Some(serde_json::json!({})),
12484                    meta: None,
12485                },
12486                state,
12487                None,
12488                None,
12489            )
12490            .expect("exact 2024 dispatch reaches the explicit legacy handler");
12491        assert_eq!(
12492            serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
12493            "called legacy-only-tool"
12494        );
12495
12496        let modern_catalog = router
12497            .dispatch_stateless(
12498                &request_ctx,
12499                &final_tools_list_request(None, None, None, 79_i64),
12500            )
12501            .expect("modern tools/list remains a valid empty catalog");
12502        assert_eq!(modern_catalog["tools"], serde_json::json!([]));
12503        let modern_error = router
12504            .dispatch_stateless(
12505                &request_ctx,
12506                &final_tools_call_request("legacy-only-tool", serde_json::json!({}), 80_i64),
12507            )
12508            .expect_err("modern dispatch cannot resolve an explicit legacy-only tool");
12509        assert_eq!(modern_error.code, McpErrorCode::InvalidParams);
12510        assert_eq!(router.tools_count(), 1);
12511    }
12512
12513    #[test]
12514    fn explicit_legacy_resource_and_prompt_are_inert_on_final_routes() {
12515        let mut router = Router::new();
12516        router.add_legacy_resource(NamedResource::new("file:///legacy-only"));
12517        router.add_legacy_resource_template(marked_template("file:///{name}", "legacy-only"));
12518        router.add_legacy_prompt(NamedPrompt::new("legacy-only-prompt"));
12519        let cx = Cx::for_testing();
12520        let state = SessionState::new();
12521        let request_ctx = request_context(&cx, 181, Budget::INFINITE, &state);
12522
12523        let legacy_resource = router
12524            .handle_resources_read(
12525                &request_ctx,
12526                &ReadResourceParams {
12527                    uri: "file:///legacy-only".to_owned(),
12528                    meta: None,
12529                },
12530                state.clone(),
12531                None,
12532                None,
12533            )
12534            .expect("the exact legacy resource route retains its explicit registration");
12535        assert_eq!(legacy_resource.contents.len(), 1);
12536        let legacy_prompt = router
12537            .handle_prompts_get(
12538                &request_ctx,
12539                GetPromptParams {
12540                    name: "legacy-only-prompt".to_owned(),
12541                    arguments: None,
12542                    meta: None,
12543                },
12544                state,
12545                None,
12546                None,
12547            )
12548            .expect("the exact legacy prompt route retains its explicit registration");
12549        assert!(legacy_prompt.messages.is_empty());
12550
12551        let modern_resources = router
12552            .dispatch_stateless(
12553                &request_ctx,
12554                &JsonRpcRequest::new(
12555                    "resources/list",
12556                    Some(serde_json::json!({
12557                        "_meta": {
12558                            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
12559                            "io.modelcontextprotocol/clientCapabilities": {},
12560                        },
12561                    })),
12562                    182_i64,
12563                ),
12564            )
12565            .expect("final discovery remains valid with only legacy resources");
12566        assert_eq!(modern_resources["resources"], serde_json::json!([]));
12567        let modern_templates = router
12568            .dispatch_stateless(
12569                &request_ctx,
12570                &JsonRpcRequest::new(
12571                    "resources/templates/list",
12572                    Some(serde_json::json!({
12573                        "_meta": {
12574                            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
12575                            "io.modelcontextprotocol/clientCapabilities": {},
12576                        },
12577                    })),
12578                    183_i64,
12579                ),
12580            )
12581            .expect("final template discovery remains valid with only legacy templates");
12582        assert_eq!(modern_templates["resourceTemplates"], serde_json::json!([]));
12583
12584        let modern_prompt = router
12585            .dispatch_stateless(
12586                &request_ctx,
12587                &JsonRpcRequest::new(
12588                    "prompts/get",
12589                    Some(serde_json::json!({
12590                        "_meta": {
12591                            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
12592                            "io.modelcontextprotocol/clientCapabilities": {},
12593                        },
12594                        "name": "legacy-only-prompt",
12595                    })),
12596                    184_i64,
12597                ),
12598            )
12599            .expect_err("changing only the dispatch era refuses the legacy-only prompt");
12600        assert_eq!(modern_prompt.code, McpErrorCode::InvalidParams);
12601        assert!(
12602            !router
12603                .server_discovery_behavior_registry()
12604                .contains(ServerBehavior::ResourcesList)
12605        );
12606        assert!(
12607            !router
12608                .server_discovery_behavior_registry()
12609                .contains(ServerBehavior::PromptsList)
12610        );
12611    }
12612
12613    // ── add_tool_with_behavior ─────────────────────────────────────────
12614
12615    #[test]
12616    fn duplicate_error_warn_and_ignore_preserve_handler_schema_and_order() {
12617        for behavior in [
12618            crate::DuplicateBehavior::Error,
12619            crate::DuplicateBehavior::Warn,
12620            crate::DuplicateBehavior::Ignore,
12621        ] {
12622            let original_legacy_calls = Arc::new(AtomicUsize::new(0));
12623            let original_final_calls = Arc::new(AtomicUsize::new(0));
12624            let candidate_legacy_calls = Arc::new(AtomicUsize::new(0));
12625            let candidate_final_calls = Arc::new(AtomicUsize::new(0));
12626            let mut router = Router::new();
12627            router
12628                .add_tool(NamedTool::new("before"))
12629                .expect("baseline tool admission succeeds");
12630            router
12631                .add_tool(DuplicateInvariantTool {
12632                    label: "original",
12633                    schema_property: "original_property",
12634                    legacy_calls: Arc::clone(&original_legacy_calls),
12635                    final_calls: Arc::clone(&original_final_calls),
12636                })
12637                .expect("original admission succeeds");
12638            router
12639                .add_tool(NamedTool::new("after"))
12640                .expect("trailing tool admission succeeds");
12641
12642            let cx = Cx::for_testing();
12643            let state = SessionState::new();
12644            let request_ctx = request_context(&cx, 81, Budget::INFINITE, &state);
12645            let legacy_before =
12646                serde_json::to_value(router.tools()).expect("legacy catalog serializes");
12647            let modern_before = router
12648                .dispatch_stateless(
12649                    &request_ctx,
12650                    &final_tools_list_request(None, None, None, 81_i64),
12651                )
12652                .expect("modern catalog is available");
12653
12654            let registration = router.add_tool_with_behavior(
12655                DuplicateInvariantTool {
12656                    label: "candidate",
12657                    schema_property: "candidate_property",
12658                    legacy_calls: Arc::clone(&candidate_legacy_calls),
12659                    final_calls: Arc::clone(&candidate_final_calls),
12660                },
12661                behavior,
12662            );
12663            if behavior == crate::DuplicateBehavior::Error {
12664                let error = registration.expect_err("Error rejects the duplicate");
12665                assert!(error.message.contains("already exists"));
12666            } else {
12667                registration.expect("Warn and Ignore retain the original");
12668            }
12669
12670            assert_eq!(
12671                serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
12672                legacy_before
12673            );
12674            assert_eq!(
12675                router
12676                    .dispatch_stateless(
12677                        &request_ctx,
12678                        &final_tools_list_request(None, None, None, 82_i64),
12679                    )
12680                    .expect("modern catalog remains available"),
12681                modern_before
12682            );
12683            assert_eq!(
12684                router
12685                    .tools()
12686                    .into_iter()
12687                    .map(|tool| tool.name)
12688                    .collect::<Vec<_>>(),
12689                vec!["before", "duplicate-invariant-tool", "after"]
12690            );
12691
12692            let legacy = router
12693                .handle_tools_call(
12694                    &request_ctx,
12695                    CallToolParams {
12696                        name: "duplicate-invariant-tool".to_owned(),
12697                        arguments: Some(serde_json::json!({})),
12698                        meta: None,
12699                    },
12700                    state.clone(),
12701                    None,
12702                    None,
12703                )
12704                .expect("legacy dispatch retains the original");
12705            assert_eq!(
12706                serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
12707                "original"
12708            );
12709            let modern = router
12710                .dispatch_stateless(
12711                    &request_ctx,
12712                    &final_tools_call_request(
12713                        "duplicate-invariant-tool",
12714                        serde_json::json!({}),
12715                        83_i64,
12716                    ),
12717                )
12718                .expect("modern dispatch retains the original");
12719            assert_eq!(modern["content"][0]["text"], "original");
12720            assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 1);
12721            assert_eq!(original_final_calls.load(Ordering::SeqCst), 1);
12722            assert_eq!(candidate_legacy_calls.load(Ordering::SeqCst), 0);
12723            assert_eq!(candidate_final_calls.load(Ordering::SeqCst), 0);
12724        }
12725    }
12726
12727    #[test]
12728    fn duplicate_replace_atomically_updates_handler_schema_and_retains_order() {
12729        let replacement_legacy_calls = Arc::new(AtomicUsize::new(0));
12730        let replacement_final_calls = Arc::new(AtomicUsize::new(0));
12731        let mut router = Router::new();
12732        router
12733            .add_tool(NamedTool::new("before"))
12734            .expect("baseline admission succeeds");
12735        router
12736            .add_tool(DuplicateInvariantTool {
12737                label: "original",
12738                schema_property: "original_property",
12739                legacy_calls: Arc::new(AtomicUsize::new(0)),
12740                final_calls: Arc::new(AtomicUsize::new(0)),
12741            })
12742            .expect("original admission succeeds");
12743        router
12744            .add_tool(NamedTool::new("after"))
12745            .expect("trailing admission succeeds");
12746        router
12747            .add_tool_with_behavior(
12748                DuplicateInvariantTool {
12749                    label: "replacement",
12750                    schema_property: "replacement_property",
12751                    legacy_calls: Arc::clone(&replacement_legacy_calls),
12752                    final_calls: Arc::clone(&replacement_final_calls),
12753                },
12754                crate::DuplicateBehavior::Replace,
12755            )
12756            .expect("Replace admits before committing the candidate");
12757
12758        assert_eq!(
12759            router
12760                .tools()
12761                .into_iter()
12762                .map(|tool| tool.name)
12763                .collect::<Vec<_>>(),
12764            vec!["before", "duplicate-invariant-tool", "after"]
12765        );
12766        let cx = Cx::for_testing();
12767        let state = SessionState::new();
12768        let request_ctx = request_context(&cx, 84, Budget::INFINITE, &state);
12769        let modern_catalog = router
12770            .dispatch_stateless(
12771                &request_ctx,
12772                &final_tools_list_request(None, None, None, 84_i64),
12773            )
12774            .expect("modern replacement catalog is available");
12775        assert_eq!(
12776            modern_catalog["tools"][1]["inputSchema"]["properties"],
12777            serde_json::json!({"replacement_property": {"type": "boolean"}})
12778        );
12779        assert_eq!(
12780            router.tools()[1].description.as_deref(),
12781            Some("replacement")
12782        );
12783
12784        let legacy = router
12785            .handle_tools_call(
12786                &request_ctx,
12787                CallToolParams {
12788                    name: "duplicate-invariant-tool".to_owned(),
12789                    arguments: Some(serde_json::json!({})),
12790                    meta: None,
12791                },
12792                state,
12793                None,
12794                None,
12795            )
12796            .expect("legacy dispatch uses replacement");
12797        assert_eq!(
12798            serde_json::to_value(legacy).expect("legacy result serializes")["content"][0]["text"],
12799            "replacement"
12800        );
12801        let modern = router
12802            .dispatch_stateless(
12803                &request_ctx,
12804                &final_tools_call_request(
12805                    "duplicate-invariant-tool",
12806                    serde_json::json!({}),
12807                    85_i64,
12808                ),
12809            )
12810            .expect("modern dispatch uses replacement");
12811        assert_eq!(modern["content"][0]["text"], "replacement");
12812        assert_eq!(replacement_legacy_calls.load(Ordering::SeqCst), 1);
12813        assert_eq!(replacement_final_calls.load(Ordering::SeqCst), 1);
12814    }
12815
12816    #[test]
12817    fn add_tool_behavior_new_tool_ok() {
12818        let mut r = Router::new();
12819        r.add_tool_with_behavior(NamedTool::new("t"), crate::DuplicateBehavior::Error)
12820            .unwrap();
12821        assert_eq!(r.tools_count(), 1);
12822    }
12823
12824    // ── add_resource / get_resource ────────────────────────────────────
12825
12826    #[test]
12827    fn add_and_get_resource() {
12828        let mut r = Router::new();
12829        r.add_resource(NamedResource::new("file:///a.txt"));
12830        assert_eq!(r.resources_count(), 1);
12831        assert!(r.get_resource("file:///a.txt").is_some());
12832        assert!(r.get_resource("file:///b.txt").is_none());
12833    }
12834
12835    #[test]
12836    fn resources_returns_definitions_in_order() {
12837        let mut r = Router::new();
12838        r.add_resource(NamedResource::new("file:///b"));
12839        r.add_resource(NamedResource::new("file:///a"));
12840        let uris: Vec<_> = r.resources().iter().map(|res| res.uri.clone()).collect();
12841        assert_eq!(uris, vec!["file:///b", "file:///a"]);
12842    }
12843
12844    // ── add_resource_with_behavior ─────────────────────────────────────
12845
12846    #[test]
12847    fn add_resource_behavior_error_on_duplicate() {
12848        let mut r = Router::new();
12849        r.add_resource(NamedResource::new("file:///a"));
12850        let err = r
12851            .add_resource_with_behavior(
12852                NamedResource::new("file:///a"),
12853                crate::DuplicateBehavior::Error,
12854            )
12855            .unwrap_err();
12856        assert!(err.message.contains("already exists"));
12857    }
12858
12859    #[test]
12860    fn add_resource_behavior_ignore() {
12861        let mut r = Router::new();
12862        r.add_resource(NamedResource::new("file:///a"));
12863        r.add_resource_with_behavior(
12864            NamedResource::new("file:///a"),
12865            crate::DuplicateBehavior::Ignore,
12866        )
12867        .unwrap();
12868        assert_eq!(r.resources_count(), 1);
12869    }
12870
12871    // ── add_prompt / get_prompt ────────────────────────────────────────
12872
12873    #[test]
12874    fn add_and_get_prompt() {
12875        let mut r = Router::new();
12876        r.add_prompt(NamedPrompt::new("greet"));
12877        assert_eq!(r.prompts_count(), 1);
12878        assert!(r.get_prompt("greet").is_some());
12879        assert!(r.get_prompt("other").is_none());
12880    }
12881
12882    #[test]
12883    fn prompts_returns_definitions_in_order() {
12884        let mut r = Router::new();
12885        r.add_prompt(NamedPrompt::new("z"));
12886        r.add_prompt(NamedPrompt::new("a"));
12887        let names: Vec<_> = r.prompts().iter().map(|p| p.name.clone()).collect();
12888        assert_eq!(names, vec!["z", "a"]);
12889    }
12890
12891    // ── add_prompt_with_behavior ───────────────────────────────────────
12892
12893    #[test]
12894    fn add_prompt_behavior_error_on_duplicate() {
12895        let mut r = Router::new();
12896        r.add_prompt(NamedPrompt::new("p"));
12897        let err = r
12898            .add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Error)
12899            .unwrap_err();
12900        assert!(err.message.contains("already exists"));
12901    }
12902
12903    #[test]
12904    fn add_prompt_behavior_warn_keeps_original() {
12905        let mut r = Router::new();
12906        r.add_prompt(NamedPrompt::new("p"));
12907        r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Warn)
12908            .unwrap();
12909        assert_eq!(r.prompts_count(), 1);
12910    }
12911
12912    #[test]
12913    fn duplicate_registration_errors_do_not_echo_peer_identifiers() {
12914        let canary = "raw-peer-identifier-canary";
12915
12916        let mut tools = Router::new();
12917        tools
12918            .add_tool(NamedTool::new(canary))
12919            .expect("tool registration succeeds");
12920        let tool_error = tools
12921            .add_tool_with_behavior(NamedTool::new(canary), crate::DuplicateBehavior::Error)
12922            .unwrap_err();
12923
12924        // Resource identities must be scheme-valid to survive final-catalog
12925        // projection; the canary stays the peer-controlled URI component.
12926        let canary_uri = format!("resource://{canary}");
12927        let mut resources = Router::new();
12928        resources.add_resource(NamedResource::new(&canary_uri));
12929        let resource_error = resources
12930            .add_resource_with_behavior(
12931                NamedResource::new(&canary_uri),
12932                crate::DuplicateBehavior::Error,
12933            )
12934            .unwrap_err();
12935
12936        let mut templates = Router::new();
12937        templates.add_resource_template(marked_template(&canary_uri, "original"));
12938        let template_error = templates
12939            .add_resource_template_with_behavior(
12940                marked_template(&canary_uri, "incoming"),
12941                crate::DuplicateBehavior::Error,
12942            )
12943            .unwrap_err();
12944
12945        let mut prompts = Router::new();
12946        prompts.add_prompt(NamedPrompt::new(canary));
12947        let prompt_error = prompts
12948            .add_prompt_with_behavior(NamedPrompt::new(canary), crate::DuplicateBehavior::Error)
12949            .unwrap_err();
12950
12951        for error in [tool_error, resource_error, template_error, prompt_error] {
12952            assert!(error.message.contains("already exists"));
12953            assert!(!error.message.contains(canary));
12954            assert!(
12955                is_duplicate_registration_error(&error),
12956                "duplicate registration errors must be recognizable without the peer key: {error:?}"
12957            );
12958        }
12959        assert!(
12960            !is_duplicate_registration_error(&McpError::internal_error(
12961                "tool metadata hook panicked during admission"
12962            )),
12963            "a non-duplicate admission failure must not be treated as a colliding catalog skip"
12964        );
12965        assert!(
12966            !is_duplicate_registration_error(&McpError::invalid_request(
12967                "MCP Apps tool metadata requires ServerBuilder::mcp_apps_tool"
12968            )),
12969            "an InvalidRequest that is not a duplicate must still fail prefixed as_proxy_typed"
12970        );
12971    }
12972
12973    // ── add_resource_template ──────────────────────────────────────────
12974
12975    #[test]
12976    fn add_resource_template_and_list() {
12977        let mut r = Router::new();
12978        let tmpl = ResourceTemplate {
12979            uri_template: "db://{table}".to_string(),
12980            name: "db".to_string(),
12981            description: None,
12982            mime_type: None,
12983            icon: None,
12984            version: None,
12985            tags: vec![],
12986        };
12987        r.add_resource_template(tmpl);
12988        assert_eq!(r.resource_templates_count(), 1);
12989        assert!(r.get_resource_template("db://{table}").is_some());
12990        assert!(r.get_resource_template("db://{other}").is_none());
12991    }
12992
12993    #[test]
12994    fn add_resource_template_replaces_existing() {
12995        let mut r = Router::new();
12996        let tmpl1 = ResourceTemplate {
12997            uri_template: "db://{table}".to_string(),
12998            name: "db1".to_string(),
12999            description: None,
13000            mime_type: None,
13001            icon: None,
13002            version: None,
13003            tags: vec![],
13004        };
13005        let tmpl2 = ResourceTemplate {
13006            uri_template: "db://{table}".to_string(),
13007            name: "db2".to_string(),
13008            description: None,
13009            mime_type: None,
13010            icon: None,
13011            version: None,
13012            tags: vec![],
13013        };
13014        r.add_resource_template(tmpl1);
13015        r.add_resource_template(tmpl2);
13016        assert_eq!(r.resource_templates_count(), 1);
13017        let tmpl = r.get_resource_template("db://{table}").unwrap();
13018        assert_eq!(tmpl.name, "db2");
13019    }
13020
13021    #[test]
13022    fn add_resource_template_with_behavior_preserves_or_replaces_identity() {
13023        for behavior in [
13024            crate::DuplicateBehavior::Warn,
13025            crate::DuplicateBehavior::Ignore,
13026            crate::DuplicateBehavior::Error,
13027        ] {
13028            let mut router = Router::new();
13029            router.add_resource_template(marked_template("peer://{secret}", "original"));
13030            let result = router.add_resource_template_with_behavior(
13031                marked_template("peer://{secret}", "incoming"),
13032                behavior,
13033            );
13034
13035            if behavior == crate::DuplicateBehavior::Error {
13036                let error = result.expect_err("Error policy rejects the duplicate");
13037                assert!(error.message.contains("already exists"));
13038                assert!(!error.message.contains("peer://{secret}"));
13039            } else {
13040                result.expect("Warn and Ignore keep the original");
13041            }
13042            assert_eq!(router.resource_templates_count(), 1);
13043            assert_eq!(
13044                router
13045                    .get_resource_template("peer://{secret}")
13046                    .expect("original template remains")
13047                    .name,
13048                "original"
13049            );
13050        }
13051
13052        let mut router = Router::new();
13053        router.add_resource_template(marked_template("peer://{secret}", "original"));
13054        router
13055            .add_resource_template_with_behavior(
13056                marked_template("peer://{secret}", "incoming"),
13057                crate::DuplicateBehavior::Replace,
13058            )
13059            .expect("Replace accepts the duplicate");
13060        assert_eq!(router.resource_templates_count(), 1);
13061        assert_eq!(
13062            router
13063                .get_resource_template("peer://{secret}")
13064                .expect("replacement template exists")
13065                .name,
13066            "incoming"
13067        );
13068    }
13069
13070    // ── resource_exists / resolve_resource ──────────────────────────────
13071
13072    #[test]
13073    fn resource_exists_for_static_resource() {
13074        let mut r = Router::new();
13075        r.add_resource(NamedResource::new("file:///a.txt"));
13076        assert!(r.resource_exists("file:///a.txt"));
13077        assert!(!r.resource_exists("file:///b.txt"));
13078    }
13079
13080    // ── strict_input_validation ────────────────────────────────────────
13081
13082    #[test]
13083    fn strict_input_validation_default_off() {
13084        let r = Router::new();
13085        assert!(!r.strict_input_validation());
13086    }
13087
13088    #[test]
13089    fn set_strict_input_validation() {
13090        let mut r = Router::new();
13091        r.set_strict_input_validation(true);
13092        assert!(r.strict_input_validation());
13093        r.set_strict_input_validation(false);
13094        assert!(!r.strict_input_validation());
13095    }
13096
13097    // ── set_list_page_size ─────────────────────────────────────────────
13098
13099    #[test]
13100    fn set_list_page_size_zero_treated_as_none() {
13101        let mut r = Router::new();
13102        r.set_list_page_size(Some(0));
13103        // Zero page size is filtered to None
13104        assert!(r.list_page_size.is_none());
13105    }
13106
13107    #[test]
13108    fn set_list_page_size_positive() {
13109        let mut r = Router::new();
13110        r.set_list_page_size(Some(10));
13111        assert_eq!(r.list_page_size, Some(10));
13112    }
13113
13114    #[test]
13115    fn set_list_page_size_none() {
13116        let mut r = Router::new();
13117        r.set_list_page_size(Some(10));
13118        r.set_list_page_size(None);
13119        assert!(r.list_page_size.is_none());
13120    }
13121
13122    // ── tools_filtered ─────────────────────────────────────────────────
13123
13124    #[test]
13125    fn tools_filtered_no_filters_returns_all() {
13126        let mut r = Router::new();
13127        r.add_tool(NamedTool::new("a"))
13128            .expect("tool registration succeeds");
13129        r.add_tool(NamedTool::new("b"))
13130            .expect("tool registration succeeds");
13131        let tools = r.tools_filtered(None, None);
13132        assert_eq!(tools.len(), 2);
13133    }
13134
13135    #[test]
13136    fn tools_filtered_by_session_state_disables() {
13137        let mut r = Router::new();
13138        r.add_tool(NamedTool::new("a"))
13139            .expect("tool registration succeeds");
13140        r.add_tool(NamedTool::new("b"))
13141            .expect("tool registration succeeds");
13142        let state = SessionState::new();
13143        let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
13144        state.set("fastmcp.disabled_tools", &disabled);
13145        let tools = r.tools_filtered(Some(&state), None);
13146        assert_eq!(tools.len(), 1);
13147        assert_eq!(tools[0].name, "b");
13148    }
13149
13150    #[test]
13151    fn tools_filtered_by_tags() {
13152        let mut r = Router::new();
13153        r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
13154            .expect("tool registration succeeds");
13155        r.add_tool(NamedTool::with_tags("b", vec!["web".to_string()]))
13156            .expect("tool registration succeeds");
13157        let include = vec!["db".to_string()];
13158        let filters = TagFilters::new(Some(&include), None);
13159        let tools = r.tools_filtered(None, Some(&filters));
13160        assert_eq!(tools.len(), 1);
13161        assert_eq!(tools[0].name, "a");
13162    }
13163
13164    // ── resources_filtered ─────────────────────────────────────────────
13165
13166    #[test]
13167    fn resources_filtered_by_session_state() {
13168        let mut r = Router::new();
13169        r.add_resource(NamedResource::new("file:///a"));
13170        r.add_resource(NamedResource::new("file:///b"));
13171        let state = SessionState::new();
13172        let disabled: std::collections::HashSet<String> =
13173            ["file:///a".to_string()].into_iter().collect();
13174        state.set("fastmcp.disabled_resources", &disabled);
13175        let res = r.resources_filtered(Some(&state), None);
13176        assert_eq!(res.len(), 1);
13177        assert_eq!(res[0].uri, "file:///b");
13178    }
13179
13180    // ── prompts_filtered ───────────────────────────────────────────────
13181
13182    #[test]
13183    fn prompts_filtered_by_session_state() {
13184        let mut r = Router::new();
13185        r.add_prompt(NamedPrompt::new("a"));
13186        r.add_prompt(NamedPrompt::new("b"));
13187        let state = SessionState::new();
13188        let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
13189        state.set("fastmcp.disabled_prompts", &disabled);
13190        let prompts = r.prompts_filtered(Some(&state), None);
13191        assert_eq!(prompts.len(), 1);
13192        assert_eq!(prompts[0].name, "b");
13193    }
13194
13195    #[test]
13196    fn prompts_filtered_by_tags() {
13197        let mut r = Router::new();
13198        r.add_prompt(NamedPrompt::with_tags("a", vec!["internal".to_string()]));
13199        r.add_prompt(NamedPrompt::with_tags("b", vec!["public".to_string()]));
13200        let exclude = vec!["internal".to_string()];
13201        let filters = TagFilters::new(None, Some(&exclude));
13202        let prompts = r.prompts_filtered(None, Some(&filters));
13203        assert_eq!(prompts.len(), 1);
13204        assert_eq!(prompts[0].name, "b");
13205    }
13206
13207    // ── resource_templates_filtered ────────────────────────────────────
13208
13209    #[test]
13210    fn resource_templates_filtered_by_session_state() {
13211        let mut r = Router::new();
13212        r.add_resource_template(ResourceTemplate {
13213            uri_template: "db://{table}".to_string(),
13214            name: "db".to_string(),
13215            description: None,
13216            mime_type: None,
13217            icon: None,
13218            version: None,
13219            tags: vec!["admin".to_string()],
13220        });
13221        r.add_resource_template(ResourceTemplate {
13222            uri_template: "cache://{key}".to_string(),
13223            name: "cache".to_string(),
13224            description: None,
13225            mime_type: None,
13226            icon: None,
13227            version: None,
13228            tags: vec![],
13229        });
13230        let state = SessionState::new();
13231        let disabled: std::collections::HashSet<String> =
13232            ["db://{table}".to_string()].into_iter().collect();
13233        state.set("fastmcp.disabled_resources", &disabled);
13234        let tmpls = r.resource_templates_filtered(Some(&state), None);
13235        assert_eq!(tmpls.len(), 1);
13236        assert_eq!(tmpls[0].name, "cache");
13237    }
13238
13239    // ── apply_prefix / validate_prefix ─────────────────────────────────
13240
13241    #[test]
13242    fn apply_prefix_with_prefix() {
13243        assert_eq!(Router::apply_prefix("tool", Some("ns")), "ns/tool");
13244    }
13245
13246    #[test]
13247    fn apply_prefix_no_prefix() {
13248        assert_eq!(Router::apply_prefix("tool", None), "tool");
13249    }
13250
13251    #[test]
13252    fn apply_prefix_empty_prefix() {
13253        assert_eq!(Router::apply_prefix("tool", Some("")), "tool");
13254    }
13255
13256    #[test]
13257    fn validate_prefix_valid() {
13258        assert!(Router::validate_prefix("my-prefix_1").is_ok());
13259    }
13260
13261    #[test]
13262    fn validate_prefix_empty_is_ok() {
13263        assert!(Router::validate_prefix("").is_ok());
13264    }
13265
13266    #[test]
13267    fn validate_prefix_rejects_slashes() {
13268        let err = Router::validate_prefix("a/b").unwrap_err();
13269        assert!(err.contains("slashes"));
13270    }
13271
13272    #[test]
13273    fn validate_prefix_rejects_special_chars() {
13274        let err = Router::validate_prefix("a@b").unwrap_err();
13275        assert!(err.contains("invalid character"));
13276    }
13277
13278    // ── MountResult ────────────────────────────────────────────────────
13279
13280    #[test]
13281    fn mount_result_default_has_no_components() {
13282        let r = MountResult::default();
13283        assert!(!r.has_components());
13284        assert!(r.is_success());
13285    }
13286
13287    #[test]
13288    fn mount_result_with_tools_has_components() {
13289        let mut r = MountResult::default();
13290        r.tools = 1;
13291        assert!(r.has_components());
13292    }
13293
13294    #[test]
13295    fn mount_result_debug() {
13296        let r = MountResult::default();
13297        let debug = format!("{:?}", r);
13298        assert!(debug.contains("MountResult"));
13299    }
13300
13301    // ── mount ──────────────────────────────────────────────────────────
13302
13303    #[test]
13304    fn mount_tools_with_prefix() {
13305        let mut main = Router::new();
13306        let mut sub = Router::new();
13307        sub.add_tool(NamedTool::new("query"))
13308            .expect("tool registration succeeds");
13309        let result = main.mount(sub, Some("db"));
13310        assert_eq!(result.tools, 1);
13311        assert!(main.get_tool("db/query").is_some());
13312        assert!(main.get_tool("query").is_none());
13313    }
13314
13315    #[test]
13316    fn mount_without_prefix() {
13317        let mut main = Router::new();
13318        let mut sub = Router::new();
13319        sub.add_tool(NamedTool::new("query"))
13320            .expect("tool registration succeeds");
13321        let result = main.mount(sub, None);
13322        assert_eq!(result.tools, 1);
13323        assert!(main.get_tool("query").is_some());
13324    }
13325
13326    struct RecordingNameCompletion {
13327        names: Arc<Mutex<Vec<String>>>,
13328    }
13329
13330    impl CompletionHandler for RecordingNameCompletion {
13331        fn complete_legacy(
13332            &self,
13333            _ctx: &McpContext,
13334            params: LegacyCompletionParams,
13335        ) -> McpResult<CompletionValues> {
13336            let name = match params.reference {
13337                fastmcp_protocol::LegacyCompletionReference::Prompt { name } => name,
13338                fastmcp_protocol::LegacyCompletionReference::Resource { uri } => uri,
13339            };
13340            self.names
13341                .lock()
13342                .unwrap_or_else(std::sync::PoisonError::into_inner)
13343                .push(name);
13344            Ok(CompletionValues {
13345                values: vec![format!("{}ging", params.argument.value)],
13346                total: Some(1),
13347                has_more: Some(false),
13348            })
13349        }
13350
13351        fn complete_final(
13352            &self,
13353            _ctx: &McpContext,
13354            params: FinalCompletionParams,
13355        ) -> McpResult<fastmcp_protocol::FinalCompletionValues> {
13356            let name = match params.reference {
13357                fastmcp_protocol::FinalCompletionReference::Prompt { name }
13358                | fastmcp_protocol::FinalCompletionReference::PromptWithTitle { name, .. } => name,
13359                fastmcp_protocol::FinalCompletionReference::Resource { uri } => uri,
13360            };
13361            self.names
13362                .lock()
13363                .unwrap_or_else(std::sync::PoisonError::into_inner)
13364                .push(name);
13365            Ok(fastmcp_protocol::FinalCompletionValues {
13366                values: vec![format!("{}ging", params.argument.value)],
13367                total: Some(fastmcp_protocol::JsonInteger::from(1_i64)),
13368                has_more: Some(false),
13369            })
13370        }
13371    }
13372
13373    #[test]
13374    fn mount_transfers_prompt_completion_providers_and_rewrites_prefixed_names() {
13375        let seen = Arc::new(Mutex::new(Vec::new()));
13376        let mut child = Router::new();
13377        child.add_prompt(PromptArgumentBoundary {
13378            final_calls: Arc::new(AtomicUsize::new(0)),
13379            legacy_calls: Arc::new(AtomicUsize::new(0)),
13380        });
13381        child.add_legacy_prompt_completion_handler(
13382            "prompt-argument-boundary",
13383            RecordingNameCompletion {
13384                names: Arc::clone(&seen),
13385            },
13386        );
13387        child.add_prompt_completion_handler(
13388            "prompt-argument-boundary",
13389            RecordingNameCompletion {
13390                names: Arc::clone(&seen),
13391            },
13392        );
13393
13394        let mut main = Router::new();
13395        let result = main.mount(child, Some("ns"));
13396        assert!(result.is_success());
13397        assert_eq!(result.prompts, 1);
13398        assert!(main.has_completion_handler());
13399
13400        let cx = Cx::for_testing();
13401        let state = SessionState::new();
13402        let request_ctx = request_context(&cx, 401, Budget::INFINITE, &state);
13403        let legacy = main
13404            .dispatch_legacy_completion(
13405                &request_ctx,
13406                &JsonRpcRequest::new(
13407                    COMPLETION_COMPLETE,
13408                    Some(serde_json::json!({
13409                        "ref": {"type": "ref/prompt", "name": "ns/prompt-argument-boundary"},
13410                        "argument": {"name": "topic", "value": "sta"},
13411                    })),
13412                    401_i64,
13413                ),
13414            )
13415            .expect("prefixed legacy completion must reach the mounted provider");
13416        assert_eq!(
13417            legacy["completion"]["values"],
13418            serde_json::json!(["staging"])
13419        );
13420
13421        let unprefixed = main.dispatch_legacy_completion(
13422            &request_ctx,
13423            &JsonRpcRequest::new(
13424                COMPLETION_COMPLETE,
13425                Some(serde_json::json!({
13426                    "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
13427                    "argument": {"name": "topic", "value": "sta"},
13428                })),
13429                402_i64,
13430            ),
13431        );
13432        assert!(
13433            unprefixed.is_err(),
13434            "the child's original completion name must not remain after a prefixed mount"
13435        );
13436
13437        let seen = seen
13438            .lock()
13439            .unwrap_or_else(std::sync::PoisonError::into_inner);
13440        assert_eq!(
13441            seen.as_slice(),
13442            ["prompt-argument-boundary"],
13443            "the mounted provider must observe the child's original prompt name"
13444        );
13445    }
13446
13447    #[test]
13448    fn mount_without_prefix_keeps_completion_provider_keys() {
13449        let mut child = Router::new();
13450        child.add_prompt(PromptArgumentBoundary {
13451            final_calls: Arc::new(AtomicUsize::new(0)),
13452            legacy_calls: Arc::new(AtomicUsize::new(0)),
13453        });
13454        child.add_legacy_prompt_completion_handler("prompt-argument-boundary", EchoCompletion);
13455        let mut main = Router::new();
13456        assert!(main.mount(child, None).is_success());
13457        let cx = Cx::for_testing();
13458        let state = SessionState::new();
13459        let request_ctx = request_context(&cx, 403, Budget::INFINITE, &state);
13460        let legacy = main
13461            .dispatch_legacy_completion(
13462                &request_ctx,
13463                &JsonRpcRequest::new(
13464                    COMPLETION_COMPLETE,
13465                    Some(serde_json::json!({
13466                        "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
13467                        "argument": {"name": "topic", "value": "sta"},
13468                    })),
13469                    403_i64,
13470                ),
13471            )
13472            .expect("unprefixed mount keeps the original completion key");
13473        assert_eq!(
13474            legacy["completion"]["values"],
13475            serde_json::json!(["staging"])
13476        );
13477    }
13478
13479    #[test]
13480    fn mount_transfers_resource_template_completion_providers_and_rewrites_prefixed_uris() {
13481        let seen = Arc::new(Mutex::new(Vec::new()));
13482        let mut child = Router::new();
13483        child
13484            .add_resource_template_with_behavior(
13485                marked_template("mcp://books/{id}", "books"),
13486                crate::DuplicateBehavior::Replace,
13487            )
13488            .expect("child resource template is admitted");
13489        child.add_legacy_resource_template_completion_handler(
13490            "mcp://books/{id}",
13491            RecordingNameCompletion {
13492                names: Arc::clone(&seen),
13493            },
13494        );
13495        child.add_resource_template_completion_handler(
13496            "mcp://books/{id}",
13497            RecordingNameCompletion {
13498                names: Arc::clone(&seen),
13499            },
13500        );
13501
13502        let mut main = Router::new();
13503        let result = main.mount(child, Some("ns"));
13504        assert!(result.is_success());
13505        assert_eq!(result.resource_templates, 1);
13506        assert!(main.has_completion_handler());
13507
13508        let cx = Cx::for_testing();
13509        let state = SessionState::new();
13510        let request_ctx = request_context(&cx, 411, Budget::INFINITE, &state);
13511        let legacy = main
13512            .dispatch_legacy_completion(
13513                &request_ctx,
13514                &JsonRpcRequest::new(
13515                    COMPLETION_COMPLETE,
13516                    Some(serde_json::json!({
13517                        "ref": {"type": "ref/resource", "uri": "ns/mcp://books/{id}"},
13518                        "argument": {"name": "id", "value": "sta"},
13519                    })),
13520                    411_i64,
13521                ),
13522            )
13523            .expect("prefixed legacy template completion must reach the mounted provider");
13524        assert_eq!(
13525            legacy["completion"]["values"],
13526            serde_json::json!(["staging"])
13527        );
13528
13529        let unprefixed = main.dispatch_legacy_completion(
13530            &request_ctx,
13531            &JsonRpcRequest::new(
13532                COMPLETION_COMPLETE,
13533                Some(serde_json::json!({
13534                    "ref": {"type": "ref/resource", "uri": "mcp://books/{id}"},
13535                    "argument": {"name": "id", "value": "sta"},
13536                })),
13537                412_i64,
13538            ),
13539        );
13540        assert!(
13541            unprefixed.is_err(),
13542            "the child's original template completion URI must not remain after a prefixed mount"
13543        );
13544
13545        let seen = seen
13546            .lock()
13547            .unwrap_or_else(std::sync::PoisonError::into_inner);
13548        assert_eq!(
13549            seen.as_slice(),
13550            ["mcp://books/{id}"],
13551            "the mounted provider must observe the child's original template URI"
13552        );
13553    }
13554
13555    #[test]
13556    fn prefixed_mount_strips_prefix_for_server_wide_completion_fallback() {
13557        let seen = Arc::new(Mutex::new(Vec::new()));
13558        let mut child = Router::new();
13559        child.add_prompt(PromptArgumentBoundary {
13560            final_calls: Arc::new(AtomicUsize::new(0)),
13561            legacy_calls: Arc::new(AtomicUsize::new(0)),
13562        });
13563        child.add_completion_handler(RecordingNameCompletion {
13564            names: Arc::clone(&seen),
13565        });
13566
13567        let mut main = Router::new();
13568        assert!(main.mount(child, Some("ns")).is_success());
13569
13570        let cx = Cx::for_testing();
13571        let state = SessionState::new();
13572        let request_ctx = request_context(&cx, 414, Budget::INFINITE, &state);
13573        let legacy = main
13574            .dispatch_legacy_completion(
13575                &request_ctx,
13576                &JsonRpcRequest::new(
13577                    COMPLETION_COMPLETE,
13578                    Some(serde_json::json!({
13579                        "ref": {"type": "ref/prompt", "name": "ns/prompt-argument-boundary"},
13580                        "argument": {"name": "topic", "value": "sta"},
13581                    })),
13582                    414_i64,
13583                ),
13584            )
13585            .expect("prefixed fallback completion must reach the child's server-wide handler");
13586        assert_eq!(
13587            legacy["completion"]["values"],
13588            serde_json::json!(["staging"])
13589        );
13590
13591        let seen = seen
13592            .lock()
13593            .unwrap_or_else(std::sync::PoisonError::into_inner);
13594        assert_eq!(
13595            seen.as_slice(),
13596            ["prompt-argument-boundary"],
13597            "the child's server-wide handler must observe the original prompt name"
13598        );
13599    }
13600
13601    #[test]
13602    fn mount_prompts_does_not_replace_destination_fallback_completion_handler() {
13603        let mut dest = Router::new();
13604        dest.add_prompt(NamedPrompt::new("kept"));
13605        dest.add_completion_handler(CountingCompletion {
13606            final_calls: Arc::new(AtomicUsize::new(0)),
13607        });
13608        let mut child = Router::new();
13609        child.add_prompt(NamedPrompt::new("incoming"));
13610        child.add_completion_handler(EchoCompletion);
13611
13612        assert!(
13613            dest.mount_prompts_with_behavior(child, None, crate::DuplicateBehavior::Replace)
13614                .is_success()
13615        );
13616
13617        let cx = Cx::for_testing();
13618        let state = SessionState::new();
13619        let request_ctx = request_context(&cx, 413, Budget::INFINITE, &state);
13620        let kept = dest
13621            .dispatch_legacy_completion(
13622                &request_ctx,
13623                &JsonRpcRequest::new(
13624                    COMPLETION_COMPLETE,
13625                    Some(serde_json::json!({
13626                        "ref": {"type": "ref/prompt", "name": "kept"},
13627                        "argument": {"name": "topic", "value": "sta"},
13628                    })),
13629                    413_i64,
13630                ),
13631            )
13632            .expect("prompts-only mount must keep the destination fallback completion handler");
13633        assert_eq!(kept["completion"]["values"], serde_json::json!(["legacy"]));
13634    }
13635
13636    #[test]
13637    fn mount_resources_with_prefix() {
13638        let mut main = Router::new();
13639        let mut sub = Router::new();
13640        sub.add_resource(NamedResource::new("file:///a"));
13641        let result = main.mount(sub, Some("ns"));
13642        assert_eq!(result.resources, 1);
13643        assert!(main.get_resource("ns/file:///a").is_some());
13644
13645        let cx = Cx::for_testing();
13646        let state = SessionState::new();
13647        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
13648        let read = main
13649            .handle_resources_read(
13650                &request_ctx,
13651                &ReadResourceParams {
13652                    uri: "ns/file:///a".to_string(),
13653                    meta: None,
13654                },
13655                state,
13656                None,
13657                None,
13658            )
13659            .expect("mounted resource is readable through its public URI");
13660        let [
13661            LegacyResourceContent::Text {
13662                uri,
13663                text,
13664                additional,
13665                ..
13666            },
13667        ] = read.contents.as_slice()
13668        else {
13669            panic!("mounted resource must retain the exact legacy text shape");
13670        };
13671        assert_eq!(uri, "ns/file:///a");
13672        assert_eq!(text, "content");
13673        assert!(additional.is_empty());
13674    }
13675
13676    #[test]
13677    fn mount_namespaced_prefixes_tools_and_keeps_resource_uris() {
13678        let mut main = Router::new();
13679        let mut sub = Router::new();
13680        sub.add_tool(NamedTool::new("query"))
13681            .expect("namespaced query tool registers");
13682        sub.add_resource(NamedResource::new("file:///a"));
13683        let result =
13684            main.mount_namespaced_with_behavior(sub, Some("ns"), crate::DuplicateBehavior::Replace);
13685        assert!(result.is_success());
13686        assert_eq!(result.tools, 1);
13687        assert_eq!(result.resources, 1);
13688        assert!(main.get_tool("ns/query").is_some());
13689        assert!(main.get_tool("query").is_none());
13690        assert!(main.get_resource("file:///a").is_some());
13691        assert!(main.get_resource("ns/file:///a").is_none());
13692        assert!(main.final_resources.contains_key("file:///a"));
13693    }
13694
13695    #[test]
13696    fn mounting_resource_does_not_requery_one_shot_definition() {
13697        let mut source = Router::new();
13698        source.add_resource(DefinitionPanicResource(std::sync::atomic::AtomicBool::new(
13699            false,
13700        )));
13701        let mut destination = Router::new();
13702
13703        let result = destination.mount_resources(source, Some("ns"));
13704
13705        assert!(result.is_success());
13706        assert_eq!(result.resources, 1);
13707        assert!(destination.get_resource("ns/panic://definition").is_some());
13708    }
13709
13710    #[test]
13711    fn mounting_resource_template_does_not_requery_one_shot_template() {
13712        let mut source = Router::new();
13713        source.add_resource(TemplatePanicResource(std::sync::atomic::AtomicBool::new(
13714            false,
13715        )));
13716        let mut destination = Router::new();
13717
13718        let result = destination.mount_resources(source, Some("ns"));
13719
13720        assert!(result.is_success());
13721        assert_eq!(result.resource_templates, 1);
13722        assert!(
13723            destination
13724                .get_resource_template("ns/panic-template://{id}")
13725                .is_some()
13726        );
13727    }
13728
13729    #[test]
13730    fn prefixed_resource_template_mount_is_legacy_only_after_final_source_dispatch() {
13731        struct FinalTemplateResource;
13732
13733        impl ResourceHandler for FinalTemplateResource {
13734            fn definition(&self) -> Resource {
13735                Resource {
13736                    uri: "mcp://mounted/template".to_owned(),
13737                    name: "mounted-template".to_owned(),
13738                    description: None,
13739                    mime_type: Some("text/plain".to_owned()),
13740                    icon: None,
13741                    version: None,
13742                    tags: Vec::new(),
13743                }
13744            }
13745
13746            fn template(&self) -> Option<ResourceTemplate> {
13747                Some(marked_template("mcp://mounted/{id}", "mounted-template"))
13748            }
13749
13750            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
13751                unreachable!("templated reads receive their URI parameters")
13752            }
13753
13754            fn read_with_uri(
13755                &self,
13756                _ctx: &McpContext,
13757                uri: &str,
13758                _params: &UriParams,
13759            ) -> McpResult<Vec<ResourceContent>> {
13760                Ok(vec![ResourceContent {
13761                    uri: uri.to_owned(),
13762                    mime_type: Some("text/plain".to_owned()),
13763                    text: Some("source-template".to_owned()),
13764                    blob: None,
13765                }])
13766            }
13767        }
13768
13769        let cx = Cx::for_testing();
13770        let state = SessionState::new();
13771        let request_ctx = request_context(&cx, 191, Budget::INFINITE, &state);
13772        let final_metadata = serde_json::json!({
13773            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
13774            "io.modelcontextprotocol/clientCapabilities": {},
13775        });
13776        let mut source = Router::new();
13777        source.add_resource(FinalTemplateResource);
13778
13779        let source_final_read = source
13780            .dispatch_stateless(
13781                &request_ctx,
13782                &JsonRpcRequest::new(
13783                    "resources/read",
13784                    Some(serde_json::json!({
13785                        "_meta": final_metadata.clone(),
13786                        "uri": "mcp://mounted/item",
13787                    })),
13788                    191_i64,
13789                ),
13790            )
13791            .expect("the unprefixed final template dispatches");
13792        assert_eq!(source_final_read["contents"][0]["text"], "source-template");
13793
13794        let mut destination = Router::new();
13795        let mounted = destination.mount_resources(source, Some("peer"));
13796        assert!(mounted.is_success());
13797        assert_eq!(mounted.resource_templates, 1);
13798
13799        let legacy_templates = destination
13800            .handle_resource_templates_list(
13801                &request_ctx,
13802                ListResourceTemplatesParams::default(),
13803                None,
13804            )
13805            .expect("the prefixed template remains in legacy discovery");
13806        assert_eq!(
13807            legacy_templates.resource_templates[0].uri_template,
13808            "peer/mcp://mounted/{id}"
13809        );
13810        let legacy_read = destination
13811            .handle_resources_read(
13812                &request_ctx,
13813                &ReadResourceParams {
13814                    uri: "peer/mcp://mounted/item".to_owned(),
13815                    meta: None,
13816                },
13817                state.clone(),
13818                None,
13819                None,
13820            )
13821            .expect("the prefixed template remains readable on the legacy surface");
13822        assert_eq!(
13823            serde_json::to_value(legacy_read).expect("legacy result serializes")["contents"][0]["text"],
13824            "source-template"
13825        );
13826
13827        let final_templates = destination
13828            .dispatch_stateless(
13829                &request_ctx,
13830                &JsonRpcRequest::new(
13831                    "resources/templates/list",
13832                    Some(serde_json::json!({"_meta": final_metadata.clone()})),
13833                    192_i64,
13834                ),
13835            )
13836            .expect("prefixed legacy-only templates do not break final discovery");
13837        assert_eq!(final_templates["resourceTemplates"], serde_json::json!([]));
13838
13839        let final_error = destination
13840            .dispatch_stateless(
13841                &request_ctx,
13842                &JsonRpcRequest::new(
13843                    "resources/read",
13844                    Some(serde_json::json!({
13845                        "_meta": final_metadata,
13846                        "uri": "peer/mcp://mounted/item",
13847                    })),
13848                    193_i64,
13849                ),
13850            )
13851            .expect_err("the relative mounted namespace is not exposed to final dispatch");
13852        assert_eq!(final_error.code, McpErrorCode::InvalidParams);
13853    }
13854
13855    #[test]
13856    fn nested_resource_mounts_translate_both_namespace_layers() {
13857        let mut leaf = Router::new();
13858        leaf.add_resource(NamedResource::new("file:///a"));
13859
13860        let mut middle = Router::new();
13861        assert!(middle.mount(leaf, Some("ns")).is_success());
13862        let mut outer = Router::new();
13863        assert!(outer.mount(middle, Some("ns")).is_success());
13864
13865        let cx = Cx::for_testing();
13866        let state = SessionState::new();
13867        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
13868        let read = outer
13869            .handle_resources_read(
13870                &request_ctx,
13871                &ReadResourceParams {
13872                    uri: "ns/ns/file:///a".to_string(),
13873                    meta: None,
13874                },
13875                state,
13876                None,
13877                None,
13878            )
13879            .expect("nested mounted resource is readable");
13880        let [
13881            LegacyResourceContent::Text {
13882                uri,
13883                text,
13884                additional,
13885                ..
13886            },
13887        ] = read.contents.as_slice()
13888        else {
13889            panic!("nested mounted resource must retain the exact legacy text shape");
13890        };
13891        assert_eq!(uri, "ns/ns/file:///a");
13892        assert_eq!(text, "content");
13893        assert!(additional.is_empty());
13894    }
13895
13896    #[test]
13897    fn nested_resource_template_mounts_translate_every_namespace_layer() {
13898        struct TemplatedResource;
13899
13900        impl ResourceHandler for TemplatedResource {
13901            fn definition(&self) -> Resource {
13902                Resource {
13903                    uri: "db://placeholder".to_string(),
13904                    name: "database".to_string(),
13905                    description: None,
13906                    mime_type: Some("text/plain".to_string()),
13907                    icon: None,
13908                    version: None,
13909                    tags: vec![],
13910                }
13911            }
13912
13913            fn template(&self) -> Option<ResourceTemplate> {
13914                Some(ResourceTemplate {
13915                    uri_template: "db://{table}".to_string(),
13916                    name: "database".to_string(),
13917                    description: None,
13918                    mime_type: Some("text/plain".to_string()),
13919                    icon: None,
13920                    version: None,
13921                    tags: vec![],
13922                })
13923            }
13924
13925            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
13926                unreachable!("templated reads use read_with_uri")
13927            }
13928
13929            fn read_with_uri(
13930                &self,
13931                _ctx: &McpContext,
13932                uri: &str,
13933                params: &UriParams,
13934            ) -> McpResult<Vec<ResourceContent>> {
13935                Ok(vec![ResourceContent {
13936                    uri: uri.to_string(),
13937                    mime_type: Some("text/plain".to_string()),
13938                    text: params.get("table").cloned(),
13939                    blob: None,
13940                }])
13941            }
13942        }
13943
13944        let mut source = Router::new();
13945        source.add_resource(TemplatedResource);
13946        let mut middle = Router::new();
13947        assert!(middle.mount_resources(source, Some("peer")).is_success());
13948        let mut mounted = Router::new();
13949        assert!(mounted.mount_resources(middle, Some("peer")).is_success());
13950
13951        let cx = Cx::for_testing();
13952        let state = SessionState::new();
13953        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
13954        let read = mounted
13955            .handle_resources_read(
13956                &request_ctx,
13957                &ReadResourceParams {
13958                    uri: "peer/peer/db://users".to_string(),
13959                    meta: None,
13960                },
13961                state,
13962                None,
13963                None,
13964            )
13965            .expect("mounted template is readable through its public URI");
13966        let [
13967            LegacyResourceContent::Text {
13968                uri,
13969                text,
13970                additional,
13971                ..
13972            },
13973        ] = read.contents.as_slice()
13974        else {
13975            panic!("mounted template must retain the exact legacy text shape");
13976        };
13977        assert_eq!(uri, "peer/peer/db://users");
13978        assert_eq!(text, "users");
13979        assert!(additional.is_empty());
13980    }
13981
13982    #[test]
13983    fn handle_resources_read_resolves_true_async_mounted_template_and_translates_uri() {
13984        struct AsyncTemplatedResource {
13985            observed: Arc<Mutex<Option<(String, String)>>>,
13986        }
13987
13988        impl ResourceHandler for AsyncTemplatedResource {
13989            fn definition(&self) -> Resource {
13990                Resource {
13991                    uri: "async-db://placeholder".to_string(),
13992                    name: "async-database".to_string(),
13993                    description: None,
13994                    mime_type: Some("text/plain".to_string()),
13995                    icon: None,
13996                    version: None,
13997                    tags: vec![],
13998                }
13999            }
14000
14001            fn template(&self) -> Option<ResourceTemplate> {
14002                Some(ResourceTemplate {
14003                    uri_template: "async-db://{table}".to_string(),
14004                    name: "async-database".to_string(),
14005                    description: None,
14006                    mime_type: Some("text/plain".to_string()),
14007                    icon: None,
14008                    version: None,
14009                    tags: vec![],
14010                })
14011            }
14012
14013            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
14014                unreachable!("router must dispatch templated resources through the async override")
14015            }
14016
14017            fn read_with_uri(
14018                &self,
14019                _ctx: &McpContext,
14020                _uri: &str,
14021                _params: &UriParams,
14022            ) -> McpResult<Vec<ResourceContent>> {
14023                unreachable!("router must not fall back to the synchronous templated read")
14024            }
14025
14026            fn read_async_with_uri<'a>(
14027                &'a self,
14028                _ctx: &'a McpContext,
14029                uri: &'a str,
14030                params: &'a UriParams,
14031            ) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
14032                Box::pin(async move {
14033                    let mut first_poll = true;
14034                    std::future::poll_fn(move |waker| {
14035                        if std::mem::take(&mut first_poll) {
14036                            waker.waker().wake_by_ref();
14037                            std::task::Poll::Pending
14038                        } else {
14039                            std::task::Poll::Ready(())
14040                        }
14041                    })
14042                    .await;
14043                    let Some(table) = params.get("table").cloned() else {
14044                        return Outcome::Err(McpError::invalid_params(
14045                            "mounted template did not resolve its table parameter",
14046                        ));
14047                    };
14048                    *self.observed.lock().expect("observation mutex poisoned") =
14049                        Some((uri.to_string(), table.clone()));
14050                    Outcome::Ok(vec![ResourceContent {
14051                        uri: uri.to_string(),
14052                        mime_type: Some("text/plain".to_string()),
14053                        text: Some(format!("async table {table}")),
14054                        blob: None,
14055                    }])
14056                })
14057            }
14058        }
14059
14060        let observed = Arc::new(Mutex::new(None));
14061        let mut source = Router::new();
14062        source.add_resource(AsyncTemplatedResource {
14063            observed: Arc::clone(&observed),
14064        });
14065        let mut mounted = Router::new();
14066        let mount_result = mounted.mount_resources(source, Some("peer"));
14067        assert!(mount_result.is_success());
14068        assert_eq!(mount_result.resource_templates, 1);
14069
14070        let cx = Cx::for_testing();
14071        let state = SessionState::new();
14072        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14073        let read = mounted
14074            .handle_resources_read(
14075                &request_ctx,
14076                &ReadResourceParams {
14077                    uri: "peer/async-db://users".to_string(),
14078                    meta: None,
14079                },
14080                state,
14081                None,
14082                None,
14083            )
14084            .expect("true-async mounted template is readable through its public URI");
14085
14086        assert_eq!(read.contents.len(), 1);
14087        let [
14088            LegacyResourceContent::Text {
14089                uri,
14090                text,
14091                additional,
14092                ..
14093            },
14094        ] = read.contents.as_slice()
14095        else {
14096            panic!("async mounted template must retain the exact legacy text shape");
14097        };
14098        assert_eq!(uri, "peer/async-db://users");
14099        assert_eq!(text, "async table users");
14100        assert!(additional.is_empty());
14101        assert_eq!(
14102            *observed.lock().expect("observation mutex poisoned"),
14103            Some(("async-db://users".to_string(), "users".to_string()))
14104        );
14105    }
14106
14107    #[test]
14108    fn mount_prompts_with_prefix() {
14109        let mut main = Router::new();
14110        let mut sub = Router::new();
14111        sub.add_prompt(NamedPrompt::new("greet"));
14112        let result = main.mount(sub, Some("ns"));
14113        assert_eq!(result.prompts, 1);
14114        assert!(main.get_prompt("ns/greet").is_some());
14115    }
14116
14117    #[test]
14118    fn mount_warns_on_conflict() {
14119        let mut main = Router::new();
14120        main.add_tool(NamedTool::new("t"))
14121            .expect("tool registration succeeds");
14122        let mut sub = Router::new();
14123        sub.add_tool(NamedTool::new("t"))
14124            .expect("tool registration succeeds");
14125        let result = main.mount(sub, None);
14126        assert_eq!(result.tools, 1);
14127        assert!(!result.warnings.is_empty());
14128        assert!(result.warnings[0].contains("already exists"));
14129    }
14130
14131    #[test]
14132    fn mount_rejects_invalid_prefix_without_mutating() {
14133        let mut main = Router::new();
14134        main.add_tool(NamedTool::new("original"))
14135            .expect("tool registration succeeds");
14136        let mut sub = Router::new();
14137        sub.add_tool(NamedTool::new("incoming"))
14138            .expect("tool registration succeeds");
14139        let result = main.mount(sub, Some("bad/prefix"));
14140        assert!(!result.is_success());
14141        assert_eq!(result.tools, 0);
14142        assert!(!result.warnings.is_empty());
14143        assert!(result.warnings[0].contains("slashes"));
14144        assert!(!result.errors[0].contains("bad/prefix"));
14145        assert_eq!(main.tools_count(), 1);
14146        assert!(main.get_tool("original").is_some());
14147        assert!(main.get_tool("bad/prefix/incoming").is_none());
14148    }
14149
14150    #[test]
14151    fn mount_with_behavior_honors_policy_for_every_component_kind() {
14152        for behavior in [
14153            crate::DuplicateBehavior::Warn,
14154            crate::DuplicateBehavior::Ignore,
14155            crate::DuplicateBehavior::Replace,
14156            crate::DuplicateBehavior::Error,
14157        ] {
14158            let mut main = marked_router("original");
14159            let mut sub = marked_router("incoming");
14160            if behavior == crate::DuplicateBehavior::Error {
14161                sub.add_tool(NamedTool::new("unique_tool"))
14162                    .expect("tool registration succeeds");
14163            }
14164
14165            let result = main.mount_with_behavior(sub, None, behavior);
14166            let replaced = behavior == crate::DuplicateBehavior::Replace;
14167            let rejected = behavior == crate::DuplicateBehavior::Error;
14168            let expected_mounted = usize::from(replaced);
14169
14170            assert_eq!(result.is_success(), !rejected);
14171            assert_eq!(result.tools, expected_mounted);
14172            assert_eq!(result.resources, expected_mounted);
14173            assert_eq!(result.resource_templates, expected_mounted);
14174            assert_eq!(result.prompts, expected_mounted);
14175            assert_eq!(result.errors.len(), if rejected { 4 } else { 0 });
14176            assert_eq!(
14177                result.warnings.len(),
14178                if matches!(
14179                    behavior,
14180                    crate::DuplicateBehavior::Warn | crate::DuplicateBehavior::Replace
14181                ) {
14182                    4
14183                } else {
14184                    0
14185                }
14186            );
14187            assert_router_marker(&main, if replaced { "incoming" } else { "original" });
14188            assert!(main.get_tool("unique_tool").is_none());
14189
14190            for message in result.warnings.iter().chain(&result.errors) {
14191                assert!(!message.contains("duplicate_tool"));
14192                assert!(!message.contains("duplicate://resource"));
14193                assert!(!message.contains("mcp://duplicate-item/{item}"));
14194                assert!(!message.contains("duplicate_prompt"));
14195            }
14196        }
14197    }
14198
14199    #[test]
14200    fn behavior_aware_partial_mounts_preflight_error_atomically() {
14201        let mut tools = Router::new();
14202        tools
14203            .add_tool(NamedTool::with_tags("same", vec!["original".to_string()]))
14204            .expect("tool registration succeeds");
14205        let mut tool_source = Router::new();
14206        tool_source
14207            .add_tool(NamedTool::with_tags("same", vec!["incoming".to_string()]))
14208            .expect("tool registration succeeds");
14209        tool_source
14210            .add_tool(NamedTool::new("unique"))
14211            .expect("tool registration succeeds");
14212        let tool_result =
14213            tools.mount_tools_with_behavior(tool_source, None, crate::DuplicateBehavior::Error);
14214        assert!(!tool_result.is_success());
14215        assert_eq!(tool_result.tools, 0);
14216        assert_eq!(tools.tools_count(), 1);
14217        assert_eq!(
14218            tools.get_tool("same").unwrap().definition().tags,
14219            vec!["original".to_string()]
14220        );
14221        assert!(tools.get_tool("unique").is_none());
14222
14223        let mut resources = Router::new();
14224        resources.add_resource(NamedResource::with_tags(
14225            "same://resource",
14226            vec!["original".to_string()],
14227        ));
14228        let mut resource_source = Router::new();
14229        resource_source.add_resource(NamedResource::with_tags(
14230            "same://resource",
14231            vec!["incoming".to_string()],
14232        ));
14233        resource_source.add_resource_template(marked_template("unique://{item}", "incoming"));
14234        let resource_result = resources.mount_resources_with_behavior(
14235            resource_source,
14236            None,
14237            crate::DuplicateBehavior::Error,
14238        );
14239        assert!(!resource_result.is_success());
14240        assert_eq!(resource_result.resources, 0);
14241        assert_eq!(resource_result.resource_templates, 0);
14242        assert_eq!(resources.resources_count(), 1);
14243        assert_eq!(resources.resource_templates_count(), 0);
14244        assert_eq!(
14245            resources
14246                .get_resource("same://resource")
14247                .unwrap()
14248                .definition()
14249                .tags,
14250            vec!["original".to_string()]
14251        );
14252
14253        let mut prompts = Router::new();
14254        prompts.add_prompt(NamedPrompt::with_tags("same", vec!["original".to_string()]));
14255        let mut prompt_source = Router::new();
14256        prompt_source.add_prompt(NamedPrompt::with_tags("same", vec!["incoming".to_string()]));
14257        prompt_source.add_prompt(NamedPrompt::new("unique"));
14258        let prompt_result = prompts.mount_prompts_with_behavior(
14259            prompt_source,
14260            None,
14261            crate::DuplicateBehavior::Error,
14262        );
14263        assert!(!prompt_result.is_success());
14264        assert_eq!(prompt_result.prompts, 0);
14265        assert_eq!(prompts.prompts_count(), 1);
14266        assert_eq!(
14267            prompts.get_prompt("same").unwrap().definition().tags,
14268            vec!["original".to_string()]
14269        );
14270        assert!(prompts.get_prompt("unique").is_none());
14271    }
14272
14273    #[test]
14274    fn full_error_mount_is_atomic_across_component_kinds() {
14275        let mut main = Router::new();
14276        main.add_tool(NamedTool::with_tags(
14277            "conflict",
14278            vec!["original".to_string()],
14279        ))
14280        .expect("tool registration succeeds");
14281
14282        let mut sub = Router::new();
14283        sub.add_tool(NamedTool::with_tags(
14284            "conflict",
14285            vec!["incoming".to_string()],
14286        ))
14287        .expect("tool registration succeeds");
14288        sub.add_resource(NamedResource::new("unique://resource"));
14289        sub.add_resource_template(marked_template("unique://{item}", "incoming"));
14290        sub.add_prompt(NamedPrompt::new("unique_prompt"));
14291
14292        let result = main.mount_with_behavior(sub, None, crate::DuplicateBehavior::Error);
14293        assert!(!result.is_success());
14294        assert_eq!(result.errors.len(), 1);
14295        assert!(!result.has_components());
14296        assert_eq!(main.tools_count(), 1);
14297        assert_eq!(main.resources_count(), 0);
14298        assert_eq!(main.resource_templates_count(), 0);
14299        assert_eq!(main.prompts_count(), 0);
14300        assert_eq!(
14301            main.get_tool("conflict").unwrap().definition().tags,
14302            vec!["original".to_string()]
14303        );
14304    }
14305
14306    #[test]
14307    fn invalid_prefix_rejects_every_partial_mount_without_mutation() {
14308        let mut tools = Router::new();
14309        let mut tool_source = Router::new();
14310        tool_source
14311            .add_tool(NamedTool::new("tool"))
14312            .expect("tool registration succeeds");
14313        let tool_result = tools.mount_tools_with_behavior(
14314            tool_source,
14315            Some("peer/secret"),
14316            crate::DuplicateBehavior::Replace,
14317        );
14318        assert!(!tool_result.is_success());
14319        assert_eq!(tools.tools_count(), 0);
14320
14321        let mut resources = Router::new();
14322        let mut resource_source = Router::new();
14323        resource_source.add_resource(NamedResource::new("resource://value"));
14324        resource_source.add_resource_template(marked_template("template://{value}", "incoming"));
14325        let resource_result = resources.mount_resources_with_behavior(
14326            resource_source,
14327            Some("peer/secret"),
14328            crate::DuplicateBehavior::Replace,
14329        );
14330        assert!(!resource_result.is_success());
14331        assert_eq!(resources.resources_count(), 0);
14332        assert_eq!(resources.resource_templates_count(), 0);
14333
14334        let mut prompts = Router::new();
14335        let mut prompt_source = Router::new();
14336        prompt_source.add_prompt(NamedPrompt::new("prompt"));
14337        let prompt_result = prompts.mount_prompts_with_behavior(
14338            prompt_source,
14339            Some("peer/secret"),
14340            crate::DuplicateBehavior::Replace,
14341        );
14342        assert!(!prompt_result.is_success());
14343        assert_eq!(prompts.prompts_count(), 0);
14344
14345        for result in [tool_result, resource_result, prompt_result] {
14346            assert_eq!(result.errors.len(), 1);
14347            assert!(!result.errors[0].contains("peer/secret"));
14348            assert!(!result.has_components());
14349        }
14350    }
14351
14352    // ── mount_tools / mount_resources / mount_prompts ──────────────────
14353
14354    #[test]
14355    fn mount_tools_only() {
14356        let mut main = Router::new();
14357        let mut sub = Router::new();
14358        sub.add_tool(NamedTool::new("t1"))
14359            .expect("tool registration succeeds");
14360        sub.add_prompt(NamedPrompt::new("p1"));
14361        let result = main.mount_tools(sub, Some("ns"));
14362        assert_eq!(result.tools, 1);
14363        assert!(main.get_tool("ns/t1").is_some());
14364        assert_eq!(main.prompts_count(), 0); // prompts not mounted
14365    }
14366
14367    #[test]
14368    fn mount_prompts_only() {
14369        let mut main = Router::new();
14370        let mut sub = Router::new();
14371        sub.add_tool(NamedTool::new("t1"))
14372            .expect("tool registration succeeds");
14373        sub.add_prompt(NamedPrompt::new("p1"));
14374        let result = main.mount_prompts(sub, Some("ns"));
14375        assert_eq!(result.prompts, 1);
14376        assert!(main.get_prompt("ns/p1").is_some());
14377        assert_eq!(main.tools_count(), 0); // tools not mounted
14378    }
14379
14380    // ── handle_tools_list pagination ───────────────────────────────────
14381
14382    #[test]
14383    fn handle_tools_list_no_pagination() {
14384        let mut r = Router::new();
14385        r.add_tool(NamedTool::new("a"))
14386            .expect("tool registration succeeds");
14387        r.add_tool(NamedTool::new("b"))
14388            .expect("tool registration succeeds");
14389        let cx = Cx::for_testing();
14390        let params = ListToolsParams {
14391            cursor: None,
14392            include_tags: None,
14393            exclude_tags: None,
14394        };
14395        let request_ctx = McpContext::new(cx, 1);
14396        let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
14397        assert_eq!(result.tools.len(), 2);
14398        assert!(result.next_cursor.is_none());
14399    }
14400
14401    #[test]
14402    fn handle_tools_list_with_pagination() {
14403        let mut r = Router::new();
14404        r.set_list_page_size(Some(1));
14405        r.add_tool(NamedTool::new("a"))
14406            .expect("tool registration succeeds");
14407        r.add_tool(NamedTool::new("b"))
14408            .expect("tool registration succeeds");
14409        let cx = Cx::for_testing();
14410        let request_ctx = McpContext::new(cx, 1);
14411
14412        // First page
14413        let params = ListToolsParams {
14414            cursor: None,
14415            include_tags: None,
14416            exclude_tags: None,
14417        };
14418        let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
14419        assert_eq!(result.tools.len(), 1);
14420        assert_eq!(result.tools[0].name, "a");
14421        assert!(result.next_cursor.is_some());
14422
14423        // Second page
14424        let params = ListToolsParams {
14425            cursor: result.next_cursor,
14426            include_tags: None,
14427            exclude_tags: None,
14428        };
14429        let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
14430        assert_eq!(result.tools.len(), 1);
14431        assert_eq!(result.tools[0].name, "b");
14432        assert!(result.next_cursor.is_none());
14433    }
14434
14435    #[test]
14436    fn handle_tools_list_with_tag_filter() {
14437        let mut r = Router::new();
14438        r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
14439            .expect("tool registration succeeds");
14440        r.add_tool(NamedTool::with_tags("b", vec!["web".to_string()]))
14441            .expect("tool registration succeeds");
14442        let cx = Cx::for_testing();
14443        let params = ListToolsParams {
14444            cursor: None,
14445            include_tags: Some(vec!["db".to_string()]),
14446            exclude_tags: None,
14447        };
14448        let request_ctx = McpContext::new(cx, 1);
14449        let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
14450        assert_eq!(result.tools.len(), 1);
14451        assert_eq!(result.tools[0].name, "a");
14452    }
14453
14454    // ── handle_resources_list pagination ───────────────────────────────
14455
14456    #[test]
14457    fn handle_resources_list_no_pagination() {
14458        let mut r = Router::new();
14459        r.add_resource(NamedResource::new("file:///a"));
14460        let cx = Cx::for_testing();
14461        let params = ListResourcesParams {
14462            cursor: None,
14463            include_tags: None,
14464            exclude_tags: None,
14465        };
14466        let request_ctx = McpContext::new(cx, 1);
14467        let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
14468        assert_eq!(result.resources.len(), 1);
14469        assert!(result.next_cursor.is_none());
14470    }
14471
14472    #[test]
14473    fn handle_resources_list_with_pagination() {
14474        let mut r = Router::new();
14475        r.set_list_page_size(Some(1));
14476        r.add_resource(NamedResource::new("file:///a"));
14477        r.add_resource(NamedResource::new("file:///b"));
14478        let cx = Cx::for_testing();
14479        let params = ListResourcesParams {
14480            cursor: None,
14481            include_tags: None,
14482            exclude_tags: None,
14483        };
14484        let request_ctx = McpContext::new(cx, 1);
14485        let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
14486        assert_eq!(result.resources.len(), 1);
14487        assert!(result.next_cursor.is_some());
14488    }
14489
14490    // ── handle_prompts_list pagination ─────────────────────────────────
14491
14492    #[test]
14493    fn handle_prompts_list_no_pagination() {
14494        let mut r = Router::new();
14495        r.add_prompt(NamedPrompt::new("greet"));
14496        let cx = Cx::for_testing();
14497        let params = ListPromptsParams {
14498            cursor: None,
14499            include_tags: None,
14500            exclude_tags: None,
14501        };
14502        let request_ctx = McpContext::new(cx, 1);
14503        let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
14504        assert_eq!(result.prompts.len(), 1);
14505        assert!(result.next_cursor.is_none());
14506    }
14507
14508    // ── handle_resource_templates_list ──────────────────────────────────
14509
14510    #[test]
14511    fn handle_resource_templates_list_no_pagination() {
14512        let mut r = Router::new();
14513        r.add_resource_template(ResourceTemplate {
14514            uri_template: "db://{table}".to_string(),
14515            name: "db".to_string(),
14516            description: None,
14517            mime_type: None,
14518            icon: None,
14519            version: None,
14520            tags: vec![],
14521        });
14522        let cx = Cx::for_testing();
14523        let params = ListResourceTemplatesParams {
14524            cursor: None,
14525            include_tags: None,
14526            exclude_tags: None,
14527        };
14528        let request_ctx = McpContext::new(cx, 1);
14529        let result = r
14530            .handle_resource_templates_list(&request_ctx, params, None)
14531            .unwrap();
14532        assert_eq!(result.resource_templates.len(), 1);
14533        assert!(result.next_cursor.is_none());
14534    }
14535
14536    // ── handle_initialize ──────────────────────────────────────────────
14537
14538    #[test]
14539    fn handle_initialize_returns_protocol_version() {
14540        let r = Router::new();
14541        let cx = Cx::for_testing();
14542        let mut session = Session::new(
14543            fastmcp_protocol::ServerInfo {
14544                name: "test".to_string(),
14545                version: "1.0".to_string(),
14546            },
14547            fastmcp_protocol::ServerCapabilities::default(),
14548        );
14549        let params = InitializeParams {
14550            protocol_version: PROTOCOL_VERSION.to_string(),
14551            capabilities: fastmcp_protocol::ClientCapabilities::default(),
14552            client_info: fastmcp_protocol::ClientInfo {
14553                name: "test-client".to_string(),
14554                version: "1.0".to_string(),
14555            },
14556        };
14557        let request_ctx = McpContext::new(cx, 1);
14558        let result = r
14559            .handle_initialize(
14560                &request_ctx,
14561                &mut session,
14562                params,
14563                Some("test instructions"),
14564            )
14565            .unwrap();
14566        assert_eq!(result.protocol_version, PROTOCOL_VERSION);
14567        assert_eq!(result.server_info.name, "test");
14568        assert_eq!(result.instructions.as_deref(), Some("test instructions"));
14569    }
14570
14571    #[test]
14572    fn handle_initialize_no_instructions() {
14573        let r = Router::new();
14574        let cx = Cx::for_testing();
14575        let mut session = Session::new(
14576            fastmcp_protocol::ServerInfo {
14577                name: "srv".to_string(),
14578                version: "0.1".to_string(),
14579            },
14580            fastmcp_protocol::ServerCapabilities::default(),
14581        );
14582        let params = InitializeParams {
14583            protocol_version: PROTOCOL_VERSION.to_string(),
14584            capabilities: fastmcp_protocol::ClientCapabilities::default(),
14585            client_info: fastmcp_protocol::ClientInfo {
14586                name: "c".to_string(),
14587                version: "0.1".to_string(),
14588            },
14589        };
14590        let request_ctx = McpContext::new(cx, 1);
14591        let result = r
14592            .handle_initialize(&request_ctx, &mut session, params, None)
14593            .unwrap();
14594        assert!(result.instructions.is_none());
14595    }
14596
14597    // ── add_resource_with_behavior (Warn / Replace) ─────────────────────
14598
14599    #[test]
14600    fn add_resource_behavior_warn_keeps_original() {
14601        let mut r = Router::new();
14602        r.add_resource(NamedResource::new("file:///a"));
14603        r.add_resource_with_behavior(
14604            NamedResource::new("file:///a"),
14605            crate::DuplicateBehavior::Warn,
14606        )
14607        .unwrap();
14608        assert_eq!(r.resources_count(), 1);
14609    }
14610
14611    #[test]
14612    fn add_resource_behavior_replace() {
14613        let mut r = Router::new();
14614        r.add_resource(NamedResource::new("file:///a"));
14615        r.add_resource_with_behavior(
14616            NamedResource::new("file:///a"),
14617            crate::DuplicateBehavior::Replace,
14618        )
14619        .unwrap();
14620        assert_eq!(r.resources_count(), 1);
14621    }
14622
14623    #[test]
14624    fn add_resource_behavior_new_resource_ok() {
14625        let mut r = Router::new();
14626        r.add_resource_with_behavior(
14627            NamedResource::new("file:///a"),
14628            crate::DuplicateBehavior::Error,
14629        )
14630        .unwrap();
14631        assert_eq!(r.resources_count(), 1);
14632    }
14633
14634    // ── add_prompt_with_behavior (Replace / Ignore / new) ───────────────
14635
14636    #[test]
14637    fn add_prompt_behavior_replace() {
14638        let mut r = Router::new();
14639        r.add_prompt(NamedPrompt::new("p"));
14640        r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Replace)
14641            .unwrap();
14642        assert_eq!(r.prompts_count(), 1);
14643    }
14644
14645    #[test]
14646    fn add_prompt_behavior_ignore() {
14647        let mut r = Router::new();
14648        r.add_prompt(NamedPrompt::new("p"));
14649        r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Ignore)
14650            .unwrap();
14651        assert_eq!(r.prompts_count(), 1);
14652    }
14653
14654    #[test]
14655    fn add_prompt_behavior_new_prompt_ok() {
14656        let mut r = Router::new();
14657        r.add_prompt_with_behavior(NamedPrompt::new("p"), crate::DuplicateBehavior::Error)
14658            .unwrap();
14659        assert_eq!(r.prompts_count(), 1);
14660    }
14661
14662    // ── add_resource / add_prompt duplicate replace ─────────────────────
14663
14664    #[test]
14665    fn add_resource_replaces_on_duplicate() {
14666        let mut r = Router::new();
14667        r.add_resource(NamedResource::new("file:///a"));
14668        r.add_resource(NamedResource::new("file:///a"));
14669        assert_eq!(r.resources_count(), 1);
14670        assert_eq!(r.resources().len(), 1);
14671    }
14672
14673    #[test]
14674    fn add_prompt_replaces_on_duplicate() {
14675        let mut r = Router::new();
14676        r.add_prompt(NamedPrompt::new("p"));
14677        r.add_prompt(NamedPrompt::new("p"));
14678        assert_eq!(r.prompts_count(), 1);
14679        assert_eq!(r.prompts().len(), 1);
14680    }
14681
14682    // ── resource_exists for template match ──────────────────────────────
14683
14684    #[test]
14685    fn resource_exists_for_template_match() {
14686        struct DbResource;
14687        impl ResourceHandler for DbResource {
14688            fn definition(&self) -> Resource {
14689                Resource {
14690                    uri: "db://placeholder".to_string(),
14691                    name: "db".to_string(),
14692                    description: None,
14693                    mime_type: Some("text/plain".to_string()),
14694                    icon: None,
14695                    version: None,
14696                    tags: vec![],
14697                }
14698            }
14699            fn template(&self) -> Option<ResourceTemplate> {
14700                Some(ResourceTemplate {
14701                    uri_template: "db://{table}".to_string(),
14702                    name: "db".to_string(),
14703                    description: None,
14704                    mime_type: None,
14705                    icon: None,
14706                    version: None,
14707                    tags: vec![],
14708                })
14709            }
14710            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<fastmcp_protocol::ResourceContent>> {
14711                Ok(vec![])
14712            }
14713        }
14714        let mut r = Router::new();
14715        r.add_resource(DbResource);
14716        assert!(r.resource_exists("db://users"));
14717        assert!(!r.resource_exists("file://other"));
14718    }
14719
14720    // ── resources_filtered by tags ──────────────────────────────────────
14721
14722    #[test]
14723    fn resources_filtered_by_tags() {
14724        let mut r = Router::new();
14725        r.add_resource(NamedResource::with_tags(
14726            "file:///a",
14727            vec!["internal".to_string()],
14728        ));
14729        r.add_resource(NamedResource::with_tags(
14730            "file:///b",
14731            vec!["public".to_string()],
14732        ));
14733        let include = vec!["public".to_string()];
14734        let filters = TagFilters::new(Some(&include), None);
14735        let res = r.resources_filtered(None, Some(&filters));
14736        assert_eq!(res.len(), 1);
14737        assert_eq!(res[0].uri, "file:///b");
14738    }
14739
14740    // ── resource_templates_filtered by tags ─────────────────────────────
14741
14742    #[test]
14743    fn resource_templates_filtered_by_tags() {
14744        let mut r = Router::new();
14745        r.add_resource_template(ResourceTemplate {
14746            uri_template: "db://{table}".to_string(),
14747            name: "db".to_string(),
14748            description: None,
14749            mime_type: None,
14750            icon: None,
14751            version: None,
14752            tags: vec!["admin".to_string()],
14753        });
14754        r.add_resource_template(ResourceTemplate {
14755            uri_template: "cache://{key}".to_string(),
14756            name: "cache".to_string(),
14757            description: None,
14758            mime_type: None,
14759            icon: None,
14760            version: None,
14761            tags: vec!["public".to_string()],
14762        });
14763        let exclude = vec!["admin".to_string()];
14764        let filters = TagFilters::new(None, Some(&exclude));
14765        let tmpls = r.resource_templates_filtered(None, Some(&filters));
14766        assert_eq!(tmpls.len(), 1);
14767        assert_eq!(tmpls[0].name, "cache");
14768    }
14769
14770    // ── handle_tools_list with session state ────────────────────────────
14771
14772    #[test]
14773    fn handle_tools_list_with_session_state_filter() {
14774        let mut r = Router::new();
14775        r.add_tool(NamedTool::new("a"))
14776            .expect("tool registration succeeds");
14777        r.add_tool(NamedTool::new("b"))
14778            .expect("tool registration succeeds");
14779        let cx = Cx::for_testing();
14780        let state = SessionState::new();
14781        let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
14782        state.set("fastmcp.disabled_tools", &disabled);
14783        let params = ListToolsParams {
14784            cursor: None,
14785            include_tags: None,
14786            exclude_tags: None,
14787        };
14788        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14789        let result = r
14790            .handle_tools_list(&request_ctx, params, Some(&state))
14791            .unwrap();
14792        assert_eq!(result.tools.len(), 1);
14793        assert_eq!(result.tools[0].name, "b");
14794    }
14795
14796    #[test]
14797    fn handle_tools_list_cursor_rejects_changed_session_visibility() {
14798        let mut router = Router::new();
14799        router.set_list_page_size(Some(1));
14800        router
14801            .add_tool(NamedTool::new("a"))
14802            .expect("tool registration succeeds");
14803        router
14804            .add_tool(NamedTool::new("b"))
14805            .expect("tool registration succeeds");
14806        let cx = Cx::for_testing();
14807        let state = SessionState::new();
14808        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
14809        let first = router
14810            .handle_tools_list(
14811                &request_ctx,
14812                ListToolsParams {
14813                    cursor: None,
14814                    include_tags: None,
14815                    exclude_tags: None,
14816                },
14817                Some(&state),
14818            )
14819            .expect("the first session-filtered page is admitted");
14820        assert_eq!(first.tools[0].name, "a");
14821        let cursor = first
14822            .next_cursor
14823            .expect("the first page has a continuation");
14824
14825        let continued = router
14826            .handle_tools_list(
14827                &request_ctx,
14828                ListToolsParams {
14829                    cursor: Some(cursor.clone()),
14830                    include_tags: None,
14831                    exclude_tags: None,
14832                },
14833                Some(&state),
14834            )
14835            .expect("unchanged session visibility continues the exact-2024 catalog");
14836        assert_eq!(continued.tools[0].name, "b");
14837        assert!(continued.next_cursor.is_none());
14838
14839        let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
14840        state.set("fastmcp.disabled_tools", &disabled);
14841        let stale = router
14842            .handle_tools_list(
14843                &request_ctx,
14844                ListToolsParams {
14845                    cursor: Some(cursor),
14846                    include_tags: None,
14847                    exclude_tags: None,
14848                },
14849                Some(&state),
14850            )
14851            .expect_err("changing only session visibility rejects the continuation");
14852        assert_eq!(stale.code, McpErrorCode::InvalidParams);
14853        assert!(stale.message.contains("session visibility"));
14854    }
14855
14856    // ── handle_resources_list with tag filter ────────────────────────────
14857
14858    #[test]
14859    fn handle_resources_list_with_tag_filter() {
14860        let mut r = Router::new();
14861        r.add_resource(NamedResource::with_tags(
14862            "file:///a",
14863            vec!["db".to_string()],
14864        ));
14865        r.add_resource(NamedResource::with_tags(
14866            "file:///b",
14867            vec!["web".to_string()],
14868        ));
14869        let cx = Cx::for_testing();
14870        let params = ListResourcesParams {
14871            cursor: None,
14872            include_tags: Some(vec!["web".to_string()]),
14873            exclude_tags: None,
14874        };
14875        let request_ctx = McpContext::new(cx, 1);
14876        let result = r.handle_resources_list(&request_ctx, params, None).unwrap();
14877        assert_eq!(result.resources.len(), 1);
14878        assert_eq!(result.resources[0].uri, "file:///b");
14879    }
14880
14881    // ── handle_prompts_list with pagination ──────────────────────────────
14882
14883    #[test]
14884    fn handle_prompts_list_with_pagination() {
14885        let mut r = Router::new();
14886        r.set_list_page_size(Some(1));
14887        r.add_prompt(NamedPrompt::new("a"));
14888        r.add_prompt(NamedPrompt::new("b"));
14889        let cx = Cx::for_testing();
14890        let params = ListPromptsParams {
14891            cursor: None,
14892            include_tags: None,
14893            exclude_tags: None,
14894        };
14895        let request_ctx = McpContext::new(cx, 1);
14896        let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
14897        assert_eq!(result.prompts.len(), 1);
14898        assert_eq!(result.prompts[0].name, "a");
14899        assert!(result.next_cursor.is_some());
14900
14901        let params = ListPromptsParams {
14902            cursor: result.next_cursor,
14903            include_tags: None,
14904            exclude_tags: None,
14905        };
14906        let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
14907        assert_eq!(result.prompts.len(), 1);
14908        assert_eq!(result.prompts[0].name, "b");
14909        assert!(result.next_cursor.is_none());
14910    }
14911
14912    // ── handle_prompts_list with tag filter ──────────────────────────────
14913
14914    #[test]
14915    fn handle_prompts_list_with_tag_filter() {
14916        let mut r = Router::new();
14917        r.add_prompt(NamedPrompt::with_tags("a", vec!["internal".to_string()]));
14918        r.add_prompt(NamedPrompt::with_tags("b", vec!["public".to_string()]));
14919        let cx = Cx::for_testing();
14920        let params = ListPromptsParams {
14921            cursor: None,
14922            include_tags: None,
14923            exclude_tags: Some(vec!["internal".to_string()]),
14924        };
14925        let request_ctx = McpContext::new(cx, 1);
14926        let result = r.handle_prompts_list(&request_ctx, params, None).unwrap();
14927        assert_eq!(result.prompts.len(), 1);
14928        assert_eq!(result.prompts[0].name, "b");
14929    }
14930
14931    // ── handle_resource_templates_list with pagination ───────────────────
14932
14933    #[test]
14934    fn handle_resource_templates_list_with_pagination() {
14935        let mut r = Router::new();
14936        r.set_list_page_size(Some(1));
14937        r.add_resource_template(ResourceTemplate {
14938            uri_template: "db://{table}".to_string(),
14939            name: "db".to_string(),
14940            description: None,
14941            mime_type: None,
14942            icon: None,
14943            version: None,
14944            tags: vec![],
14945        });
14946        r.add_resource_template(ResourceTemplate {
14947            uri_template: "cache://{key}".to_string(),
14948            name: "cache".to_string(),
14949            description: None,
14950            mime_type: None,
14951            icon: None,
14952            version: None,
14953            tags: vec![],
14954        });
14955        let cx = Cx::for_testing();
14956        let request_ctx = McpContext::new(cx, 1);
14957        let params = ListResourceTemplatesParams {
14958            cursor: None,
14959            include_tags: None,
14960            exclude_tags: None,
14961        };
14962        let result = r
14963            .handle_resource_templates_list(&request_ctx, params, None)
14964            .unwrap();
14965        assert_eq!(result.resource_templates.len(), 1);
14966        assert!(result.next_cursor.is_some());
14967
14968        let params = ListResourceTemplatesParams {
14969            cursor: result.next_cursor,
14970            include_tags: None,
14971            exclude_tags: None,
14972        };
14973        let result = r
14974            .handle_resource_templates_list(&request_ctx, params, None)
14975            .unwrap();
14976        assert_eq!(result.resource_templates.len(), 1);
14977        assert!(result.next_cursor.is_none());
14978    }
14979
14980    // ── handle_resource_templates_list with tag filter ───────────────────
14981
14982    #[test]
14983    fn handle_resource_templates_list_with_tag_filter() {
14984        let mut r = Router::new();
14985        r.add_resource_template(ResourceTemplate {
14986            uri_template: "db://{table}".to_string(),
14987            name: "db".to_string(),
14988            description: None,
14989            mime_type: None,
14990            icon: None,
14991            version: None,
14992            tags: vec!["admin".to_string()],
14993        });
14994        r.add_resource_template(ResourceTemplate {
14995            uri_template: "cache://{key}".to_string(),
14996            name: "cache".to_string(),
14997            description: None,
14998            mime_type: None,
14999            icon: None,
15000            version: None,
15001            tags: vec!["public".to_string()],
15002        });
15003        let cx = Cx::for_testing();
15004        let params = ListResourceTemplatesParams {
15005            cursor: None,
15006            include_tags: Some(vec!["public".to_string()]),
15007            exclude_tags: None,
15008        };
15009        let request_ctx = McpContext::new(cx, 1);
15010        let result = r
15011            .handle_resource_templates_list(&request_ctx, params, None)
15012            .unwrap();
15013        assert_eq!(result.resource_templates.len(), 1);
15014        assert_eq!(result.resource_templates[0].name, "cache");
15015    }
15016
15017    // ── mount_resources (selective method) ───────────────────────────────
15018
15019    #[test]
15020    fn mount_resources_only() {
15021        let mut main = Router::new();
15022        let mut sub = Router::new();
15023        sub.add_resource(NamedResource::new("file:///a"));
15024        sub.add_tool(NamedTool::new("t1"))
15025            .expect("tool registration succeeds");
15026        sub.add_resource_template(ResourceTemplate {
15027            uri_template: "db://{t}".to_string(),
15028            name: "db".to_string(),
15029            description: None,
15030            mime_type: None,
15031            icon: None,
15032            version: None,
15033            tags: vec![],
15034        });
15035        let result = main.mount_resources(sub, Some("ns"));
15036        assert_eq!(result.resources, 1);
15037        assert_eq!(result.resource_templates, 1);
15038        assert!(main.get_resource("ns/file:///a").is_some());
15039        assert_eq!(main.tools_count(), 0); // tools not mounted
15040    }
15041
15042    // ── MountResult has_components with all fields ──────────────────────
15043
15044    #[test]
15045    fn mount_result_with_resources_has_components() {
15046        let mut r = MountResult::default();
15047        r.resources = 1;
15048        assert!(r.has_components());
15049    }
15050
15051    #[test]
15052    fn mount_result_with_templates_has_components() {
15053        let mut r = MountResult::default();
15054        r.resource_templates = 1;
15055        assert!(r.has_components());
15056    }
15057
15058    #[test]
15059    fn mount_result_with_prompts_has_components() {
15060        let mut r = MountResult::default();
15061        r.prompts = 1;
15062        assert!(r.has_components());
15063    }
15064
15065    #[test]
15066    fn mount_result_is_success_with_warnings() {
15067        let mut r = MountResult::default();
15068        r.warnings.push("something".to_string());
15069        assert!(r.is_success());
15070    }
15071
15072    #[test]
15073    fn mount_result_reports_errors_as_failure() {
15074        let mut result = MountResult::default();
15075        result.errors.push("mount rejected".to_string());
15076        assert!(!result.is_success());
15077        assert!(!result.has_components());
15078    }
15079
15080    // ── mount with all component types ──────────────────────────────────
15081
15082    #[test]
15083    fn mount_all_component_types() {
15084        let mut main = Router::new();
15085        let mut sub = Router::new();
15086        sub.add_tool(NamedTool::new("t1"))
15087            .expect("tool registration succeeds");
15088        sub.add_resource(NamedResource::new("file:///r1"));
15089        sub.add_prompt(NamedPrompt::new("p1"));
15090        sub.add_resource_template(ResourceTemplate {
15091            uri_template: "db://{table}".to_string(),
15092            name: "db".to_string(),
15093            description: None,
15094            mime_type: None,
15095            icon: None,
15096            version: None,
15097            tags: vec![],
15098        });
15099        let result = main.mount(sub, Some("ns"));
15100        assert_eq!(result.tools, 1);
15101        assert_eq!(result.resources, 1);
15102        assert_eq!(result.prompts, 1);
15103        assert_eq!(result.resource_templates, 1);
15104        assert!(result.has_components());
15105        assert!(main.get_tool("ns/t1").is_some());
15106        assert!(main.get_resource("ns/file:///r1").is_some());
15107        assert!(main.get_prompt("ns/p1").is_some());
15108    }
15109
15110    // ── mount resource conflict warnings ────────────────────────────────
15111
15112    #[test]
15113    fn mount_warns_on_resource_conflict() {
15114        let mut main = Router::new();
15115        main.add_resource(NamedResource::new("file:///a"));
15116        let mut sub = Router::new();
15117        sub.add_resource(NamedResource::new("file:///a"));
15118        let result = main.mount(sub, None);
15119        assert!(!result.warnings.is_empty());
15120        assert!(result.warnings[0].contains("Resource"));
15121    }
15122
15123    #[test]
15124    fn mount_warns_on_prompt_conflict() {
15125        let mut main = Router::new();
15126        main.add_prompt(NamedPrompt::new("p"));
15127        let mut sub = Router::new();
15128        sub.add_prompt(NamedPrompt::new("p"));
15129        let result = main.mount(sub, None);
15130        assert!(!result.warnings.is_empty());
15131        assert!(result.warnings[0].contains("Prompt"));
15132    }
15133
15134    // ── TagFilters::clone ───────────────────────────────────────────────
15135
15136    #[test]
15137    fn tag_filters_clone() {
15138        let include = vec!["a".to_string()];
15139        let f = TagFilters::new(Some(&include), None);
15140        let cloned = f.clone();
15141        assert!(cloned.matches(&["a".to_string()]));
15142        assert!(!cloned.matches(&["b".to_string()]));
15143    }
15144
15145    // ── handle_tools_list with pagination AND tags ───────────────────────
15146
15147    #[test]
15148    fn handle_tools_list_pagination_with_tags() {
15149        let mut r = Router::new();
15150        r.set_list_page_size(Some(1));
15151        r.add_tool(NamedTool::with_tags("a", vec!["db".to_string()]))
15152            .expect("tool registration succeeds");
15153        r.add_tool(NamedTool::with_tags("b", vec!["db".to_string()]))
15154            .expect("tool registration succeeds");
15155        r.add_tool(NamedTool::with_tags("c", vec!["web".to_string()]))
15156            .expect("tool registration succeeds");
15157        let cx = Cx::for_testing();
15158        let request_ctx = McpContext::new(cx, 1);
15159
15160        // Only "db" tagged tools, page 1
15161        let params = ListToolsParams {
15162            cursor: None,
15163            include_tags: Some(vec!["db".to_string()]),
15164            exclude_tags: None,
15165        };
15166        let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
15167        assert_eq!(result.tools.len(), 1);
15168        assert_eq!(result.tools[0].name, "a");
15169        assert!(result.next_cursor.is_some());
15170
15171        // Page 2
15172        let params = ListToolsParams {
15173            cursor: result.next_cursor,
15174            include_tags: Some(vec!["db".to_string()]),
15175            exclude_tags: None,
15176        };
15177        let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
15178        assert_eq!(result.tools.len(), 1);
15179        assert_eq!(result.tools[0].name, "b");
15180        assert!(result.next_cursor.is_none());
15181    }
15182
15183    #[test]
15184    fn final_mounted_tools_preserve_admitted_order_before_cursor_pagination() {
15185        let mut router = mounted_tool_router();
15186        let cx = Cx::for_testing();
15187        let state = SessionState::new();
15188        let request_ctx = request_context(&cx, 164, Budget::INFINITE, &state);
15189
15190        let legacy = router
15191            .handle_tools_list(
15192                &request_ctx,
15193                ListToolsParams {
15194                    cursor: None,
15195                    include_tags: Some(vec!["visible".to_owned()]),
15196                    exclude_tags: Some(vec!["excluded".to_owned()]),
15197                },
15198                None,
15199            )
15200            .expect("legacy filtering retains every registered tool");
15201        assert_eq!(
15202            legacy
15203                .tools
15204                .iter()
15205                .map(|tool| tool.name.as_str())
15206                .collect::<Vec<_>>(),
15207            vec!["peer/first", "peer/second"],
15208            "the legacy catalog retains mounted tools in source order"
15209        );
15210
15211        router.set_list_page_size(Some(1));
15212        let first_page = router
15213            .dispatch_stateless(
15214                &request_ctx,
15215                &final_tools_list_request(
15216                    None,
15217                    Some(vec!["visible"]),
15218                    Some(vec!["excluded"]),
15219                    164_i64,
15220                ),
15221            )
15222            .expect("the first final page contains the first admitted entry");
15223        assert_eq!(first_page["tools"][0]["name"], "peer/first");
15224        assert_eq!(first_page["tools"].as_array().map(Vec::len), Some(1));
15225        let cursor = first_page["nextCursor"]
15226            .as_str()
15227            .expect("the first admitted page has a continuation cursor");
15228        let include_tags = vec!["visible".to_owned()];
15229        let exclude_tags = vec!["excluded".to_owned()];
15230        let query = FinalCatalogQuery::from_tag_filters(Some(&include_tags), Some(&exclude_tags));
15231        assert_eq!(
15232            decode_final_catalog_cursor_offset(
15233                Some(cursor),
15234                FinalCatalogKind::Tools,
15235                router.final_catalog_revision,
15236                &query,
15237                None,
15238                2,
15239            )
15240            .expect("cursor is router-generated for this exact final catalog revision"),
15241            1,
15242            "the cursor advances across admitted entries"
15243        );
15244
15245        let query_mismatch = router
15246            .dispatch_stateless(
15247                &request_ctx,
15248                &final_tools_list_request(
15249                    Some(cursor),
15250                    Some(vec!["other"]),
15251                    Some(vec!["excluded"]),
15252                    165_i64,
15253                ),
15254            )
15255            .expect_err("changing only the final list filter rejects the continuation");
15256        assert_eq!(query_mismatch.code, McpErrorCode::InvalidParams);
15257        assert!(query_mismatch.message.contains("query filters"));
15258
15259        let second_page = router
15260            .dispatch_stateless(
15261                &request_ctx,
15262                &final_tools_list_request(
15263                    Some(cursor),
15264                    Some(vec!["visible"]),
15265                    Some(vec!["excluded"]),
15266                    166_i64,
15267                ),
15268            )
15269            .expect("the continuation page keeps admitted source order");
15270        assert_eq!(second_page["tools"][0]["name"], "peer/second");
15271        assert_eq!(second_page["tools"].as_array().map(Vec::len), Some(1));
15272        assert!(
15273            second_page.get("nextCursor").is_none(),
15274            "the second admitted entry terminates the filtered final sequence"
15275        );
15276    }
15277
15278    #[test]
15279    fn final_default_list_validates_every_supplied_catalog_cursor() {
15280        let mut router = Router::new();
15281        router
15282            .add_tool(NamedTool::new("default-cursor-first"))
15283            .expect("first final tool registers");
15284        router
15285            .add_tool(NamedTool::new("default-cursor-second"))
15286            .expect("second final tool registers");
15287        assert!(
15288            router.list_page_size.is_none(),
15289            "the default final list path has pagination disabled"
15290        );
15291
15292        let cx = Cx::for_testing();
15293        let state = SessionState::new();
15294        let request_ctx = request_context(&cx, 167, Budget::INFINITE, &state);
15295        let full = router
15296            .dispatch_stateless(
15297                &request_ctx,
15298                &final_tools_list_request(None, None, None, 167_i64),
15299            )
15300            .expect("a cursor-free default final list returns the full catalog");
15301        assert_eq!(full["tools"].as_array().map(Vec::len), Some(2));
15302        assert!(full.get("nextCursor").is_none());
15303
15304        let query = FinalCatalogQuery::from_tag_filters(None, None);
15305        let valid_cursor = encode_final_catalog_cursor(
15306            FinalCatalogKind::Tools,
15307            router.final_catalog_revision,
15308            &query,
15309            0,
15310            None,
15311        );
15312        let validated_full = router
15313            .dispatch_stateless(
15314                &request_ctx,
15315                &final_tools_list_request(Some(&valid_cursor), None, None, 168_i64),
15316            )
15317            .expect(
15318                "a valid cursor remains admitted while default listing returns the full catalog",
15319            );
15320        assert_eq!(validated_full["tools"].as_array().map(Vec::len), Some(2));
15321        assert!(validated_full.get("nextCursor").is_none());
15322
15323        let stale_revision = router
15324            .final_catalog_revision
15325            .checked_sub(1)
15326            .expect("registered tools advance the final catalog revision");
15327        let stale_cursor =
15328            encode_final_catalog_cursor(FinalCatalogKind::Tools, stale_revision, &query, 0, None);
15329        let stale = router
15330            .dispatch_stateless(
15331                &request_ctx,
15332                &final_tools_list_request(Some(&stale_cursor), None, None, 169_i64),
15333            )
15334            .expect_err("changing only the revision rejects a default-list cursor");
15335        assert_eq!(stale.code, McpErrorCode::InvalidParams);
15336        assert!(stale.message.contains("stale catalog revision"));
15337
15338        let wrong_kind_cursor = encode_final_catalog_cursor(
15339            FinalCatalogKind::Prompts,
15340            router.final_catalog_revision,
15341            &query,
15342            0,
15343            None,
15344        );
15345        let wrong_kind = router
15346            .dispatch_stateless(
15347                &request_ctx,
15348                &final_tools_list_request(Some(&wrong_kind_cursor), None, None, 170_i64),
15349            )
15350            .expect_err("changing only the kind rejects a default-list cursor");
15351        assert_eq!(wrong_kind.code, McpErrorCode::InvalidParams);
15352        assert!(wrong_kind.message.contains("another list method"));
15353
15354        let other_tags = vec!["other".to_owned()];
15355        let other_query = FinalCatalogQuery::from_tag_filters(Some(&other_tags), None);
15356        let wrong_query_cursor = encode_final_catalog_cursor(
15357            FinalCatalogKind::Tools,
15358            router.final_catalog_revision,
15359            &other_query,
15360            0,
15361            None,
15362        );
15363        let wrong_query = router
15364            .dispatch_stateless(
15365                &request_ctx,
15366                &final_tools_list_request(Some(&wrong_query_cursor), None, None, 171_i64),
15367            )
15368            .expect_err("changing only the filters rejects a default-list cursor");
15369        assert_eq!(wrong_query.code, McpErrorCode::InvalidParams);
15370        assert!(wrong_query.message.contains("query filters"));
15371
15372        let out_of_range_cursor = encode_final_catalog_cursor(
15373            FinalCatalogKind::Tools,
15374            router.final_catalog_revision,
15375            &query,
15376            2,
15377            None,
15378        );
15379        let out_of_range = router
15380            .dispatch_stateless(
15381                &request_ctx,
15382                &final_tools_list_request(Some(&out_of_range_cursor), None, None, 172_i64),
15383            )
15384            .expect_err("changing only the offset rejects a default-list cursor");
15385        assert_eq!(out_of_range.code, McpErrorCode::InvalidParams);
15386        assert!(
15387            out_of_range
15388                .message
15389                .contains("outside the requested catalog page")
15390        );
15391    }
15392
15393    #[test]
15394    fn final_resource_and_prompt_cursors_reject_stale_catalog_revisions() {
15395        fn final_list_request(method: &str, cursor: Option<&str>, id: i64) -> JsonRpcRequest {
15396            let mut params = serde_json::json!({
15397                "_meta": {
15398                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
15399                    "io.modelcontextprotocol/clientCapabilities": {},
15400                },
15401            });
15402            if let Some(cursor) = cursor {
15403                params["cursor"] = serde_json::json!(cursor);
15404            }
15405            JsonRpcRequest::new(method, Some(params), id)
15406        }
15407
15408        let mut router = Router::new();
15409        router.set_list_page_size(Some(1));
15410        router.add_resource(NamedResource::new("file:///cursor-resource-a"));
15411        router.add_resource(NamedResource::new("file:///cursor-resource-b"));
15412        router.add_prompt(NamedPrompt::new("cursor-prompt-a"));
15413        router.add_prompt(NamedPrompt::new("cursor-prompt-b"));
15414
15415        let cx = Cx::for_testing();
15416        let state = SessionState::new();
15417        let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
15418
15419        let resource_first = router
15420            .dispatch_stateless(
15421                &request_ctx,
15422                &final_list_request("resources/list", None, 166_i64),
15423            )
15424            .expect("the first final resource page is emitted");
15425        let resource_cursor = resource_first["nextCursor"]
15426            .as_str()
15427            .expect("the first resource page has a continuation")
15428            .to_owned();
15429        let resource_second = router
15430            .dispatch_stateless(
15431                &request_ctx,
15432                &final_list_request("resources/list", Some(&resource_cursor), 167_i64),
15433            )
15434            .expect("an unchanged final resource catalog accepts its cursor");
15435        assert_eq!(
15436            resource_second["resources"][0]["uri"],
15437            "file:///cursor-resource-b"
15438        );
15439
15440        router.add_resource(NamedResource::new("file:///cursor-resource-c"));
15441        let resource_stale = router
15442            .dispatch_stateless(
15443                &request_ctx,
15444                &final_list_request("resources/list", Some(&resource_cursor), 168_i64),
15445            )
15446            .expect_err("adding only one catalog resource invalidates the old continuation");
15447        assert_eq!(resource_stale.code, McpErrorCode::InvalidParams);
15448        assert!(resource_stale.message.contains("stale catalog revision"));
15449
15450        let prompt_first = router
15451            .dispatch_stateless(
15452                &request_ctx,
15453                &final_list_request("prompts/list", None, 169_i64),
15454            )
15455            .expect("the first final prompt page is emitted");
15456        let prompt_cursor = prompt_first["nextCursor"]
15457            .as_str()
15458            .expect("the first prompt page has a continuation")
15459            .to_owned();
15460        let prompt_second = router
15461            .dispatch_stateless(
15462                &request_ctx,
15463                &final_list_request("prompts/list", Some(&prompt_cursor), 170_i64),
15464            )
15465            .expect("an unchanged final prompt catalog accepts its cursor");
15466        assert_eq!(prompt_second["prompts"][0]["name"], "cursor-prompt-b");
15467
15468        router.add_prompt(NamedPrompt::new("cursor-prompt-c"));
15469        let prompt_stale = router
15470            .dispatch_stateless(
15471                &request_ctx,
15472                &final_list_request("prompts/list", Some(&prompt_cursor), 171_i64),
15473            )
15474            .expect_err("adding only one catalog prompt invalidates the old continuation");
15475        assert_eq!(prompt_stale.code, McpErrorCode::InvalidParams);
15476        assert!(prompt_stale.message.contains("stale catalog revision"));
15477    }
15478
15479    #[test]
15480    fn final_mounted_tools_apply_tag_filters_in_admitted_order() {
15481        let router = mounted_tool_router();
15482        let cx = Cx::for_testing();
15483        let state = SessionState::new();
15484        let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
15485
15486        let visible = router
15487            .dispatch_stateless(
15488                &request_ctx,
15489                &final_tools_list_request(None, Some(vec!["visible"]), None, 166_i64),
15490            )
15491            .expect("the final include filter projects only admitted visible entries");
15492        assert_eq!(
15493            visible["tools"]
15494                .as_array()
15495                .expect("final tools are an array")
15496                .iter()
15497                .map(|tool| tool["name"].as_str().expect("tool name is a string"))
15498                .collect::<Vec<_>>(),
15499            vec!["peer/first", "peer/second", "peer/excluded"],
15500            "tag filtering preserves admitted insertion order"
15501        );
15502
15503        let other = router
15504            .dispatch_stateless(
15505                &request_ctx,
15506                &final_tools_list_request(None, Some(vec!["other"]), None, 167_i64),
15507            )
15508            .expect("the include filter is evaluated after final admission");
15509        assert_eq!(other["tools"][0]["name"], "peer/other");
15510        assert_eq!(other["tools"].as_array().map(Vec::len), Some(1));
15511    }
15512
15513    // ── handle_resources_list with session state filter ──────────────────
15514
15515    #[test]
15516    fn handle_resources_list_with_session_state_filter() {
15517        let mut r = Router::new();
15518        r.add_resource(NamedResource::new("file:///a"));
15519        r.add_resource(NamedResource::new("file:///b"));
15520        let cx = Cx::for_testing();
15521        let state = SessionState::new();
15522        let disabled: std::collections::HashSet<String> =
15523            ["file:///a".to_string()].into_iter().collect();
15524        state.set("fastmcp.disabled_resources", &disabled);
15525        let params = ListResourcesParams {
15526            cursor: None,
15527            include_tags: None,
15528            exclude_tags: None,
15529        };
15530        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
15531        let result = r
15532            .handle_resources_list(&request_ctx, params, Some(&state))
15533            .unwrap();
15534        assert_eq!(result.resources.len(), 1);
15535        assert_eq!(result.resources[0].uri, "file:///b");
15536    }
15537
15538    // ── handle_prompts_list with session state filter ────────────────────
15539
15540    #[test]
15541    fn handle_prompts_list_with_session_state_filter() {
15542        let mut r = Router::new();
15543        r.add_prompt(NamedPrompt::new("a"));
15544        r.add_prompt(NamedPrompt::new("b"));
15545        let cx = Cx::for_testing();
15546        let state = SessionState::new();
15547        let disabled: std::collections::HashSet<String> = ["a".to_string()].into_iter().collect();
15548        state.set("fastmcp.disabled_prompts", &disabled);
15549        let params = ListPromptsParams {
15550            cursor: None,
15551            include_tags: None,
15552            exclude_tags: None,
15553        };
15554        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
15555        let result = r
15556            .handle_prompts_list(&request_ctx, params, Some(&state))
15557            .unwrap();
15558        assert_eq!(result.prompts.len(), 1);
15559        assert_eq!(result.prompts[0].name, "b");
15560    }
15561
15562    // ── resource_templates_filtered by session + tags combined ───────────
15563
15564    #[test]
15565    fn resource_templates_filtered_session_and_tags_combined() {
15566        let mut r = Router::new();
15567        r.add_resource_template(ResourceTemplate {
15568            uri_template: "db://{table}".to_string(),
15569            name: "db".to_string(),
15570            description: None,
15571            mime_type: None,
15572            icon: None,
15573            version: None,
15574            tags: vec!["admin".to_string()],
15575        });
15576        r.add_resource_template(ResourceTemplate {
15577            uri_template: "cache://{key}".to_string(),
15578            name: "cache".to_string(),
15579            description: None,
15580            mime_type: None,
15581            icon: None,
15582            version: None,
15583            tags: vec!["admin".to_string()],
15584        });
15585        r.add_resource_template(ResourceTemplate {
15586            uri_template: "log://{entry}".to_string(),
15587            name: "log".to_string(),
15588            description: None,
15589            mime_type: None,
15590            icon: None,
15591            version: None,
15592            tags: vec!["public".to_string()],
15593        });
15594        // Disable db template via session state
15595        let state = SessionState::new();
15596        let disabled: std::collections::HashSet<String> =
15597            ["db://{table}".to_string()].into_iter().collect();
15598        state.set("fastmcp.disabled_resources", &disabled);
15599        // Also filter by admin tag
15600        let include = vec!["admin".to_string()];
15601        let filters = TagFilters::new(Some(&include), None);
15602        let tmpls = r.resource_templates_filtered(Some(&state), Some(&filters));
15603        // db is disabled, log doesn't have admin tag => only cache
15604        assert_eq!(tmpls.len(), 1);
15605        assert_eq!(tmpls[0].name, "cache");
15606    }
15607
15608    // ── mount_tools warns on template conflict ──────────────────────────
15609
15610    #[test]
15611    fn mount_resource_template_warns_on_conflict() {
15612        let mut main = Router::new();
15613        main.add_resource_template(ResourceTemplate {
15614            uri_template: "db://{table}".to_string(),
15615            name: "db".to_string(),
15616            description: None,
15617            mime_type: None,
15618            icon: None,
15619            version: None,
15620            tags: vec![],
15621        });
15622        let mut sub = Router::new();
15623        sub.add_resource_template(ResourceTemplate {
15624            uri_template: "db://{table}".to_string(),
15625            name: "db2".to_string(),
15626            description: None,
15627            mime_type: None,
15628            icon: None,
15629            version: None,
15630            tags: vec![],
15631        });
15632        let result = main.mount(sub, None);
15633        assert!(!result.warnings.is_empty());
15634        assert!(result.warnings[0].contains("Resource template"));
15635    }
15636
15637    // ── handle_tools_call: tool disabled via session ─────────────────────
15638
15639    #[test]
15640    fn handle_tools_call_disabled_tool_returns_error() {
15641        let mut r = Router::new();
15642        r.add_tool(NamedTool::new("my_tool"))
15643            .expect("tool registration succeeds");
15644        let cx = Cx::for_testing();
15645        let budget = Budget::INFINITE;
15646        let state = SessionState::new();
15647        let disabled: std::collections::HashSet<String> =
15648            ["my_tool".to_string()].into_iter().collect();
15649        state.set("fastmcp.disabled_tools", &disabled);
15650        let params = CallToolParams {
15651            name: "my_tool".to_string(),
15652            arguments: None,
15653            meta: None,
15654        };
15655        let request_ctx = request_context(&cx, 1, budget, &state);
15656        let err = r
15657            .handle_tools_call(&request_ctx, params, state, None, None)
15658            .unwrap_err();
15659        assert!(err.message.contains("disabled"));
15660    }
15661
15662    // ── handle_tools_call: success path ──────────────────────────────────
15663
15664    #[test]
15665    fn handle_tools_call_success() {
15666        let mut r = Router::new();
15667        r.add_tool(NamedTool::new("echo"))
15668            .expect("tool registration succeeds");
15669        let cx = Cx::for_testing();
15670        let budget = Budget::INFINITE;
15671        let params = CallToolParams {
15672            name: "echo".to_string(),
15673            arguments: None,
15674            meta: None,
15675        };
15676        let state = SessionState::new();
15677        let request_ctx = request_context(&cx, 1, budget, &state);
15678        let result = r
15679            .handle_tools_call(&request_ctx, params, state, None, None)
15680            .unwrap();
15681        assert!(!result.is_error);
15682        assert!(!result.content.is_empty());
15683    }
15684
15685    // ── handle_tools_call: not found ─────────────────────────────────────
15686
15687    #[test]
15688    fn handle_tools_call_not_found() {
15689        let r = Router::new();
15690        let cx = Cx::for_testing();
15691        let budget = Budget::INFINITE;
15692        let params = CallToolParams {
15693            name: "missing".to_string(),
15694            arguments: None,
15695            meta: None,
15696        };
15697        let state = SessionState::new();
15698        let request_ctx = request_context(&cx, 1, budget, &state);
15699        let err = r
15700            .handle_tools_call(&request_ctx, params, state, None, None)
15701            .unwrap_err();
15702        // The refusal deliberately does not echo the peer-controlled tool
15703        // name; only the sanitized method-not-found classification surfaces.
15704        assert_eq!(err.code, McpErrorCode::MethodNotFound);
15705        assert!(!err.message.contains("missing"));
15706    }
15707
15708    // ── handle_tools_call: zero poll balance without poll admission ──────
15709
15710    #[test]
15711    fn handle_tools_call_zero_poll_balance_allows_handler_without_checkpoint() {
15712        let mut r = Router::new();
15713        r.add_tool(NamedTool::new("t"))
15714            .expect("tool registration succeeds");
15715        let cx = Cx::for_testing();
15716        let budget = Budget::unlimited().with_poll_quota(0);
15717        let params = CallToolParams {
15718            name: "t".to_string(),
15719            arguments: None,
15720            meta: None,
15721        };
15722        let state = SessionState::new();
15723        let request_ctx = request_context(&cx, 1, budget, &state);
15724        let result = r
15725            .handle_tools_call(&request_ctx, params, state, None, None)
15726            .expect("a zero balance is not a retroactive failure");
15727        assert!(!result.is_error);
15728    }
15729
15730    #[test]
15731    fn handle_tools_call_defers_pending_cancellation_inside_context_mask() {
15732        let mut router = Router::new();
15733        router
15734            .add_tool(NamedTool::new("t"))
15735            .expect("tool registration succeeds");
15736        let cx = Cx::for_testing();
15737        cx.set_cancel_requested(true);
15738        let state = SessionState::new();
15739        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
15740        let params = || CallToolParams {
15741            name: "t".to_string(),
15742            arguments: None,
15743            meta: None,
15744        };
15745
15746        let masked_result = request_ctx
15747            .masked(|| router.handle_tools_call(&request_ctx, params(), state.clone(), None, None))
15748            .expect("mask should be admitted");
15749        assert!(masked_result.is_ok());
15750
15751        let unmasked_error = router
15752            .handle_tools_call(&request_ctx, params(), state, None, None)
15753            .expect_err("pending cancellation should surface after mask exit");
15754        assert_eq!(unmasked_error.code, McpErrorCode::RequestCancelled);
15755    }
15756
15757    // ── handle_resources_read: resource disabled via session ──────────────
15758
15759    #[test]
15760    fn handle_resources_read_disabled_resource_returns_error() {
15761        let mut r = Router::new();
15762        r.add_resource(NamedResource::new("file:///secret"));
15763        let cx = Cx::for_testing();
15764        let budget = Budget::INFINITE;
15765        let state = SessionState::new();
15766        let disabled: std::collections::HashSet<String> =
15767            ["file:///secret".to_string()].into_iter().collect();
15768        state.set("fastmcp.disabled_resources", &disabled);
15769        let params = ReadResourceParams {
15770            uri: "file:///secret".to_string(),
15771            meta: None,
15772        };
15773        let request_ctx = request_context(&cx, 1, budget, &state);
15774        let err = r
15775            .handle_resources_read(&request_ctx, &params, state, None, None)
15776            .unwrap_err();
15777        assert!(err.message.contains("disabled"));
15778    }
15779
15780    // ── handle_resources_read: success path ──────────────────────────────
15781
15782    #[test]
15783    fn handle_resources_read_success() {
15784        let mut r = Router::new();
15785        r.add_resource(NamedResource::new("file:///a"));
15786        let cx = Cx::for_testing();
15787        let budget = Budget::INFINITE;
15788        let params = ReadResourceParams {
15789            uri: "file:///a".to_string(),
15790            meta: None,
15791        };
15792        let state = SessionState::new();
15793        let request_ctx = request_context(&cx, 1, budget, &state);
15794        let result = r
15795            .handle_resources_read(&request_ctx, &params, state, None, None)
15796            .unwrap();
15797        assert_eq!(result.contents.len(), 1);
15798        let [
15799            LegacyResourceContent::Text {
15800                uri,
15801                text,
15802                additional,
15803                ..
15804            },
15805        ] = result.contents.as_slice()
15806        else {
15807            panic!("resource read must retain the exact legacy text shape");
15808        };
15809        assert_eq!(uri, "file:///a");
15810        assert_eq!(text, "content");
15811        assert!(additional.is_empty());
15812    }
15813
15814    // ── handle_resources_read: not found ─────────────────────────────────
15815
15816    #[test]
15817    fn handle_resources_read_not_found() {
15818        let r = Router::new();
15819        let cx = Cx::for_testing();
15820        let budget = Budget::INFINITE;
15821        let params = ReadResourceParams {
15822            uri: "file:///nonexistent".to_string(),
15823            meta: None,
15824        };
15825        let state = SessionState::new();
15826        let request_ctx = request_context(&cx, 1, budget, &state);
15827        let err = r
15828            .handle_resources_read(&request_ctx, &params, state, None, None)
15829            .unwrap_err();
15830        assert!(err.message.contains("nonexistent") || err.message.contains("not found"));
15831    }
15832
15833    // ── handle_resources_read: zero poll balance without admission ───────
15834
15835    #[test]
15836    fn handle_resources_read_zero_poll_balance_allows_handler_without_checkpoint() {
15837        let mut r = Router::new();
15838        r.add_resource(NamedResource::new("file:///a"));
15839        let cx = Cx::for_testing();
15840        let budget = Budget::unlimited().with_poll_quota(0);
15841        let params = ReadResourceParams {
15842            uri: "file:///a".to_string(),
15843            meta: None,
15844        };
15845        let state = SessionState::new();
15846        let request_ctx = request_context(&cx, 1, budget, &state);
15847        let result = r
15848            .handle_resources_read(&request_ctx, &params, state, None, None)
15849            .expect("a zero balance is not a retroactive failure");
15850        assert_eq!(result.contents.len(), 1);
15851    }
15852
15853    // ── handle_prompts_get: prompt disabled via session ───────────────────
15854
15855    #[test]
15856    fn handle_prompts_get_disabled_prompt_returns_error() {
15857        let mut r = Router::new();
15858        r.add_prompt(NamedPrompt::new("secret_prompt"));
15859        let cx = Cx::for_testing();
15860        let budget = Budget::INFINITE;
15861        let state = SessionState::new();
15862        let disabled: std::collections::HashSet<String> =
15863            ["secret_prompt".to_string()].into_iter().collect();
15864        state.set("fastmcp.disabled_prompts", &disabled);
15865        let params = GetPromptParams {
15866            name: "secret_prompt".to_string(),
15867            arguments: None,
15868            meta: None,
15869        };
15870        let request_ctx = request_context(&cx, 1, budget, &state);
15871        let err = r
15872            .handle_prompts_get(&request_ctx, params, state, None, None)
15873            .unwrap_err();
15874        assert!(err.message.contains("disabled"));
15875    }
15876
15877    // ── handle_prompts_get: success path ─────────────────────────────────
15878
15879    #[test]
15880    fn nested_get_prompt_returns_handler_text_and_refuses_missing() {
15881        let mut router = Router::new();
15882        router.add_prompt(NamedPrompt::new("greet"));
15883        let router = Arc::new(router);
15884        let cx = Cx::for_testing();
15885        let state = SessionState::new();
15886        let request_ctx = McpContext::with_state(cx, 1, state.clone()).with_prompt_caller(
15887            Arc::new(RouterPromptCaller::new(Arc::clone(&router), state)),
15888        );
15889
15890        let missing = block_on(request_ctx.get_prompt("missing", HashMap::new()))
15891            .expect_err("an unknown nested prompt must stay refused");
15892        assert_eq!(missing.code, McpErrorCode::PromptNotFound);
15893
15894        let result = block_on(request_ctx.get_prompt("greet", HashMap::new()))
15895            .expect("a registered nested prompt must complete");
15896        assert_eq!(result.description.as_deref(), Some("Prompt greet"));
15897        assert!(result.messages.is_empty());
15898    }
15899
15900    #[test]
15901    fn nested_get_prompt_from_legacy_surface_skips_required_argument_admission() {
15902        let final_calls = Arc::new(AtomicUsize::new(0));
15903        let legacy_calls = Arc::new(AtomicUsize::new(0));
15904        let mut router = Router::new();
15905        router.add_prompt(PromptArgumentBoundary {
15906            final_calls: Arc::clone(&final_calls),
15907            legacy_calls: Arc::clone(&legacy_calls),
15908        });
15909        let router = Arc::new(router);
15910        let cx = Cx::for_testing();
15911        let state = SessionState::new();
15912        let request_ctx = McpContext::with_state(cx, 1, state.clone()).with_prompt_caller(
15913            Arc::new(RouterPromptCaller::new(Arc::clone(&router), state)),
15914        );
15915
15916        let result = block_on(request_ctx.get_prompt("prompt-argument-boundary", HashMap::new()))
15917            .expect("legacy nested get_prompt retains unvalidated argument behavior");
15918        assert_eq!(
15919            result.messages[0].text.as_deref(),
15920            Some("legacy prompt-argument-boundary result")
15921        );
15922        assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
15923        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
15924    }
15925
15926    #[test]
15927    fn nested_get_prompt_from_final_surface_admits_arguments_before_handler() {
15928        let final_calls = Arc::new(AtomicUsize::new(0));
15929        let legacy_calls = Arc::new(AtomicUsize::new(0));
15930        let mut router = Router::new();
15931        router.add_prompt(PromptArgumentBoundary {
15932            final_calls: Arc::clone(&final_calls),
15933            legacy_calls: Arc::clone(&legacy_calls),
15934        });
15935        let router = Arc::new(router);
15936        let cx = Cx::for_testing();
15937        let state = SessionState::new();
15938        let request_ctx = McpContext::with_state(cx, 1, state.clone())
15939            .with_final_request_surface(true)
15940            .with_prompt_caller(Arc::new(RouterPromptCaller::new(
15941                Arc::clone(&router),
15942                state,
15943            )));
15944
15945        let missing = block_on(request_ctx.get_prompt("prompt-argument-boundary", HashMap::new()))
15946            .expect_err("removing only the required nested argument is rejected");
15947        assert_eq!(missing.code, McpErrorCode::InvalidParams);
15948        assert_eq!(missing.message, "Missing required prompt argument");
15949        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
15950        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
15951
15952        let mut unknown = HashMap::new();
15953        unknown.insert("topic".to_owned(), "release".to_owned());
15954        unknown.insert("extra".to_owned(), "nope".to_owned());
15955        let unknown_error = block_on(request_ctx.get_prompt("prompt-argument-boundary", unknown))
15956            .expect_err("an undeclared nested argument is rejected before the handler");
15957        assert_eq!(unknown_error.code, McpErrorCode::InvalidParams);
15958        assert_eq!(unknown_error.message, "Unknown prompt argument");
15959        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
15960        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
15961
15962        let mut required = HashMap::new();
15963        required.insert("topic".to_owned(), "release".to_owned());
15964        let accepted = block_on(request_ctx.get_prompt("prompt-argument-boundary", required))
15965            .expect("the complete required nested argument is accepted");
15966        assert_eq!(
15967            accepted.messages[0].text.as_deref(),
15968            Some("final prompt-argument-boundary result")
15969        );
15970        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
15971        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
15972    }
15973
15974    fn handle_prompts_get_success() {
15975        let mut r = Router::new();
15976        r.add_prompt(NamedPrompt::new("greet"));
15977        let cx = Cx::for_testing();
15978        let budget = Budget::INFINITE;
15979        let params = GetPromptParams {
15980            name: "greet".to_string(),
15981            arguments: None,
15982            meta: None,
15983        };
15984        let state = SessionState::new();
15985        let request_ctx = request_context(&cx, 1, budget, &state);
15986        let result = r
15987            .handle_prompts_get(&request_ctx, params, state, None, None)
15988            .unwrap();
15989        assert!(result.description.is_some());
15990    }
15991
15992    // ── handle_prompts_get: not found ────────────────────────────────────
15993
15994    #[test]
15995    fn handle_prompts_get_not_found() {
15996        let r = Router::new();
15997        let cx = Cx::for_testing();
15998        let budget = Budget::INFINITE;
15999        let params = GetPromptParams {
16000            name: "missing".to_string(),
16001            arguments: None,
16002            meta: None,
16003        };
16004        let state = SessionState::new();
16005        let request_ctx = request_context(&cx, 1, budget, &state);
16006        let err = r
16007            .handle_prompts_get(&request_ctx, params, state, None, None)
16008            .unwrap_err();
16009        assert!(err.message.contains("missing") || err.message.contains("not found"));
16010    }
16011
16012    // ── handle_prompts_get: zero poll balance without admission ──────────
16013
16014    #[test]
16015    fn handle_prompts_get_zero_poll_balance_allows_handler_without_checkpoint() {
16016        let mut r = Router::new();
16017        r.add_prompt(NamedPrompt::new("p"));
16018        let cx = Cx::for_testing();
16019        let budget = Budget::unlimited().with_poll_quota(0);
16020        let params = GetPromptParams {
16021            name: "p".to_string(),
16022            arguments: None,
16023            meta: None,
16024        };
16025        let state = SessionState::new();
16026        let request_ctx = request_context(&cx, 1, budget, &state);
16027        let result = r
16028            .handle_prompts_get(&request_ctx, params, state, None, None)
16029            .expect("a zero balance is not a retroactive failure");
16030        assert!(result.messages.is_empty());
16031    }
16032
16033    #[test]
16034    fn handler_budget_composition_uses_exact_earliest_deadline() {
16035        let now = Time::from_secs(100);
16036        let at = |seconds| Budget::new().with_deadline(Time::from_secs(seconds));
16037        let cases = [
16038            (Budget::INFINITE, Budget::INFINITE, None, None),
16039            (at(110), Budget::INFINITE, None, Some(Time::from_secs(110))),
16040            (Budget::INFINITE, at(115), None, Some(Time::from_secs(115))),
16041            (
16042                at(110),
16043                at(115),
16044                Some(Duration::from_secs(30)),
16045                Some(Time::from_secs(110)),
16046            ),
16047            (
16048                at(140),
16049                at(130),
16050                Some(Duration::from_secs(5)),
16051                Some(Time::from_secs(105)),
16052            ),
16053            (
16054                at(110),
16055                at(115),
16056                Some(Duration::ZERO),
16057                Some(Time::from_secs(110)),
16058            ),
16059            (
16060                at(90),
16061                at(115),
16062                Some(Duration::from_secs(5)),
16063                Some(Time::from_secs(90)),
16064            ),
16065            (
16066                Budget::INFINITE,
16067                Budget::new().with_deadline(Time::from_nanos(u64::MAX - 1)),
16068                Some(Duration::MAX),
16069                Some(Time::from_nanos(u64::MAX - 1)),
16070            ),
16071        ];
16072
16073        for (ambient, request, handler, expected) in cases {
16074            assert_eq!(
16075                compose_handler_budget(ambient, request, handler, now).deadline,
16076                expected
16077            );
16078        }
16079    }
16080
16081    #[test]
16082    fn alternating_tool_resource_recursion_uses_one_effective_depth() {
16083        let calls = Arc::new(AtomicU64::new(0));
16084        let mut router = Router::new();
16085        router
16086            .add_tool(AlternatingTool {
16087                calls: Arc::clone(&calls),
16088            })
16089            .expect("tool registration succeeds");
16090        router.add_resource(AlternatingResource {
16091            calls: Arc::clone(&calls),
16092        });
16093        let router = Arc::new(router);
16094        let cx = Cx::for_testing();
16095        let state = SessionState::new();
16096        let request_ctx = McpContext::with_state(cx, 1, state.clone())
16097            .with_tool_caller(Arc::new(RouterToolCaller::new(
16098                Arc::clone(&router),
16099                state.clone(),
16100            )))
16101            .with_resource_reader(Arc::new(RouterResourceReader::new(
16102                Arc::clone(&router),
16103                state.clone(),
16104            )));
16105
16106        let error = router
16107            .handle_tools_call(
16108                &request_ctx,
16109                CallToolParams {
16110                    name: "alternating_tool".to_string(),
16111                    arguments: None,
16112                    meta: None,
16113                },
16114                state,
16115                None,
16116                None,
16117            )
16118            .expect_err("alternating recursion must stop at the shared depth limit");
16119
16120        assert_eq!(error.code, McpErrorCode::InternalError);
16121        // Internal errors crossing the handler boundary are sanitized, so the
16122        // depth diagnosis never reaches the peer; the call count proves the
16123        // shared limit stopped the alternating recursion.
16124        assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16125        assert_eq!(calls.load(Ordering::Relaxed), 11);
16126    }
16127
16128    #[test]
16129    fn nested_tool_resource_call_shares_parent_cost_ledger() {
16130        let remaining_after_parent_debit = Arc::new(AtomicU64::new(u64::MAX));
16131        let remaining_after_nested_debit = Arc::new(AtomicU64::new(u64::MAX));
16132        let remaining_after_nested_read = Arc::new(AtomicU64::new(u64::MAX));
16133        let mut router = Router::new();
16134        router
16135            .add_tool(CostLedgerTool {
16136                remaining_after_parent_debit: Arc::clone(&remaining_after_parent_debit),
16137                remaining_after_nested_read: Arc::clone(&remaining_after_nested_read),
16138            })
16139            .expect("tool registration succeeds");
16140        router.add_resource(CostLedgerResource {
16141            remaining_after_nested_debit: Arc::clone(&remaining_after_nested_debit),
16142        });
16143
16144        let router = Arc::new(router);
16145        let state = SessionState::new();
16146        let cx = Cx::for_testing_with_budget(Budget::new().with_cost_quota(3));
16147        let request_ctx = McpContext::with_state(cx, 77, state.clone())
16148            .with_tool_caller(Arc::new(RouterToolCaller::new(
16149                Arc::clone(&router),
16150                state.clone(),
16151            )))
16152            .with_resource_reader(Arc::new(RouterResourceReader::new(router, state)));
16153
16154        let result = block_on(request_ctx.call_tool("cost_ledger_tool", serde_json::json!({})))
16155            .expect("parent and nested debits fit the shared cost quota");
16156
16157        assert!(!result.is_error);
16158        assert_eq!(remaining_after_parent_debit.load(Ordering::Relaxed), 2);
16159        assert_eq!(remaining_after_nested_debit.load(Ordering::Relaxed), 1);
16160        assert_eq!(remaining_after_nested_read.load(Ordering::Relaxed), 1);
16161        assert_eq!(request_ctx.budget().cost_quota, Some(1));
16162    }
16163
16164    #[test]
16165    fn nested_tool_calls_preserve_framework_terminal_errors() {
16166        let mut router = Router::new();
16167        router
16168            .add_tool(ErrorTool {
16169                name: "nested_cancelled",
16170                code: McpErrorCode::RequestCancelled,
16171            })
16172            .expect("tool registration succeeds");
16173        router
16174            .add_tool(ErrorTool {
16175                name: "nested_internal",
16176                code: McpErrorCode::InternalError,
16177            })
16178            .expect("tool registration succeeds");
16179        router
16180            .add_tool(ErrorTool {
16181                name: "nested_tool_failure",
16182                code: McpErrorCode::ToolExecutionError,
16183            })
16184            .expect("tool registration succeeds");
16185        let router = Arc::new(router);
16186        let cx = Cx::for_testing();
16187        let state = SessionState::new();
16188        let request_ctx = McpContext::with_state(cx, 88, state.clone())
16189            .with_tool_caller(Arc::new(RouterToolCaller::new(Arc::clone(&router), state)));
16190
16191        for (name, expected) in [
16192            ("nested_cancelled", McpErrorCode::RequestCancelled),
16193            ("nested_internal", McpErrorCode::InternalError),
16194        ] {
16195            let error = block_on(request_ctx.call_tool(name, serde_json::json!({})))
16196                .expect_err("framework terminal errors must remain outer failures");
16197            assert_eq!(error.code, expected);
16198        }
16199
16200        let tool_failure =
16201            block_on(request_ctx.call_tool("nested_tool_failure", serde_json::json!({})))
16202                .expect("ordinary tool failures remain protocol-level tool results");
16203        assert!(tool_failure.is_error);
16204    }
16205
16206    #[test]
16207    fn nested_call_tool_from_legacy_surface_uses_legacy_hook() {
16208        let final_calls = Arc::new(AtomicUsize::new(0));
16209        let legacy_calls = Arc::new(AtomicUsize::new(0));
16210        let mut router = Router::new();
16211        router
16212            .add_tool(DuplicateInvariantTool {
16213                label: "nested-legacy",
16214                schema_property: "allowed",
16215                legacy_calls: Arc::clone(&legacy_calls),
16216                final_calls: Arc::clone(&final_calls),
16217            })
16218            .expect("tool registration succeeds");
16219        let router = Arc::new(router);
16220        let cx = Cx::for_testing();
16221        let state = SessionState::new();
16222        let request_ctx = McpContext::with_state(cx, 1, state.clone())
16223            .with_tool_caller(Arc::new(RouterToolCaller::new(Arc::clone(&router), state)));
16224
16225        let rejected = block_on(request_ctx.call_tool(
16226            "duplicate-invariant-tool",
16227            serde_json::json!({"allowed": true, "extra": true}),
16228        ))
16229        .expect_err("legacy nested schema rejection remains an outer invalid-params error");
16230        assert_eq!(rejected.code, McpErrorCode::InvalidParams);
16231        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
16232        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
16233
16234        let result = block_on(request_ctx.call_tool(
16235            "duplicate-invariant-tool",
16236            serde_json::json!({"allowed": true}),
16237        ))
16238        .expect("legacy nested call_tool reaches the legacy hook");
16239        assert!(!result.is_error);
16240        assert_eq!(result.first_text(), Some("nested-legacy"));
16241        assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
16242        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
16243    }
16244
16245    #[test]
16246    fn nested_call_tool_from_final_surface_validates_before_handler() {
16247        let final_calls = Arc::new(AtomicUsize::new(0));
16248        let legacy_calls = Arc::new(AtomicUsize::new(0));
16249        let mut router = Router::new();
16250        router
16251            .add_tool(DuplicateInvariantTool {
16252                label: "nested-final",
16253                schema_property: "allowed",
16254                legacy_calls: Arc::clone(&legacy_calls),
16255                final_calls: Arc::clone(&final_calls),
16256            })
16257            .expect("tool registration succeeds");
16258        let router = Arc::new(router);
16259        let cx = Cx::for_testing();
16260        let state = SessionState::new();
16261        let request_ctx = McpContext::with_state(cx, 1, state.clone())
16262            .with_final_request_surface(true)
16263            .with_tool_caller(Arc::new(RouterToolCaller::new(Arc::clone(&router), state)));
16264
16265        let rejected = block_on(request_ctx.call_tool(
16266            "duplicate-invariant-tool",
16267            serde_json::json!({"allowed": true, "extra": true}),
16268        ))
16269        .expect("schema rejection remains a tool-level error result");
16270        assert!(rejected.is_error);
16271        assert_eq!(
16272            rejected.first_text(),
16273            Some("Tool arguments do not match the declared input schema.")
16274        );
16275        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
16276        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
16277
16278        let accepted = block_on(request_ctx.call_tool(
16279            "duplicate-invariant-tool",
16280            serde_json::json!({"allowed": true}),
16281        ))
16282        .expect("the complete nested final arguments are accepted");
16283        assert!(!accepted.is_error);
16284        assert_eq!(accepted.first_text(), Some("nested-final"));
16285        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16286        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
16287    }
16288
16289    #[test]
16290    fn nested_read_resource_from_final_surface_uses_final_hook() {
16291        let final_calls = Arc::new(AtomicUsize::new(0));
16292        let legacy_calls = Arc::new(AtomicUsize::new(0));
16293        let mut router = Router::new();
16294        router.add_resource(EraBoundaryResource {
16295            legacy_calls: Arc::clone(&legacy_calls),
16296            final_calls: Arc::clone(&final_calls),
16297        });
16298        let router = Arc::new(router);
16299        let cx = Cx::for_testing();
16300        let state = SessionState::new();
16301        let request_ctx = McpContext::with_state(cx, 1, state.clone())
16302            .with_final_request_surface(true)
16303            .with_resource_reader(Arc::new(RouterResourceReader::new(
16304                Arc::clone(&router),
16305                state,
16306            )));
16307
16308        let accepted = block_on(request_ctx.read_resource("mcp://era-boundary"))
16309            .expect("nested final resource read reaches the final hook");
16310        assert_eq!(
16311            accepted.contents[0].text.as_deref(),
16312            Some("final resource result")
16313        );
16314        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16315        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
16316
16317        let missing = block_on(request_ctx.read_resource("mcp://missing"))
16318            .expect_err("an unknown nested final resource is invalid params");
16319        assert_eq!(missing.code, McpErrorCode::InvalidParams);
16320        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
16321    }
16322
16323    #[test]
16324    fn nested_read_resource_from_legacy_surface_uses_legacy_hook() {
16325        let final_calls = Arc::new(AtomicUsize::new(0));
16326        let legacy_calls = Arc::new(AtomicUsize::new(0));
16327        let mut router = Router::new();
16328        router.add_resource(EraBoundaryResource {
16329            legacy_calls: Arc::clone(&legacy_calls),
16330            final_calls: Arc::clone(&final_calls),
16331        });
16332        let router = Arc::new(router);
16333        let cx = Cx::for_testing();
16334        let state = SessionState::new();
16335        let request_ctx = McpContext::with_state(cx, 1, state.clone()).with_resource_reader(
16336            Arc::new(RouterResourceReader::new(Arc::clone(&router), state)),
16337        );
16338
16339        let accepted = block_on(request_ctx.read_resource("mcp://era-boundary"))
16340            .expect("legacy nested resource read reaches the legacy hook");
16341        assert_eq!(
16342            accepted.contents[0].text.as_deref(),
16343            Some("legacy resource result")
16344        );
16345        assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
16346        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
16347    }
16348
16349    #[test]
16350    fn manual_handler_timeout_is_read_exposed_and_enforced() {
16351        let observed_deadline = Arc::new(Mutex::new(None));
16352        let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
16353        let mut router = Router::new();
16354        // The timeout must outlive dispatch admission (a too-tight deadline
16355        // rejects before the handler starts, proving nothing about read
16356        // exposure) while still expiring before the handler's delay finishes.
16357        router
16358            .add_tool(BudgetProbeTool {
16359                timeout: Some(Duration::from_millis(10)),
16360                delay: Duration::from_millis(100),
16361                observed_deadline: Arc::clone(&observed_deadline),
16362                timeout_read: Arc::clone(&timeout_read),
16363            })
16364            .expect("tool registration succeeds");
16365        let cx = Cx::for_testing();
16366        let state = SessionState::new();
16367        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
16368        let error = router
16369            .handle_tools_call(
16370                &request_ctx,
16371                CallToolParams {
16372                    name: "budget_probe".to_string(),
16373                    arguments: None,
16374                    meta: None,
16375                },
16376                state,
16377                None,
16378                None,
16379            )
16380            .expect_err("the handler deadline must reject a late completion");
16381
16382        assert!(timeout_read.load(Ordering::Relaxed));
16383        assert!(
16384            observed_deadline
16385                .lock()
16386                .unwrap_or_else(std::sync::PoisonError::into_inner)
16387                .is_some()
16388        );
16389        assert_eq!(error.code, McpErrorCode::RequestCancelled);
16390        assert_eq!(error.message, "Request timeout exceeded");
16391        assert!(!cx.is_cancel_requested());
16392    }
16393
16394    #[test]
16395    fn admitted_definition_is_not_requeried_inside_handler_budget() {
16396        let definition_reads = Arc::new(AtomicU64::new(0));
16397        let called = Arc::new(std::sync::atomic::AtomicBool::new(false));
16398        let mut router = Router::new();
16399        router
16400            .add_tool(SlowDefinitionTool {
16401                definition_reads: Arc::clone(&definition_reads),
16402                called: Arc::clone(&called),
16403            })
16404            .expect("the definition snapshot is admitted once");
16405        let cx = Cx::for_testing();
16406        let state = SessionState::new();
16407        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
16408
16409        router
16410            .handle_tools_call(
16411                &request_ctx,
16412                CallToolParams {
16413                    name: "slow_definition".to_string(),
16414                    arguments: None,
16415                    meta: None,
16416                },
16417                state,
16418                None,
16419                None,
16420            )
16421            .expect("dispatch uses the admitted snapshot and reaches the handler");
16422
16423        assert!(called.load(Ordering::Relaxed));
16424        assert_eq!(definition_reads.load(Ordering::Relaxed), 1);
16425    }
16426
16427    #[test]
16428    fn zero_handler_timeout_cannot_relax_request_deadline() {
16429        let observed_deadline = Arc::new(Mutex::new(None));
16430        let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
16431        let request_deadline = wall_now().saturating_add_nanos(5_000_000_000);
16432        let mut router = Router::new();
16433        router
16434            .add_tool(BudgetProbeTool {
16435                timeout: Some(Duration::ZERO),
16436                delay: Duration::ZERO,
16437                observed_deadline: Arc::clone(&observed_deadline),
16438                timeout_read: Arc::clone(&timeout_read),
16439            })
16440            .expect("tool registration succeeds");
16441        let cx = Cx::for_testing();
16442        let state = SessionState::new();
16443        let request_ctx = request_context(
16444            &cx,
16445            1,
16446            Budget::new().with_deadline(request_deadline),
16447            &state,
16448        );
16449        router
16450            .handle_tools_call(
16451                &request_ctx,
16452                CallToolParams {
16453                    name: "budget_probe".to_string(),
16454                    arguments: None,
16455                    meta: None,
16456                },
16457                state,
16458                None,
16459                None,
16460            )
16461            .expect("zero adds no ceiling but preserves the request deadline");
16462
16463        assert!(timeout_read.load(Ordering::Relaxed));
16464        assert_eq!(
16465            *observed_deadline
16466                .lock()
16467                .unwrap_or_else(std::sync::PoisonError::into_inner),
16468            Some(request_deadline)
16469        );
16470    }
16471
16472    #[test]
16473    fn ambient_deadline_remains_visible_when_server_and_handler_are_looser() {
16474        let observed_deadline = Arc::new(Mutex::new(None));
16475        let timeout_read = Arc::new(std::sync::atomic::AtomicBool::new(false));
16476        let ambient_deadline = wall_now().saturating_add_nanos(2_000_000_000);
16477        let request_deadline = ambient_deadline.saturating_add_nanos(2_000_000_000);
16478        let mut router = Router::new();
16479        router
16480            .add_tool(BudgetProbeTool {
16481                timeout: Some(Duration::from_secs(10)),
16482                delay: Duration::ZERO,
16483                observed_deadline: Arc::clone(&observed_deadline),
16484                timeout_read,
16485            })
16486            .expect("tool registration succeeds");
16487        let cx = Cx::for_testing_with_budget(Budget::new().with_deadline(ambient_deadline));
16488        let state = SessionState::new();
16489        let request_ctx = request_context(
16490            &cx,
16491            1,
16492            Budget::new().with_deadline(request_deadline),
16493            &state,
16494        );
16495        router
16496            .handle_tools_call(
16497                &request_ctx,
16498                CallToolParams {
16499                    name: "budget_probe".to_string(),
16500                    arguments: None,
16501                    meta: None,
16502                },
16503                state,
16504                None,
16505                None,
16506            )
16507            .expect("looser inner limits must preserve the ambient deadline");
16508
16509        assert_eq!(
16510            *observed_deadline
16511                .lock()
16512                .unwrap_or_else(std::sync::PoisonError::into_inner),
16513            Some(ambient_deadline)
16514        );
16515    }
16516
16517    fn assert_sanitized_panic_tool(handler: impl ToolHandler + 'static, name: &str) {
16518        let mut router = Router::new();
16519        router
16520            .add_tool(handler)
16521            .expect("panic-test tool registration succeeds");
16522        let cx = Cx::for_testing();
16523        let state = SessionState::new();
16524        let request_ctx = request_context(&cx, 1, Budget::INFINITE, &state);
16525        let error = router
16526            .handle_tools_call(
16527                &request_ctx,
16528                CallToolParams {
16529                    name: name.to_string(),
16530                    arguments: None,
16531                    meta: None,
16532                },
16533                state,
16534                None,
16535                None,
16536            )
16537            .expect_err("panic must terminate as a sanitized protocol error");
16538        let wire = serde_json::to_string(&error).expect("error serializes");
16539        assert_eq!(error.code, McpErrorCode::InternalError);
16540        assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16541        assert_eq!(error.data, None);
16542        assert!(!wire.contains(PANIC_CANARY));
16543        assert!(!wire.contains("Bearer"));
16544        assert!(!wire.contains("secret"));
16545        assert!(!wire.contains("peer-secret"));
16546        assert!(!wire.contains('\u{001b}'));
16547        assert!(wire.len() < 256);
16548        assert!(!cx.is_cancel_requested());
16549    }
16550
16551    #[test]
16552    fn unwinding_string_and_non_string_panics_have_one_fixed_peer_error() {
16553        assert_sanitized_panic_tool(
16554            UnwindingPanicTool {
16555                payload: "Bearer actual-secret".to_string(),
16556                non_string: false,
16557            },
16558            "panic_tool",
16559        );
16560        assert_sanitized_panic_tool(
16561            UnwindingPanicTool {
16562                payload: PANIC_CANARY.to_string(),
16563                non_string: true,
16564            },
16565            "panic_tool",
16566        );
16567    }
16568
16569    #[test]
16570    fn four_valued_panic_payload_is_never_rendered_for_peer() {
16571        assert_sanitized_panic_tool(
16572            OutcomePanicTool(format!("{PANIC_CANARY}{}", "y".repeat(64 * 1024))),
16573            "outcome_panic_tool",
16574        );
16575    }
16576
16577    #[test]
16578    fn opaque_internal_handler_errors_use_the_fixed_peer_contract() {
16579        let cx = Cx::for_testing();
16580
16581        let mut tool_router = Router::new();
16582        tool_router
16583            .add_tool(OpaqueInternalTool)
16584            .expect("tool registration succeeds");
16585        let tool_state = SessionState::new();
16586        let tool_ctx = request_context(&cx, 1, Budget::INFINITE, &tool_state);
16587        let tool_error = tool_router
16588            .handle_tools_call(
16589                &tool_ctx,
16590                CallToolParams {
16591                    name: "opaque_internal_tool".to_string(),
16592                    arguments: None,
16593                    meta: None,
16594                },
16595                tool_state,
16596                None,
16597                None,
16598            )
16599            .expect_err("opaque internal tool errors must remain protocol failures");
16600
16601        let mut resource_router = Router::new();
16602        resource_router.add_resource(OpaqueInternalResource);
16603        let resource_state = SessionState::new();
16604        let resource_ctx = request_context(&cx, 2, Budget::INFINITE, &resource_state);
16605        let resource_error = resource_router
16606            .handle_resources_read(
16607                &resource_ctx,
16608                &ReadResourceParams {
16609                    uri: "opaque://internal".to_string(),
16610                    meta: None,
16611                },
16612                resource_state,
16613                None,
16614                None,
16615            )
16616            .expect_err("opaque internal resource errors must be sanitized");
16617
16618        let mut prompt_router = Router::new();
16619        prompt_router.add_prompt(OpaqueInternalPrompt);
16620        let prompt_state = SessionState::new();
16621        let prompt_ctx = request_context(&cx, 3, Budget::INFINITE, &prompt_state);
16622        let prompt_error = prompt_router
16623            .handle_prompts_get(
16624                &prompt_ctx,
16625                GetPromptParams {
16626                    name: "opaque_internal_prompt".to_string(),
16627                    arguments: None,
16628                    meta: None,
16629                },
16630                prompt_state,
16631                None,
16632                None,
16633            )
16634            .expect_err("opaque internal prompt errors must be sanitized");
16635
16636        for error in [tool_error, resource_error, prompt_error] {
16637            let wire = serde_json::to_string(&error).expect("error serializes");
16638            assert_eq!(error.code, McpErrorCode::InternalError);
16639            assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16640            assert_eq!(error.data, None);
16641            assert!(!wire.contains(PANIC_CANARY));
16642            assert!(wire.len() < 256);
16643        }
16644    }
16645
16646    #[test]
16647    fn resource_and_prompt_panics_use_same_sanitized_contract() {
16648        let mut resource_router = Router::new();
16649        resource_router.add_resource(PanicResource);
16650        let resource_cx = Cx::for_testing();
16651        let resource_state = SessionState::new();
16652        let resource_ctx = request_context(&resource_cx, 1, Budget::INFINITE, &resource_state);
16653        let resource_error = resource_router
16654            .handle_resources_read(
16655                &resource_ctx,
16656                &ReadResourceParams {
16657                    uri: "panic://resource".to_string(),
16658                    meta: None,
16659                },
16660                resource_state,
16661                None,
16662                None,
16663            )
16664            .expect_err("resource panic must be sanitized");
16665        assert_eq!(resource_error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16666
16667        let mut prompt_router = Router::new();
16668        prompt_router.add_prompt(PanicPrompt);
16669        let prompt_cx = Cx::for_testing();
16670        let prompt_state = SessionState::new();
16671        let prompt_ctx = request_context(&prompt_cx, 1, Budget::INFINITE, &prompt_state);
16672        let prompt_error = prompt_router
16673            .handle_prompts_get(
16674                &prompt_ctx,
16675                GetPromptParams {
16676                    name: "panic_prompt".to_string(),
16677                    arguments: None,
16678                    meta: None,
16679                },
16680                prompt_state,
16681                None,
16682                None,
16683            )
16684            .expect_err("prompt panic must be sanitized");
16685        assert_eq!(prompt_error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16686
16687        for error in [resource_error, prompt_error] {
16688            let wire = serde_json::to_string(&error).expect("error serializes");
16689            assert!(!wire.contains(PANIC_CANARY));
16690            assert!(wire.len() < 256);
16691        }
16692    }
16693
16694    #[test]
16695    fn admitted_tool_definition_is_snapshotted_while_other_list_panics_are_sanitized() {
16696        let cx = Cx::for_testing();
16697        let request_ctx = McpContext::new(cx, 1);
16698
16699        let mut tool_router = Router::new();
16700        tool_router
16701            .add_tool(DefinitionPanicTool(std::sync::atomic::AtomicBool::new(
16702                false,
16703            )))
16704            .expect("the definition hook is read exactly once during admission");
16705        let tools = tool_router
16706            .handle_tools_list(&request_ctx, ListToolsParams::default(), None)
16707            .expect("listing clones the immutable admitted definition snapshot");
16708        assert_eq!(tools.tools.len(), 1);
16709        assert_eq!(tools.tools[0].name, "definition_panic_tool");
16710
16711        let mut resource_router = Router::new();
16712        resource_router.add_resource(DefinitionPanicResource(std::sync::atomic::AtomicBool::new(
16713            false,
16714        )));
16715        let resource_error = resource_router
16716            .handle_resources_list(&request_ctx, ListResourcesParams::default(), None)
16717            .expect_err("resource definition panic must be sanitized");
16718
16719        let mut prompt_router = Router::new();
16720        prompt_router.add_prompt(DefinitionPanicPrompt(std::sync::atomic::AtomicBool::new(
16721            false,
16722        )));
16723        let prompt_error = prompt_router
16724            .handle_prompts_list(&request_ctx, ListPromptsParams::default(), None)
16725            .expect_err("prompt definition panic must be sanitized");
16726
16727        for error in [resource_error, prompt_error] {
16728            let wire = serde_json::to_string(&error).expect("error serializes");
16729            assert_eq!(error.code, McpErrorCode::InternalError);
16730            assert_eq!(error.message, SANITIZED_HANDLER_PANIC_MESSAGE);
16731            assert_eq!(error.data, None);
16732            assert!(!wire.contains(PANIC_CANARY));
16733            assert!(wire.len() < 256);
16734        }
16735    }
16736
16737    // ── add_resource_with_behavior: template resource Error ───────────────
16738
16739    #[test]
16740    fn add_resource_with_behavior_template_error_on_duplicate() {
16741        struct TmplResource;
16742        impl ResourceHandler for TmplResource {
16743            fn definition(&self) -> Resource {
16744                Resource {
16745                    uri: "db://placeholder".to_string(),
16746                    name: "db".to_string(),
16747                    description: None,
16748                    mime_type: None,
16749                    icon: None,
16750                    version: None,
16751                    tags: vec![],
16752                }
16753            }
16754            fn template(&self) -> Option<ResourceTemplate> {
16755                Some(ResourceTemplate {
16756                    uri_template: "db://{table}".to_string(),
16757                    name: "db".to_string(),
16758                    description: None,
16759                    mime_type: None,
16760                    icon: None,
16761                    version: None,
16762                    tags: vec![],
16763                })
16764            }
16765            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
16766                Ok(vec![])
16767            }
16768        }
16769        let mut r = Router::new();
16770        r.add_resource(TmplResource);
16771        let err = r
16772            .add_resource_with_behavior(TmplResource, crate::DuplicateBehavior::Error)
16773            .unwrap_err();
16774        assert!(err.message.contains("already exists"));
16775    }
16776
16777    // ── add_resource_with_behavior: template resource Ignore ─────────────
16778
16779    #[test]
16780    fn add_resource_with_behavior_template_ignore_on_duplicate() {
16781        struct TmplResource2;
16782        impl ResourceHandler for TmplResource2 {
16783            fn definition(&self) -> Resource {
16784                Resource {
16785                    uri: "cache://placeholder".to_string(),
16786                    name: "cache".to_string(),
16787                    description: None,
16788                    mime_type: None,
16789                    icon: None,
16790                    version: None,
16791                    tags: vec![],
16792                }
16793            }
16794            fn template(&self) -> Option<ResourceTemplate> {
16795                Some(ResourceTemplate {
16796                    uri_template: "cache://{key}".to_string(),
16797                    name: "cache".to_string(),
16798                    description: None,
16799                    mime_type: None,
16800                    icon: None,
16801                    version: None,
16802                    tags: vec![],
16803                })
16804            }
16805            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
16806                Ok(vec![])
16807            }
16808        }
16809        let mut r = Router::new();
16810        r.add_resource(TmplResource2);
16811        r.add_resource_with_behavior(TmplResource2, crate::DuplicateBehavior::Ignore)
16812            .unwrap();
16813        assert_eq!(r.resource_templates_count(), 1);
16814    }
16815
16816    // ── add_resource_with_behavior: template resource Warn ───────────────
16817
16818    #[test]
16819    fn add_resource_with_behavior_template_warn_on_duplicate() {
16820        struct TmplResource3;
16821        impl ResourceHandler for TmplResource3 {
16822            fn definition(&self) -> Resource {
16823                Resource {
16824                    uri: "log://placeholder".to_string(),
16825                    name: "log".to_string(),
16826                    description: None,
16827                    mime_type: None,
16828                    icon: None,
16829                    version: None,
16830                    tags: vec![],
16831                }
16832            }
16833            fn template(&self) -> Option<ResourceTemplate> {
16834                Some(ResourceTemplate {
16835                    uri_template: "log://{entry}".to_string(),
16836                    name: "log".to_string(),
16837                    description: None,
16838                    mime_type: None,
16839                    icon: None,
16840                    version: None,
16841                    tags: vec![],
16842                })
16843            }
16844            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
16845                Ok(vec![])
16846            }
16847        }
16848        let mut r = Router::new();
16849        r.add_resource(TmplResource3);
16850        r.add_resource_with_behavior(TmplResource3, crate::DuplicateBehavior::Warn)
16851            .unwrap();
16852        assert_eq!(r.resource_templates_count(), 1);
16853    }
16854
16855    // ── mount_tools warns on conflict ────────────────────────────────
16856
16857    #[test]
16858    fn mount_tools_warns_on_tool_conflict() {
16859        let mut main = Router::new();
16860        main.add_tool(NamedTool::new("t"))
16861            .expect("tool registration succeeds");
16862        let mut sub = Router::new();
16863        sub.add_tool(NamedTool::new("t"))
16864            .expect("tool registration succeeds");
16865        let result = main.mount_tools(sub, None);
16866        assert!(!result.warnings.is_empty());
16867        assert!(result.warnings[0].contains("Tool"));
16868    }
16869
16870    // ── mount_prompts warns on conflict ──────────────────────────────────
16871
16872    #[test]
16873    fn mount_prompts_warns_on_prompt_conflict() {
16874        let mut main = Router::new();
16875        main.add_prompt(NamedPrompt::new("p"));
16876        let mut sub = Router::new();
16877        sub.add_prompt(NamedPrompt::new("p"));
16878        let result = main.mount_prompts(sub, None);
16879        assert!(!result.warnings.is_empty());
16880        assert!(result.warnings[0].contains("Prompt"));
16881    }
16882
16883    // ── invalid cursor returns error ─────────────────────────────────────
16884
16885    #[test]
16886    fn invalid_cursor_returns_error() {
16887        let mut r = Router::new();
16888        r.set_list_page_size(Some(1));
16889        r.add_tool(NamedTool::new("a"))
16890            .expect("tool registration succeeds");
16891        let cx = Cx::for_testing();
16892        let params = ListToolsParams {
16893            cursor: Some("not-valid-base64!!!".to_string()),
16894            include_tags: None,
16895            exclude_tags: None,
16896        };
16897        let request_ctx = McpContext::new(cx, 1);
16898        let err = r.handle_tools_list(&request_ctx, params, None).unwrap_err();
16899        assert!(err.message.contains("cursor") || err.message.contains("Invalid"));
16900    }
16901
16902    // ── set_list_page_size zero is treated as None ───────────────────────
16903
16904    #[test]
16905    fn set_list_page_size_zero_disables_pagination() {
16906        let mut r = Router::new();
16907        r.set_list_page_size(Some(0));
16908        r.add_tool(NamedTool::new("a"))
16909            .expect("tool registration succeeds");
16910        r.add_tool(NamedTool::new("b"))
16911            .expect("tool registration succeeds");
16912        let cx = Cx::for_testing();
16913        let params = ListToolsParams {
16914            cursor: None,
16915            include_tags: None,
16916            exclude_tags: None,
16917        };
16918        let request_ctx = McpContext::new(cx, 1);
16919        let result = r.handle_tools_list(&request_ctx, params, None).unwrap();
16920        // With page_size = 0, all items returned (no pagination)
16921        assert_eq!(result.tools.len(), 2);
16922        assert!(result.next_cursor.is_none());
16923    }
16924
16925    // ── strict_input_validation getter ───────────────────────────────────
16926
16927    #[test]
16928    fn strict_input_validation_toggle() {
16929        let mut r = Router::new();
16930        assert!(!r.strict_input_validation());
16931        r.set_strict_input_validation(true);
16932        assert!(r.strict_input_validation());
16933        r.set_strict_input_validation(false);
16934        assert!(!r.strict_input_validation());
16935    }
16936
16937    // ── cx-cancelled early return paths ──────────────────────────────────
16938
16939    #[test]
16940    fn handle_tools_call_cancelled_cx_returns_error() {
16941        let mut r = Router::new();
16942        r.add_tool(NamedTool::new("t"))
16943            .expect("tool registration succeeds");
16944        let cx = Cx::for_testing();
16945        cx.set_cancel_requested(true);
16946        let budget = Budget::INFINITE;
16947        let params = CallToolParams {
16948            name: "t".to_string(),
16949            arguments: None,
16950            meta: None,
16951        };
16952        let state = SessionState::new();
16953        let request_ctx = request_context(&cx, 1, budget, &state);
16954        let err = r
16955            .handle_tools_call(&request_ctx, params, state, None, None)
16956            .unwrap_err();
16957        assert_eq!(err.code, McpErrorCode::RequestCancelled);
16958    }
16959
16960    #[test]
16961    fn handle_resources_read_cancelled_cx_returns_error() {
16962        let mut r = Router::new();
16963        r.add_resource(NamedResource::new("file:///a.txt"));
16964        let cx = Cx::for_testing();
16965        cx.set_cancel_requested(true);
16966        let budget = Budget::INFINITE;
16967        let params = ReadResourceParams {
16968            uri: "file:///a.txt".to_string(),
16969            meta: None,
16970        };
16971        let state = SessionState::new();
16972        let request_ctx = request_context(&cx, 1, budget, &state);
16973        let err = r
16974            .handle_resources_read(&request_ctx, &params, state, None, None)
16975            .unwrap_err();
16976        assert_eq!(err.code, McpErrorCode::RequestCancelled);
16977    }
16978
16979    #[test]
16980    fn handle_prompts_get_cancelled_cx_returns_error() {
16981        let mut r = Router::new();
16982        r.add_prompt(NamedPrompt::new("p"));
16983        let cx = Cx::for_testing();
16984        cx.set_cancel_requested(true);
16985        let budget = Budget::INFINITE;
16986        let params = GetPromptParams {
16987            name: "p".to_string(),
16988            arguments: None,
16989            meta: None,
16990        };
16991        let state = SessionState::new();
16992        let request_ctx = request_context(&cx, 1, budget, &state);
16993        let err = r
16994            .handle_prompts_get(&request_ctx, params, state, None, None)
16995            .unwrap_err();
16996        assert_eq!(err.code, McpErrorCode::RequestCancelled);
16997    }
16998
16999    #[test]
17000    fn completion_handler_dispatches_exact_legacy_and_final_contracts() {
17001        let mut router = Router::new();
17002        assert!(!router.has_completion_handler());
17003        router.add_completion_handler(EchoCompletion);
17004        router.add_prompt(PromptArgumentBoundary {
17005            final_calls: Arc::new(AtomicUsize::new(0)),
17006            legacy_calls: Arc::new(AtomicUsize::new(0)),
17007        });
17008        assert!(router.has_completion_handler());
17009        assert!(
17010            router
17011                .server_discovery_behavior_registry()
17012                .contains(ServerBehavior::CompletionComplete),
17013            "discovery advertises completion only after the handler is installed"
17014        );
17015
17016        let cx = Cx::for_testing();
17017        let state = SessionState::new();
17018        let request_ctx = request_context(&cx, 87, Budget::INFINITE, &state);
17019        let legacy_request = JsonRpcRequest::new(
17020            COMPLETION_COMPLETE,
17021            Some(serde_json::json!({
17022                "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17023                "argument": {"name": "topic", "value": "sta"},
17024            })),
17025            87_i64,
17026        );
17027        let legacy = router
17028            .dispatch_legacy_completion(&request_ctx, &legacy_request)
17029            .expect("the exact legacy request reaches the registered completion handler");
17030        assert!(
17031            legacy.get("resultType").is_none(),
17032            "the exact legacy completion result remains discriminator-free"
17033        );
17034        assert_eq!(
17035            legacy["completion"]["values"],
17036            serde_json::json!(["staging"])
17037        );
17038
17039        let modern = router
17040            .dispatch_stateless(
17041                &request_ctx,
17042                &JsonRpcRequest::new(
17043                    COMPLETION_COMPLETE,
17044                    Some(serde_json::json!({
17045                        "_meta": {
17046                            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17047                            "io.modelcontextprotocol/clientCapabilities": {},
17048                        },
17049                        "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17050                        "argument": {"name": "topic", "value": "sta"},
17051                    })),
17052                    88_i64,
17053                ),
17054            )
17055            .expect("the final request reaches the same registered completion handler");
17056        assert_eq!(
17057            modern.get("resultType"),
17058            Some(&serde_json::json!("complete"))
17059        );
17060        assert_eq!(modern.get("completion"), legacy.get("completion"));
17061    }
17062
17063    #[test]
17064    fn final_completion_routes_to_the_registered_prompt_or_resource_provider() {
17065        let prompt_calls = Arc::new(AtomicUsize::new(0));
17066        let resource_calls = Arc::new(AtomicUsize::new(0));
17067        let fallback_calls = Arc::new(AtomicUsize::new(0));
17068        let mut router = Router::new();
17069        router.add_completion_handler(ProviderCompletion {
17070            value: "fallback-provider",
17071            final_calls: Arc::clone(&fallback_calls),
17072        });
17073        router.add_prompt(PromptArgumentBoundary {
17074            final_calls: Arc::new(AtomicUsize::new(0)),
17075            legacy_calls: Arc::new(AtomicUsize::new(0)),
17076        });
17077        router.add_resource_template(marked_template("resource://first/{id}", "first"));
17078        router.add_resource_template(marked_template("resource://second/{id}", "second"));
17079        router.add_prompt_completion_handler(
17080            "prompt-argument-boundary",
17081            ProviderCompletion {
17082                value: "prompt-provider",
17083                final_calls: Arc::clone(&prompt_calls),
17084            },
17085        );
17086        router.add_resource_template_completion_handler(
17087            "resource://first/{id}",
17088            ProviderCompletion {
17089                value: "resource-provider",
17090                final_calls: Arc::clone(&resource_calls),
17091            },
17092        );
17093
17094        assert!(
17095            router
17096                .server_discovery_behavior_registry()
17097                .contains(ServerBehavior::CompletionComplete),
17098            "a registered final completion provider enables discovery"
17099        );
17100
17101        let cx = Cx::for_testing();
17102        let state = SessionState::new();
17103        let request_ctx = request_context(&cx, 187, Budget::INFINITE, &state);
17104        let metadata = serde_json::json!({
17105            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17106            "io.modelcontextprotocol/clientCapabilities": {},
17107        });
17108        let prompt_request = JsonRpcRequest::new(
17109            COMPLETION_COMPLETE,
17110            Some(serde_json::json!({
17111                "_meta": metadata.clone(),
17112                "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17113                "argument": {"name": "topic", "value": "pro"},
17114            })),
17115            187_i64,
17116        );
17117        let resource_request = JsonRpcRequest::new(
17118            COMPLETION_COMPLETE,
17119            Some(serde_json::json!({
17120                "_meta": metadata,
17121                "ref": {"type": "ref/resource", "uri": "resource://first/{id}"},
17122                "argument": {"name": "id", "value": "pro"},
17123            })),
17124            188_i64,
17125        );
17126
17127        let prompt = router
17128            .dispatch_stateless(&request_ctx, &prompt_request)
17129            .expect("the registered prompt provider handles its exact target");
17130        let resource = router
17131            .dispatch_stateless(&request_ctx, &resource_request)
17132            .expect("the registered resource provider handles its exact target");
17133        assert_eq!(
17134            prompt["completion"]["values"],
17135            serde_json::json!(["prompt-provider"])
17136        );
17137        assert_eq!(
17138            resource["completion"]["values"],
17139            serde_json::json!(["resource-provider"])
17140        );
17141        assert_eq!(prompt_calls.load(Ordering::SeqCst), 1);
17142        assert_eq!(resource_calls.load(Ordering::SeqCst), 1);
17143        assert_eq!(
17144            fallback_calls.load(Ordering::SeqCst),
17145            0,
17146            "a target-specific provider takes precedence over the installed fallback"
17147        );
17148
17149        let mut unregistered_provider = resource_request.clone();
17150        unregistered_provider
17151            .params
17152            .as_mut()
17153            .and_then(serde_json::Value::as_object_mut)
17154            .and_then(|params| params.get_mut("ref"))
17155            .and_then(serde_json::Value::as_object_mut)
17156            .expect("completion reference is an object")
17157            .insert(
17158                "uri".to_owned(),
17159                serde_json::json!("resource://second/{id}"),
17160            );
17161        assert_eq!(resource_request.method, unregistered_provider.method);
17162        assert_eq!(resource_request.id, unregistered_provider.id);
17163        assert_eq!(
17164            resource_request
17165                .params
17166                .as_ref()
17167                .and_then(|params| params.get("argument")),
17168            unregistered_provider
17169                .params
17170                .as_ref()
17171                .and_then(|params| params.get("argument")),
17172            "the referenced resource template is the sole planted dimension"
17173        );
17174        let fallback = router
17175            .dispatch_stateless(&request_ctx, &unregistered_provider)
17176            .expect("an admitted target without a provider-specific handler reaches the fallback");
17177        assert_eq!(
17178            fallback["completion"]["values"],
17179            serde_json::json!(["fallback-provider"])
17180        );
17181        assert_eq!(prompt_calls.load(Ordering::SeqCst), 1);
17182        assert_eq!(resource_calls.load(Ordering::SeqCst), 1);
17183        assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
17184    }
17185
17186    #[test]
17187    fn modern_connection_visibility_blocks_final_completion_before_provider_invocation() {
17188        let final_calls = Arc::new(AtomicUsize::new(0));
17189        let mut router = Router::new();
17190        router.add_completion_handler(CountingCompletion {
17191            final_calls: Arc::clone(&final_calls),
17192        });
17193        router.add_prompt(PromptArgumentBoundary {
17194            final_calls: Arc::new(AtomicUsize::new(0)),
17195            legacy_calls: Arc::new(AtomicUsize::new(0)),
17196        });
17197        const TEMPLATE_URI: &str = "resource://completion-visibility/{id}";
17198        router.add_resource_template(marked_template(TEMPLATE_URI, "completion-visibility"));
17199
17200        let prompt_catalog_before = serde_json::to_vec(&router.prompts())
17201            .expect("prompt catalog serializes before completion dispatch");
17202        let template_catalog_before = serde_json::to_vec(&router.resource_templates())
17203            .expect("resource-template catalog serializes before completion dispatch");
17204        let prompt_request = JsonRpcRequest::new(
17205            COMPLETION_COMPLETE,
17206            Some(serde_json::json!({
17207                "_meta": {
17208                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17209                    "io.modelcontextprotocol/clientCapabilities": {},
17210                },
17211                "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17212                "argument": {"name": "topic", "value": "sta"},
17213            })),
17214            1_871_i64,
17215        );
17216        let resource_request = JsonRpcRequest::new(
17217            COMPLETION_COMPLETE,
17218            Some(serde_json::json!({
17219                "_meta": {
17220                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17221                    "io.modelcontextprotocol/clientCapabilities": {},
17222                },
17223                "ref": {"type": "ref/resource", "uri": TEMPLATE_URI},
17224                "argument": {"name": "id", "value": "sta"},
17225            })),
17226            1_872_i64,
17227        );
17228        let prompt_request_bytes =
17229            serde_json::to_vec(&prompt_request).expect("prompt completion request serializes");
17230        let resource_request_bytes = serde_json::to_vec(&resource_request)
17231            .expect("resource-template completion request serializes");
17232
17233        let cx = Cx::for_testing();
17234        let allowed_connection = ModernConnection::new();
17235        let allowed_inbound = InboundRequestContext::with_modern_connection(
17236            cx.clone(),
17237            1_871,
17238            InboundRequestTransport::Stdio,
17239            &allowed_connection,
17240        );
17241        let allowed_context = allowed_inbound.request_context();
17242        for request in [&prompt_request, &resource_request] {
17243            let result = router
17244                .dispatch_stateless(&allowed_context, request)
17245                .expect("a visible completion target reaches the fallback provider");
17246            assert_eq!(
17247                result["completion"]["values"],
17248                serde_json::json!(["staging"])
17249            );
17250        }
17251        assert_eq!(final_calls.load(Ordering::SeqCst), 2);
17252
17253        let denied_connection = ModernConnection::new();
17254        let denied_inbound = InboundRequestContext::with_modern_connection(
17255            cx,
17256            1_871,
17257            InboundRequestTransport::Stdio,
17258            &denied_connection,
17259        );
17260        let denied_context = denied_inbound.request_context();
17261        assert!(denied_context.disable_prompt("prompt-argument-boundary"));
17262        assert!(denied_context.disable_resource(TEMPLATE_URI));
17263
17264        assert_eq!(
17265            serde_json::to_vec(&prompt_request)
17266                .expect("prompt completion request remains serializable"),
17267            prompt_request_bytes,
17268            "connection visibility is the sole planted prompt-completion dimension"
17269        );
17270        let hidden_prompt = router
17271            .dispatch_stateless(&denied_context, &prompt_request)
17272            .expect_err("a hidden prompt cannot be used as a completion reference");
17273        assert_eq!(hidden_prompt.code, McpErrorCode::InvalidParams);
17274        assert_eq!(
17275            hidden_prompt.message,
17276            "completion prompt reference is not registered"
17277        );
17278
17279        assert_eq!(
17280            serde_json::to_vec(&resource_request)
17281                .expect("resource-template completion request remains serializable"),
17282            resource_request_bytes,
17283            "connection visibility is the sole planted resource-template-completion dimension"
17284        );
17285        let hidden_resource = router
17286            .dispatch_stateless(&denied_context, &resource_request)
17287            .expect_err("a hidden resource template cannot reach the fallback provider");
17288        assert_eq!(hidden_resource.code, McpErrorCode::InvalidParams);
17289        assert_eq!(
17290            hidden_resource.message,
17291            "completion resource reference is not registered"
17292        );
17293        assert_eq!(
17294            final_calls.load(Ordering::SeqCst),
17295            2,
17296            "refused completion references must not invoke the fallback provider"
17297        );
17298        assert_eq!(
17299            serde_json::to_vec(&router.prompts())
17300                .expect("prompt catalog serializes after completion refusal"),
17301            prompt_catalog_before,
17302            "completion refusal cannot mutate the prompt catalog"
17303        );
17304        assert_eq!(
17305            serde_json::to_vec(&router.resource_templates())
17306                .expect("resource-template catalog serializes after completion refusal"),
17307            template_catalog_before,
17308            "completion refusal cannot mutate the resource-template catalog"
17309        );
17310    }
17311
17312    #[test]
17313    fn explicit_legacy_completion_is_not_discovered_or_dispatched_as_final() {
17314        let mut router = Router::new();
17315        router.add_legacy_completion_handler(EchoCompletion);
17316        router.add_prompt(NamedPrompt::new("deploy"));
17317        let cx = Cx::for_testing();
17318        let state = SessionState::new();
17319        let request_ctx = request_context(&cx, 184, Budget::INFINITE, &state);
17320        let request = JsonRpcRequest::new(
17321            COMPLETION_COMPLETE,
17322            Some(serde_json::json!({
17323                "ref": {"type": "ref/prompt", "name": "deploy"},
17324                "argument": {"name": "environment", "value": "sta"},
17325            })),
17326            184_i64,
17327        );
17328        assert!(
17329            router
17330                .dispatch_legacy_completion(&request_ctx, &request)
17331                .is_ok()
17332        );
17333
17334        let mut final_request = request.clone();
17335        final_request
17336            .params
17337            .as_mut()
17338            .and_then(serde_json::Value::as_object_mut)
17339            .expect("completion parameters are an object")
17340            .insert(
17341                "_meta".to_owned(),
17342                serde_json::json!({
17343                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17344                    "io.modelcontextprotocol/clientCapabilities": {},
17345                }),
17346            );
17347        let error = router
17348            .dispatch_stateless(&request_ctx, &final_request)
17349            .expect_err("only the selected protocol era changes completion availability");
17350        assert_eq!(error.code, McpErrorCode::MethodNotFound);
17351        assert!(
17352            !router
17353                .server_discovery_behavior_registry()
17354                .contains(ServerBehavior::CompletionComplete)
17355        );
17356    }
17357
17358    #[test]
17359    fn final_completion_resource_template_reference_and_argument_are_validated_before_handler() {
17360        let final_calls = Arc::new(AtomicUsize::new(0));
17361        let mut router = Router::new();
17362        router.add_completion_handler(CountingCompletion {
17363            final_calls: Arc::clone(&final_calls),
17364        });
17365        router.add_resource(NamedResource::new("resource://static"));
17366        router.add_resource_template(marked_template("resource://{id}", "registered"));
17367        router
17368            .add_legacy_resource_template(marked_template("resource://{legacy_id}", "legacy-only"));
17369        let cx = Cx::for_testing();
17370        let state = SessionState::new();
17371        let request_ctx = request_context(&cx, 188, Budget::INFINITE, &state);
17372        let baseline = JsonRpcRequest::new(
17373            COMPLETION_COMPLETE,
17374            Some(serde_json::json!({
17375                "_meta": {
17376                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17377                    "io.modelcontextprotocol/clientCapabilities": {},
17378                },
17379                "ref": {"type": "ref/resource", "uri": "resource://{id}"},
17380                "argument": {"name": "id", "value": "sta"},
17381            })),
17382            188_i64,
17383        );
17384        let templates_before = serde_json::to_vec(&router.resource_templates())
17385            .expect("resource-template catalog serializes");
17386        let template_accepted = router
17387            .dispatch_stateless(&request_ctx, &baseline)
17388            .expect("a registered final resource-template reference is accepted");
17389        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17390
17391        let mut static_reference = baseline.clone();
17392        static_reference
17393            .params
17394            .as_mut()
17395            .and_then(serde_json::Value::as_object_mut)
17396            .and_then(|params| params.get_mut("ref"))
17397            .and_then(serde_json::Value::as_object_mut)
17398            .expect("completion reference is an object")
17399            .insert("uri".to_owned(), serde_json::json!("resource://static"));
17400        let static_error = router
17401            .dispatch_stateless(&request_ctx, &static_reference)
17402            .expect_err("a static resource is not a final completion-template target");
17403        assert_eq!(static_error.code, McpErrorCode::InvalidParams);
17404        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17405
17406        let mut legacy_template = baseline.clone();
17407        legacy_template
17408            .params
17409            .as_mut()
17410            .and_then(serde_json::Value::as_object_mut)
17411            .and_then(|params| params.get_mut("ref"))
17412            .and_then(serde_json::Value::as_object_mut)
17413            .expect("completion reference is an object")
17414            .insert(
17415                "uri".to_owned(),
17416                serde_json::json!("resource://{legacy_id}"),
17417            );
17418        assert_eq!(baseline.method, legacy_template.method);
17419        assert_eq!(baseline.id, legacy_template.id);
17420        assert_eq!(
17421            baseline
17422                .params
17423                .as_ref()
17424                .and_then(|params| params.get("argument")),
17425            legacy_template
17426                .params
17427                .as_ref()
17428                .and_then(|params| params.get("argument")),
17429            "the target URI is the sole planted visibility dimension"
17430        );
17431        let legacy_template_error = router
17432            .dispatch_stateless(&request_ctx, &legacy_template)
17433            .expect_err("an exact-2024-only template is not final-visible");
17434        assert_eq!(legacy_template_error.code, McpErrorCode::InvalidParams);
17435        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17436
17437        let mut unknown_argument = baseline.clone();
17438        unknown_argument
17439            .params
17440            .as_mut()
17441            .and_then(serde_json::Value::as_object_mut)
17442            .and_then(|params| params.get_mut("argument"))
17443            .and_then(serde_json::Value::as_object_mut)
17444            .expect("completion argument is an object")
17445            .insert("name".to_owned(), serde_json::json!("unknown"));
17446        assert_eq!(baseline.method, unknown_argument.method);
17447        assert_eq!(baseline.id, unknown_argument.id);
17448        assert_eq!(
17449            baseline
17450                .params
17451                .as_ref()
17452                .and_then(|params| params.get("ref")),
17453            unknown_argument
17454                .params
17455                .as_ref()
17456                .and_then(|params| params.get("ref")),
17457            "the argument name is the sole planted validation dimension"
17458        );
17459        let unknown_argument_error = router
17460            .dispatch_stateless(&request_ctx, &unknown_argument)
17461            .expect_err("an undeclared template argument is rejected before the handler");
17462        assert_eq!(unknown_argument_error.code, McpErrorCode::InvalidParams);
17463        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17464        assert_eq!(
17465            serde_json::to_vec(&router.resource_templates())
17466                .expect("resource-template catalog serializes after refusal"),
17467            templates_before,
17468            "completion reference refusal cannot mutate the registered catalog"
17469        );
17470        assert_eq!(
17471            router
17472                .dispatch_stateless(&request_ctx, &baseline)
17473                .expect("the registered reference remains accepted after refusal"),
17474            template_accepted,
17475            "rejected target or argument changes cannot alter the accepted completion"
17476        );
17477        assert_eq!(final_calls.load(Ordering::SeqCst), 2);
17478    }
17479
17480    #[test]
17481    fn final_completion_prompt_reference_and_argument_are_validated_before_handler() {
17482        let final_calls = Arc::new(AtomicUsize::new(0));
17483        let mut router = Router::new();
17484        router.add_completion_handler(CountingCompletion {
17485            final_calls: Arc::clone(&final_calls),
17486        });
17487        router.add_prompt(PromptArgumentBoundary {
17488            final_calls: Arc::new(AtomicUsize::new(0)),
17489            legacy_calls: Arc::new(AtomicUsize::new(0)),
17490        });
17491        router.add_legacy_prompt(NamedPrompt::new("legacy-completion-prompt"));
17492        let cx = Cx::for_testing();
17493        let state = SessionState::new();
17494        let request_ctx = request_context(&cx, 189, Budget::INFINITE, &state);
17495        let baseline = JsonRpcRequest::new(
17496            COMPLETION_COMPLETE,
17497            Some(serde_json::json!({
17498                "_meta": {
17499                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17500                    "io.modelcontextprotocol/clientCapabilities": {},
17501                },
17502                "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17503                "argument": {"name": "topic", "value": "sta"},
17504            })),
17505            189_i64,
17506        );
17507        let accepted = router
17508            .dispatch_stateless(&request_ctx, &baseline)
17509            .expect("a final-visible prompt and its declared argument are accepted");
17510        assert_eq!(accepted["resultType"], "complete");
17511        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17512
17513        let mut legacy_prompt = baseline.clone();
17514        legacy_prompt
17515            .params
17516            .as_mut()
17517            .and_then(serde_json::Value::as_object_mut)
17518            .and_then(|params| params.get_mut("ref"))
17519            .and_then(serde_json::Value::as_object_mut)
17520            .expect("completion reference is an object")
17521            .insert(
17522                "name".to_owned(),
17523                serde_json::json!("legacy-completion-prompt"),
17524            );
17525        assert_eq!(baseline.method, legacy_prompt.method);
17526        assert_eq!(baseline.id, legacy_prompt.id);
17527        assert_eq!(
17528            baseline
17529                .params
17530                .as_ref()
17531                .and_then(|params| params.get("argument")),
17532            legacy_prompt
17533                .params
17534                .as_ref()
17535                .and_then(|params| params.get("argument")),
17536            "the prompt name is the sole planted visibility dimension"
17537        );
17538        let legacy_prompt_error = router
17539            .dispatch_stateless(&request_ctx, &legacy_prompt)
17540            .expect_err("an exact-2024-only prompt is not final-visible");
17541        assert_eq!(legacy_prompt_error.code, McpErrorCode::InvalidParams);
17542        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17543
17544        let mut unknown_argument = baseline.clone();
17545        unknown_argument
17546            .params
17547            .as_mut()
17548            .and_then(serde_json::Value::as_object_mut)
17549            .and_then(|params| params.get_mut("argument"))
17550            .and_then(serde_json::Value::as_object_mut)
17551            .expect("completion argument is an object")
17552            .insert("name".to_owned(), serde_json::json!("unknown"));
17553        assert_eq!(baseline.method, unknown_argument.method);
17554        assert_eq!(baseline.id, unknown_argument.id);
17555        assert_eq!(
17556            baseline
17557                .params
17558                .as_ref()
17559                .and_then(|params| params.get("ref")),
17560            unknown_argument
17561                .params
17562                .as_ref()
17563                .and_then(|params| params.get("ref")),
17564            "the argument name is the sole planted validation dimension"
17565        );
17566        let unknown_argument_error = router
17567            .dispatch_stateless(&request_ctx, &unknown_argument)
17568            .expect_err("an undeclared prompt argument is rejected before the handler");
17569        assert_eq!(unknown_argument_error.code, McpErrorCode::InvalidParams);
17570        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17571
17572        let legacy = router
17573            .dispatch_legacy_completion(
17574                &request_ctx,
17575                &JsonRpcRequest::new(
17576                    COMPLETION_COMPLETE,
17577                    Some(serde_json::json!({
17578                        "ref": {"type": "ref/prompt", "name": "legacy-completion-prompt"},
17579                        "argument": {"name": "unknown", "value": "sta"},
17580                    })),
17581                    190_i64,
17582                ),
17583            )
17584            .expect("exact-2024 completion retains its unvalidated target argument behavior");
17585        assert!(legacy.get("resultType").is_none());
17586        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17587    }
17588
17589    #[test]
17590    fn final_completion_rejects_invalid_local_handler_results() {
17591        let final_calls = Arc::new(AtomicUsize::new(0));
17592        let mut router = Router::new();
17593        router.add_completion_handler(CompletionValueBoundary {
17594            final_calls: Arc::clone(&final_calls),
17595        });
17596        router.add_prompt(PromptArgumentBoundary {
17597            final_calls: Arc::new(AtomicUsize::new(0)),
17598            legacy_calls: Arc::new(AtomicUsize::new(0)),
17599        });
17600        let cx = Cx::for_testing();
17601        let state = SessionState::new();
17602        let request_ctx = request_context(&cx, 191, Budget::INFINITE, &state);
17603        let baseline = JsonRpcRequest::new(
17604            COMPLETION_COMPLETE,
17605            Some(serde_json::json!({
17606                "_meta": {
17607                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17608                    "io.modelcontextprotocol/clientCapabilities": {},
17609                },
17610                "ref": {"type": "ref/prompt", "name": "prompt-argument-boundary"},
17611                "argument": {"name": "topic", "value": "at-bound"},
17612            })),
17613            191_i64,
17614        );
17615        let accepted = router
17616            .dispatch_stateless(&request_ctx, &baseline)
17617            .expect("a local handler result at the 100-value limit is accepted");
17618        assert_eq!(
17619            accepted["completion"]["values"].as_array().map(Vec::len),
17620            Some(fastmcp_protocol::MAX_COMPLETION_VALUES)
17621        );
17622        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
17623
17624        let mut one_over = baseline.clone();
17625        one_over
17626            .params
17627            .as_mut()
17628            .and_then(serde_json::Value::as_object_mut)
17629            .and_then(|params| params.get_mut("argument"))
17630            .and_then(serde_json::Value::as_object_mut)
17631            .expect("completion argument is an object")
17632            .insert("value".to_owned(), serde_json::json!("one-over"));
17633        assert_eq!(baseline.method, one_over.method);
17634        assert_eq!(baseline.id, one_over.id);
17635        assert_eq!(
17636            baseline
17637                .params
17638                .as_ref()
17639                .and_then(|params| params.get("ref")),
17640            one_over
17641                .params
17642                .as_ref()
17643                .and_then(|params| params.get("ref")),
17644            "the handler result boundary is the sole planted dimension"
17645        );
17646        let error = router
17647            .dispatch_stateless(&request_ctx, &one_over)
17648            .expect_err("a local handler cannot return a 101st completion value");
17649        assert_eq!(error.code, McpErrorCode::InternalError);
17650        assert_eq!(
17651            error.message,
17652            "completion handler returned more than 100 values"
17653        );
17654        assert_eq!(final_calls.load(Ordering::SeqCst), 2);
17655
17656        let mut negative_total = baseline;
17657        negative_total
17658            .params
17659            .as_mut()
17660            .and_then(serde_json::Value::as_object_mut)
17661            .and_then(|params| params.get_mut("argument"))
17662            .and_then(serde_json::Value::as_object_mut)
17663            .expect("completion argument is an object")
17664            .insert("value".to_owned(), serde_json::json!("negative-total"));
17665        let error = router
17666            .dispatch_stateless(&request_ctx, &negative_total)
17667            .expect_err("a local handler cannot return a negative final completion total");
17668        assert_eq!(error.code, McpErrorCode::InternalError);
17669        assert_eq!(
17670            error.message,
17671            "final completion total must be a nonnegative JSON integer"
17672        );
17673        assert_eq!(final_calls.load(Ordering::SeqCst), 3);
17674    }
17675
17676    #[test]
17677    fn resource_template_registration_is_final_visible_via_protocol_rfc6570_matcher() {
17678        struct AmbiguousTemplateResource;
17679
17680        impl ResourceHandler for AmbiguousTemplateResource {
17681            fn definition(&self) -> Resource {
17682                Resource {
17683                    uri: "mcp://resource/ambiguous".to_owned(),
17684                    name: "ambiguous-template".to_owned(),
17685                    description: None,
17686                    mime_type: None,
17687                    icon: None,
17688                    version: None,
17689                    tags: Vec::new(),
17690                }
17691            }
17692
17693            fn template(&self) -> Option<ResourceTemplate> {
17694                Some(marked_template(
17695                    "mcp://resource/{first}{second}",
17696                    "ambiguous",
17697                ))
17698            }
17699
17700            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
17701                Ok(Vec::new())
17702            }
17703        }
17704
17705        let mut router = Router::new();
17706        let read_calls = Arc::new(AtomicUsize::new(0));
17707        let completion_calls = Arc::new(AtomicUsize::new(0));
17708        router.add_completion_handler(CountingCompletion {
17709            final_calls: Arc::clone(&completion_calls),
17710        });
17711        router
17712            .add_resource_with_behavior(
17713                ReversibleLevelFourTemplateResource {
17714                    read_calls: Arc::clone(&read_calls),
17715                },
17716                crate::DuplicateBehavior::Replace,
17717            )
17718            .expect("a reversible level-four template is admitted");
17719        let cx = Cx::for_testing();
17720        let state = SessionState::new();
17721        let request_ctx = request_context(&cx, 189, Budget::INFINITE, &state);
17722        let legacy_error = router
17723            .handle_resources_read(
17724                &request_ctx,
17725                &fastmcp_protocol::ReadResourceParams {
17726                    uri: "mcp://resource/books/manifest?revision=stable".to_owned(),
17727                    meta: None,
17728                },
17729                state,
17730                None,
17731                None,
17732            )
17733            .expect_err("scalar explode remains unavailable to exact-2024 routing");
17734        assert_eq!(legacy_error.code, McpErrorCode::ResourceNotFound);
17735        assert_eq!(read_calls.load(Ordering::SeqCst), 0);
17736
17737        let final_metadata = serde_json::json!({
17738            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
17739            "io.modelcontextprotocol/clientCapabilities": {},
17740        });
17741        let templates = router
17742            .dispatch_stateless(
17743                &request_ctx,
17744                &JsonRpcRequest::new(
17745                    "resources/templates/list",
17746                    Some(serde_json::json!({"_meta": final_metadata.clone()})),
17747                    189_i64,
17748                ),
17749            )
17750            .expect("the admitted template is final-visible");
17751        assert_eq!(templates["resultType"], "complete");
17752        assert_eq!(
17753            templates["resourceTemplates"][0]["uriTemplate"],
17754            "mcp://resource{/collection*}/manifest{?revision*}"
17755        );
17756
17757        let final_read = router
17758            .dispatch_stateless(
17759                &request_ctx,
17760                &JsonRpcRequest::new(
17761                    "resources/read",
17762                    Some(serde_json::json!({
17763                        "_meta": final_metadata.clone(),
17764                        "uri": "mcp://resource/books%2Ffiction/manifest?revision=stable",
17765                    })),
17766                    189_i64,
17767                ),
17768            )
17769            .expect("the final route uses the same reversible matcher");
17770        assert_eq!(final_read["resultType"], "complete");
17771        assert_eq!(
17772            final_read["contents"][0]["text"], "books/fiction:stable",
17773            "the final route decodes the scalar capture exactly once"
17774        );
17775        assert_eq!(read_calls.load(Ordering::SeqCst), 1);
17776
17777        let completion = router
17778            .dispatch_stateless(
17779                &request_ctx,
17780                &JsonRpcRequest::new(
17781                    COMPLETION_COMPLETE,
17782                    Some(serde_json::json!({
17783                        "_meta": final_metadata,
17784                        "ref": {
17785                            "type": "ref/resource",
17786                            "uri": "mcp://resource{/collection*}/manifest{?revision*}",
17787                        },
17788                        "argument": {"name": "revision", "value": "sta"},
17789                    })),
17790                    189_i64,
17791                ),
17792            )
17793            .expect("the final completion target exposes protocol-derived variables");
17794        assert_eq!(completion["resultType"], "complete");
17795        assert_eq!(
17796            completion["completion"]["values"],
17797            serde_json::json!(["staging"])
17798        );
17799        assert_eq!(completion_calls.load(Ordering::SeqCst), 1);
17800
17801        let template_count = router.resource_templates_count();
17802        let error = router
17803            .add_resource_with_behavior(
17804                AmbiguousTemplateResource,
17805                crate::DuplicateBehavior::Replace,
17806            )
17807            .expect_err("changing only the adjacent scalar boundary remains ambiguous");
17808        assert_eq!(error.code, McpErrorCode::InvalidParams);
17809        assert_eq!(
17810            router.resource_templates_count(),
17811            template_count,
17812            "rejected template admission cannot mutate the registered catalog"
17813        );
17814    }
17815
17816    #[test]
17817    fn final_resource_registration_rejects_template_language_collisions_atomically() {
17818        let mut template_router = Router::new();
17819        template_router
17820            .add_resource_template_with_behavior(
17821                marked_template("mcp://resource/{first}", "first"),
17822                crate::DuplicateBehavior::Replace,
17823            )
17824            .expect("the first reversible template is admitted");
17825        let templates_before = serde_json::to_vec(&template_router.resource_templates())
17826            .expect("the admitted template catalog serializes");
17827
17828        let error = template_router
17829            .add_resource_template_with_behavior(
17830                marked_template("mcp://resource/{second}", "second"),
17831                crate::DuplicateBehavior::Replace,
17832            )
17833            .expect_err("renaming only the capture must not create a tie-broken route");
17834        assert_eq!(error.code, McpErrorCode::InvalidParams);
17835        assert_eq!(
17836            serde_json::to_vec(&template_router.resource_templates())
17837                .expect("a rejected collision leaves the template catalog serializable"),
17838            templates_before,
17839            "template-template collision rejection leaves the catalog unchanged"
17840        );
17841
17842        let mut exact_router = Router::new();
17843        exact_router
17844            .add_resource_with_behavior(
17845                NamedResource::new("mcp://resource/books"),
17846                crate::DuplicateBehavior::Replace,
17847            )
17848            .expect("the exact final resource is admitted");
17849        let exact_count = exact_router.resources_count();
17850        let error = exact_router
17851            .add_resource_template_with_behavior(
17852                marked_template("mcp://resource/{id}", "template"),
17853                crate::DuplicateBehavior::Replace,
17854            )
17855            .expect_err("a template cannot shadow an exact final resource");
17856        assert_eq!(error.code, McpErrorCode::InvalidParams);
17857        assert_eq!(exact_router.resources_count(), exact_count);
17858        assert_eq!(exact_router.resource_templates_count(), 0);
17859
17860        let mut reverse_router = Router::new();
17861        reverse_router
17862            .add_resource_template_with_behavior(
17863                marked_template("mcp://resource/{id}", "template"),
17864                crate::DuplicateBehavior::Replace,
17865            )
17866            .expect("the reversible template is admitted before the planted exact collision");
17867        let templates_before = serde_json::to_vec(&reverse_router.resource_templates())
17868            .expect("the admitted template catalog serializes");
17869        let error = reverse_router
17870            .add_resource_with_behavior(
17871                NamedResource::new("mcp://resource/books"),
17872                crate::DuplicateBehavior::Replace,
17873            )
17874            .expect_err("an exact final resource cannot be hidden behind a template");
17875        assert_eq!(error.code, McpErrorCode::InvalidParams);
17876        assert_eq!(reverse_router.resources_count(), 0);
17877        assert_eq!(
17878            serde_json::to_vec(&reverse_router.resource_templates())
17879                .expect("the template catalog remains serializable after refusal"),
17880            templates_before,
17881            "exact-resource collision rejection leaves the existing template unchanged"
17882        );
17883
17884        let mut disjoint_router = Router::new();
17885        disjoint_router
17886            .add_resource_template_with_behavior(
17887                marked_template("mcp://alpha/{id}", "alpha"),
17888                crate::DuplicateBehavior::Replace,
17889            )
17890            .expect("a disjoint template is admitted");
17891        disjoint_router
17892            .add_resource_template_with_behavior(
17893                marked_template("mcp://beta/{id}", "beta"),
17894                crate::DuplicateBehavior::Replace,
17895            )
17896            .expect("a template with a conflicting literal prefix remains independent");
17897        assert_eq!(disjoint_router.resource_templates_count(), 2);
17898    }
17899
17900    #[test]
17901    fn final_resource_mount_rejects_cross_router_language_collisions_atomically() {
17902        let exact_uri = "mcp://resource/books";
17903
17904        let mut exact_destination = Router::new();
17905        exact_destination
17906            .add_resource_with_behavior(
17907                NamedResource::new(exact_uri),
17908                crate::DuplicateBehavior::Replace,
17909            )
17910            .expect("the exact final resource is admitted before mounting");
17911        let exact_before = serde_json::to_vec(&exact_destination.resources())
17912            .expect("the exact destination catalog serializes");
17913        let mut template_source = Router::new();
17914        template_source
17915            .add_resource_template_with_behavior(
17916                marked_template("mcp://resource/{id}", "mounted-template"),
17917                crate::DuplicateBehavior::Replace,
17918            )
17919            .expect("the independently admitted source template is valid");
17920
17921        let result = exact_destination.mount_resources(template_source, None);
17922        assert!(!result.is_success());
17923        assert_eq!(
17924            serde_json::to_vec(&exact_destination.resources())
17925                .expect("a rejected mount preserves the destination catalog"),
17926            exact_before,
17927            "template-after-exact mount rejection leaves every destination resource unchanged"
17928        );
17929        assert_eq!(exact_destination.resource_templates_count(), 0);
17930
17931        let mut template_destination = Router::new();
17932        template_destination
17933            .add_resource_template_with_behavior(
17934                marked_template("mcp://resource/{id}", "destination-template"),
17935                crate::DuplicateBehavior::Replace,
17936            )
17937            .expect("the final template is admitted before mounting");
17938        let templates_before = serde_json::to_vec(&template_destination.resource_templates())
17939            .expect("the template destination catalog serializes");
17940        let mut exact_source = Router::new();
17941        exact_source
17942            .add_resource_with_behavior(
17943                NamedResource::new(exact_uri),
17944                crate::DuplicateBehavior::Replace,
17945            )
17946            .expect("the independently admitted source resource is valid");
17947
17948        let result = template_destination.mount_resources(exact_source, None);
17949        assert!(!result.is_success());
17950        assert_eq!(template_destination.resources_count(), 0);
17951        assert_eq!(
17952            serde_json::to_vec(&template_destination.resource_templates())
17953                .expect("a rejected mount preserves the destination template catalog"),
17954            templates_before,
17955            "exact-after-template mount rejection leaves every destination template unchanged"
17956        );
17957
17958        let mut first_template_destination = Router::new();
17959        first_template_destination
17960            .add_resource_template_with_behavior(
17961                marked_template("mcp://resource/{first}", "first-template"),
17962                crate::DuplicateBehavior::Replace,
17963            )
17964            .expect("the first final template is admitted");
17965        let templates_before = serde_json::to_vec(&first_template_destination.resource_templates())
17966            .expect("the first template destination catalog serializes");
17967        let mut second_template_source = Router::new();
17968        second_template_source
17969            .add_resource_template_with_behavior(
17970                marked_template("mcp://resource/{second}", "second-template"),
17971                crate::DuplicateBehavior::Replace,
17972            )
17973            .expect("the independently admitted second template is valid");
17974
17975        let result = first_template_destination.mount_resources(second_template_source, None);
17976        assert!(!result.is_success());
17977        assert_eq!(
17978            serde_json::to_vec(&first_template_destination.resource_templates())
17979                .expect("a rejected mount preserves the original template catalog"),
17980            templates_before,
17981            "template-language overlap cannot make mount order a dispatch authority"
17982        );
17983    }
17984
17985    #[test]
17986    fn empty_prefix_mount_matches_unprefixed_final_route_projection() {
17987        const EXACT_URI: &str = "mcp://resource/books";
17988
17989        for prefix in [None, Some("")] {
17990            let mut destination = Router::new();
17991            destination
17992                .add_resource_with_behavior(
17993                    NamedResource::new(EXACT_URI),
17994                    crate::DuplicateBehavior::Replace,
17995                )
17996                .expect("the destination exact final resource is admitted");
17997            let resources_before = serde_json::to_vec(&destination.resources())
17998                .expect("the destination catalog serializes before the mount");
17999            let mut source = Router::new();
18000            source
18001                .add_resource_template_with_behavior(
18002                    marked_template("mcp://resource/{id}", "one-variable-template"),
18003                    crate::DuplicateBehavior::Replace,
18004                )
18005                .expect("the source one-variable final template is admitted");
18006
18007            let result = destination.mount_resources(source, prefix);
18008
18009            assert!(
18010                !result.is_success(),
18011                "an unchanged-key mount must reject the final route collision for {prefix:?}"
18012            );
18013            assert!(
18014                result
18015                    .errors
18016                    .iter()
18017                    .any(|error| error.contains("collides with an exact final resource")),
18018                "the rejection must retain the exact/template collision reason for {prefix:?}"
18019            );
18020            assert_eq!(
18021                serde_json::to_vec(&destination.resources())
18022                    .expect("the rejected mount leaves the destination catalog serializable"),
18023                resources_before,
18024                "the one-variable collision leaves the destination unchanged for {prefix:?}"
18025            );
18026            assert_eq!(destination.resource_templates_count(), 0);
18027            assert!(destination.final_resources.contains_key(EXACT_URI));
18028
18029            let mut template_destination = Router::new();
18030            template_destination
18031                .add_resource_template_with_behavior(
18032                    marked_template("mcp://resource/{id}", "one-variable-destination-template"),
18033                    crate::DuplicateBehavior::Replace,
18034                )
18035                .expect("the destination one-variable final template is admitted");
18036            let templates_before = serde_json::to_vec(&template_destination.resource_templates())
18037                .expect("the destination template catalog serializes before the mount");
18038            let mut exact_source = Router::new();
18039            exact_source
18040                .add_resource_with_behavior(
18041                    NamedResource::new(EXACT_URI),
18042                    crate::DuplicateBehavior::Replace,
18043                )
18044                .expect("the source exact final resource is admitted");
18045
18046            let result = template_destination.mount_resources(exact_source, prefix);
18047
18048            assert!(
18049                !result.is_success(),
18050                "the reverse unchanged-key collision must reject for {prefix:?}"
18051            );
18052            assert!(
18053                result
18054                    .errors
18055                    .iter()
18056                    .any(|error| error.contains("collides with an exact final resource")),
18057                "the reverse rejection must retain the exact/template collision reason for {prefix:?}"
18058            );
18059            assert_eq!(
18060                serde_json::to_vec(&template_destination.resource_templates())
18061                    .expect("the rejected reverse mount leaves the template catalog serializable"),
18062                templates_before,
18063                "the reverse one-variable collision leaves the destination unchanged for {prefix:?}"
18064            );
18065            assert_eq!(template_destination.resources_count(), 0);
18066            assert!(
18067                template_destination.resource_templates["mcp://resource/{id}"]
18068                    .final_definition
18069                    .is_some()
18070            );
18071        }
18072
18073        for prefix in [None, Some("")] {
18074            let mut destination = Router::new();
18075            destination
18076                .add_resource_template_with_behavior(
18077                    marked_template("mcp://alpha/{id}", "destination-disjoint"),
18078                    crate::DuplicateBehavior::Replace,
18079                )
18080                .expect("the destination disjoint final template is admitted");
18081            let mut source = Router::new();
18082            source
18083                .add_resource_template_with_behavior(
18084                    marked_template("mcp://beta/{id}", "source-disjoint"),
18085                    crate::DuplicateBehavior::Replace,
18086                )
18087                .expect("the source disjoint final template is admitted");
18088
18089            let result = destination.mount_resources(source, prefix);
18090
18091            assert!(
18092                result.is_success(),
18093                "a disjoint unchanged-key mount remains admissible for {prefix:?}"
18094            );
18095            assert_eq!(destination.resource_templates_count(), 2);
18096            assert!(
18097                destination.resource_templates["mcp://beta/{id}"]
18098                    .final_definition
18099                    .is_some(),
18100                "an empty prefix cannot downgrade a disjoint final template for {prefix:?}"
18101            );
18102        }
18103
18104        for prefix in [None, Some("")] {
18105            let mut destination = Router::new();
18106            destination
18107                .add_final_resource_with_behavior(
18108                    NamedResource::new(EXACT_URI),
18109                    crate::DuplicateBehavior::Replace,
18110                )
18111                .expect("the destination final-only exact resource is admitted");
18112            let mut source = Router::new();
18113            source
18114                .add_legacy_resource_template_with_behavior(
18115                    marked_template("mcp://resource/{id}", "legacy-template"),
18116                    crate::DuplicateBehavior::Replace,
18117                )
18118                .expect("the source legacy-only template is admitted");
18119
18120            let result = destination.mount_resources(source, prefix);
18121
18122            assert!(
18123                result.is_success(),
18124                "a legacy-only template remains isolated for {prefix:?}"
18125            );
18126            assert!(destination.final_resources.contains_key(EXACT_URI));
18127            assert!(
18128                destination.resource_templates["mcp://resource/{id}"]
18129                    .final_definition
18130                    .is_none(),
18131                "legacy-only template mounting cannot enter final routing for {prefix:?}"
18132            );
18133        }
18134    }
18135
18136    #[test]
18137    fn legacy_only_template_mount_remains_final_isolated() {
18138        let exact_uri = "mcp://resource/books";
18139        let mut destination = Router::new();
18140        destination
18141            .add_final_resource_with_behavior(
18142                NamedResource::new(exact_uri),
18143                crate::DuplicateBehavior::Replace,
18144            )
18145            .expect("the final-only exact resource is admitted");
18146        let mut legacy_source = Router::new();
18147        legacy_source
18148            .add_legacy_resource_template_with_behavior(
18149                marked_template("mcp://resource/{id}", "legacy-template"),
18150                crate::DuplicateBehavior::Replace,
18151            )
18152            .expect("the frozen exact-2024 template is admitted in its own catalog");
18153
18154        let result = destination.mount_resources(legacy_source, None);
18155        assert!(result.is_success());
18156        assert_eq!(destination.resources_count(), 1);
18157        assert_eq!(destination.resource_templates_count(), 1);
18158        assert!(destination.final_resources.contains_key(exact_uri));
18159        assert!(
18160            destination.resource_templates["mcp://resource/{id}"]
18161                .final_definition
18162                .is_none(),
18163            "a legacy-only template remains absent from final dispatch after mounting"
18164        );
18165    }
18166
18167    #[test]
18168    fn legacy_resource_template_is_inert_on_final_list_read_and_completion() {
18169        let mut router = Router::new();
18170        let read_calls = Arc::new(AtomicUsize::new(0));
18171        let completion_calls = Arc::new(AtomicUsize::new(0));
18172        router.add_completion_handler(CountingCompletion {
18173            final_calls: Arc::clone(&completion_calls),
18174        });
18175        router
18176            .add_legacy_resource_with_behavior(
18177                LegacyTemplateResource {
18178                    read_calls: Arc::clone(&read_calls),
18179                },
18180                crate::DuplicateBehavior::Replace,
18181            )
18182            .expect("the exact-2024 template is admitted for its own route");
18183        let cx = Cx::for_testing();
18184        let state = SessionState::new();
18185        let request_ctx = request_context(&cx, 190, Budget::INFINITE, &state);
18186        let uri = "mcp://resource/books/manifest?revision=stable";
18187        let legacy_read = router
18188            .handle_resources_read(
18189                &request_ctx,
18190                &ReadResourceParams {
18191                    uri: uri.to_owned(),
18192                    meta: None,
18193                },
18194                state,
18195                None,
18196                None,
18197            )
18198            .expect("the exact legacy route retains the registered template");
18199        let legacy_wire =
18200            serde_json::to_value(legacy_read).expect("legacy resource result serializes");
18201        assert_eq!(legacy_wire["contents"][0]["text"], "books:stable");
18202        assert!(legacy_wire.get("resultType").is_none());
18203        assert_eq!(read_calls.load(Ordering::SeqCst), 1);
18204
18205        let final_metadata = serde_json::json!({
18206            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18207            "io.modelcontextprotocol/clientCapabilities": {},
18208        });
18209        let templates = router
18210            .dispatch_stateless(
18211                &request_ctx,
18212                &JsonRpcRequest::new(
18213                    "resources/templates/list",
18214                    Some(serde_json::json!({"_meta": final_metadata.clone()})),
18215                    190_i64,
18216                ),
18217            )
18218            .expect("final template discovery remains valid with only exact-2024 templates");
18219        assert_eq!(templates["resultType"], "complete");
18220        assert_eq!(templates["resourceTemplates"], serde_json::json!([]));
18221
18222        let final_read = router
18223            .dispatch_stateless(
18224                &request_ctx,
18225                &JsonRpcRequest::new(
18226                    "resources/read",
18227                    Some(serde_json::json!({
18228                        "_meta": final_metadata.clone(),
18229                        "uri": uri,
18230                    })),
18231                    190_i64,
18232                ),
18233            )
18234            .expect_err("changing only the dispatch era cannot invoke a legacy-only template");
18235        assert_eq!(final_read.code, McpErrorCode::InvalidParams);
18236        assert_eq!(read_calls.load(Ordering::SeqCst), 1);
18237
18238        let completion = router
18239            .dispatch_stateless(
18240                &request_ctx,
18241                &JsonRpcRequest::new(
18242                    COMPLETION_COMPLETE,
18243                    Some(serde_json::json!({
18244                        "_meta": final_metadata,
18245                        "ref": {
18246                            "type": "ref/resource",
18247                            "uri": "mcp://resource/{collection}/manifest?revision={revision}",
18248                        },
18249                        "argument": {"name": "revision", "value": "sta"},
18250                    })),
18251                    190_i64,
18252                ),
18253            )
18254            .expect_err("a legacy-only template is not a final completion target");
18255        assert_eq!(completion.code, McpErrorCode::InvalidParams);
18256        assert_eq!(completion_calls.load(Ordering::SeqCst), 0);
18257    }
18258
18259    #[test]
18260    fn exact_2024_resource_template_admission_rejects_final_only_star_and_multi_variable_forms() {
18261        let mut legacy_router = Router::new();
18262        legacy_router
18263            .add_legacy_resource_with_behavior(
18264                LegacyTemplateResource {
18265                    read_calls: Arc::new(AtomicUsize::new(0)),
18266                },
18267                crate::DuplicateBehavior::Replace,
18268            )
18269            .expect("the frozen {name} legacy grammar remains admitted");
18270        let catalog_before = serde_json::to_vec(&legacy_router.resource_templates())
18271            .expect("the admitted legacy catalog serializes");
18272
18273        let star_error = legacy_router
18274            .add_legacy_resource_with_behavior(
18275                ReversibleLevelFourTemplateResource {
18276                    read_calls: Arc::new(AtomicUsize::new(0)),
18277                },
18278                crate::DuplicateBehavior::Replace,
18279            )
18280            .expect_err("changing only a legacy variable to scalar explode is final-only");
18281        assert_eq!(star_error.code, McpErrorCode::InvalidParams);
18282        assert_eq!(
18283            serde_json::to_vec(&legacy_router.resource_templates())
18284                .expect("rejected star admission leaves the catalog serializable"),
18285            catalog_before,
18286            "legacy rejection cannot mutate the existing catalog"
18287        );
18288
18289        let multi_variable_error = legacy_router
18290            .add_legacy_resource_template_with_behavior(
18291                marked_template("mcp://resource{?collection*,revision*}", "final-only-multi"),
18292                crate::DuplicateBehavior::Replace,
18293            )
18294            .expect_err("changing only to a named multi-variable expression is final-only");
18295        assert_eq!(multi_variable_error.code, McpErrorCode::InvalidParams);
18296        assert_eq!(
18297            serde_json::to_vec(&legacy_router.resource_templates())
18298                .expect("rejected multi-variable admission leaves the catalog serializable"),
18299            catalog_before,
18300            "the near-identical final syntax cannot alter an exact-2024 catalog"
18301        );
18302
18303        let mut final_router = Router::new();
18304        final_router
18305            .add_final_resource_with_behavior(
18306                ReversibleLevelFourTemplateResource {
18307                    read_calls: Arc::new(AtomicUsize::new(0)),
18308                },
18309                crate::DuplicateBehavior::Replace,
18310            )
18311            .expect("the scalar-explode form remains admitted on the final route");
18312        final_router
18313            .add_resource_template_with_behavior(
18314                marked_template("mcp://resource{?collection*,revision*}", "final-multi"),
18315                crate::DuplicateBehavior::Replace,
18316            )
18317            .expect("the named multi-variable form remains admitted on the final route");
18318        assert_eq!(final_router.resource_templates_count(), 2);
18319    }
18320
18321    #[test]
18322    fn frozen_exact_2024_matcher_preserves_plus_capture_and_percent_decoding() {
18323        let matcher = admit_legacy_resource_template("legacy://resource/{+path}")
18324            .expect("the frozen legacy grammar retains {+name}");
18325        let params = matcher
18326            .matches("legacy://resource/books%2Ffiction")
18327            .expect("legacy plus capture matches a percent-encoded path");
18328        assert_eq!(
18329            params.get("path").map(String::as_str),
18330            Some("books/fiction")
18331        );
18332
18333        assert!(
18334            admit_legacy_resource_template("legacy://resource/{+path*}").is_err(),
18335            "changing only the scalar modifier keeps the new syntax final-only"
18336        );
18337    }
18338
18339    #[test]
18340    fn resource_template_admission_rejects_bare_literal_percent_without_catalog_mutation() {
18341        let accepted = marked_template("mcp://percent/reports%2Fdaily", "percent-template");
18342        let rejected = marked_template("mcp://percent/reports%Qdaily", "percent-template");
18343        let mut router = Router::new();
18344
18345        router
18346            .add_resource_template_with_behavior(accepted, crate::DuplicateBehavior::Replace)
18347            .expect("a complete literal percent triplet is admitted");
18348        let catalog_before = serde_json::to_vec(&router.resource_templates())
18349            .expect("accepted resource-template catalog serializes");
18350
18351        let error = router
18352            .add_resource_template_with_behavior(rejected, crate::DuplicateBehavior::Replace)
18353            .expect_err("changing only the percent triplet to a bare percent is refused");
18354        assert_eq!(error.code, McpErrorCode::InvalidParams);
18355        assert_eq!(
18356            serde_json::to_vec(&router.resource_templates())
18357                .expect("rejected admission leaves the catalog serializable"),
18358            catalog_before,
18359            "rejected literal syntax cannot rewrite the advertised template"
18360        );
18361    }
18362
18363    #[test]
18364    fn resource_resolution_skips_cross_era_static_shadows_for_matching_templates() {
18365        struct ShadowTemplate {
18366            label: &'static str,
18367        }
18368
18369        impl ResourceHandler for ShadowTemplate {
18370            fn definition(&self) -> Resource {
18371                Resource {
18372                    uri: "mcp://shadow/template".to_owned(),
18373                    name: self.label.to_owned(),
18374                    description: None,
18375                    mime_type: Some("text/plain".to_owned()),
18376                    icon: None,
18377                    version: None,
18378                    tags: Vec::new(),
18379                }
18380            }
18381
18382            fn template(&self) -> Option<ResourceTemplate> {
18383                Some(marked_template("mcp://shadow/{id}", self.label))
18384            }
18385
18386            fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
18387                unreachable!("templated reads receive their URI parameters")
18388            }
18389
18390            fn read_with_uri(
18391                &self,
18392                _ctx: &McpContext,
18393                uri: &str,
18394                _params: &UriParams,
18395            ) -> McpResult<Vec<ResourceContent>> {
18396                Ok(vec![ResourceContent {
18397                    uri: uri.to_owned(),
18398                    mime_type: Some("text/plain".to_owned()),
18399                    text: Some(self.label.to_owned()),
18400                    blob: None,
18401                }])
18402            }
18403        }
18404
18405        let cx = Cx::for_testing();
18406        let state = SessionState::new();
18407        let request_ctx = request_context(&cx, 190, Budget::INFINITE, &state);
18408        let uri = "mcp://shadow/item";
18409
18410        let mut legacy_router = Router::new();
18411        legacy_router
18412            .add_legacy_resource_with_behavior(
18413                ShadowTemplate {
18414                    label: "legacy-template",
18415                },
18416                crate::DuplicateBehavior::Replace,
18417            )
18418            .expect("legacy template registers");
18419        legacy_router
18420            .add_final_resource_with_behavior(
18421                NamedResource::new(uri),
18422                crate::DuplicateBehavior::Replace,
18423            )
18424            .expect("final-only static resource registers");
18425        let legacy_read = legacy_router
18426            .handle_resources_read(
18427                &request_ctx,
18428                &ReadResourceParams {
18429                    uri: uri.to_owned(),
18430                    meta: None,
18431                },
18432                state.clone(),
18433                None,
18434                None,
18435            )
18436            .expect("a final-only static URI cannot hide a listed legacy template");
18437        assert_eq!(
18438            serde_json::to_value(legacy_read).expect("legacy result serializes")["contents"][0]["text"],
18439            "legacy-template"
18440        );
18441
18442        let mut final_router = Router::new();
18443        final_router
18444            .add_final_resource_with_behavior(
18445                ShadowTemplate {
18446                    label: "final-template",
18447                },
18448                crate::DuplicateBehavior::Replace,
18449            )
18450            .expect("final template registers");
18451        final_router
18452            .add_legacy_resource_with_behavior(
18453                NamedResource::new(uri),
18454                crate::DuplicateBehavior::Replace,
18455            )
18456            .expect("legacy-only static resource registers");
18457        let final_read = final_router
18458            .dispatch_stateless(
18459                &request_ctx,
18460                &JsonRpcRequest::new(
18461                    "resources/read",
18462                    Some(serde_json::json!({
18463                        "_meta": {
18464                            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18465                            "io.modelcontextprotocol/clientCapabilities": {},
18466                        },
18467                        "uri": uri,
18468                    })),
18469                    190_i64,
18470                ),
18471            )
18472            .expect("a legacy-only static URI cannot hide a listed final template");
18473        assert_eq!(final_read["contents"][0]["text"], "final-template");
18474    }
18475
18476    #[test]
18477    fn completion_handler_rejects_one_field_final_metadata_in_legacy_request() {
18478        let mut router = Router::new();
18479        router.add_completion_handler(EchoCompletion);
18480        let cx = Cx::for_testing();
18481        let state = SessionState::new();
18482        let request_ctx = request_context(&cx, 89, Budget::INFINITE, &state);
18483        let baseline = JsonRpcRequest::new(
18484            COMPLETION_COMPLETE,
18485            Some(serde_json::json!({
18486                "ref": {"type": "ref/prompt", "name": "deploy"},
18487                "argument": {"name": "environment", "value": "sta"},
18488            })),
18489            89_i64,
18490        );
18491        let mut planted = baseline.clone();
18492        planted
18493            .params
18494            .as_mut()
18495            .and_then(serde_json::Value::as_object_mut)
18496            .expect("completion parameters are an object")
18497            .insert(
18498                "_meta".to_string(),
18499                serde_json::json!({
18500                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18501                }),
18502            );
18503
18504        assert_eq!(baseline.method, planted.method);
18505        assert_eq!(baseline.id, planted.id);
18506        assert_eq!(
18507            baseline
18508                .params
18509                .as_ref()
18510                .and_then(|params| params.get("ref")),
18511            planted.params.as_ref().and_then(|params| params.get("ref")),
18512            "the final metadata object is the sole planted dimension"
18513        );
18514        let catalog_before = router.has_completion_handler();
18515        let planted_before = serde_json::to_vec(&planted).expect("planted request serializes");
18516
18517        let baseline_result = router
18518            .dispatch_legacy_completion(&request_ctx, &baseline)
18519            .expect("the baseline legacy completion request is accepted");
18520        let error = router
18521            .dispatch_legacy_completion(&request_ctx, &planted)
18522            .expect_err("only final metadata is refused in the exact legacy request");
18523        assert_eq!(error.code, McpErrorCode::InvalidParams);
18524        assert_eq!(
18525            serde_json::to_vec(&planted).expect("rejected request serializes"),
18526            planted_before,
18527            "cross-era rejection cannot mutate caller-owned completion parameters"
18528        );
18529        assert_eq!(
18530            router.has_completion_handler(),
18531            catalog_before,
18532            "cross-era rejection cannot alter the installed completion handler"
18533        );
18534        assert_eq!(
18535            router
18536                .dispatch_legacy_completion(&request_ctx, &baseline)
18537                .expect("the baseline remains accepted after the planted rejection"),
18538            baseline_result,
18539            "the one-field rejection cannot alter the accepted legacy completion result"
18540        );
18541    }
18542
18543    #[test]
18544    fn macro_tool_dispatches_exact_legacy_and_final_complete_results() {
18545        let _counter_guard = MACRO_DUAL_ERA_TOOL_LOCK
18546            .lock()
18547            .unwrap_or_else(std::sync::PoisonError::into_inner);
18548        let mut router = Router::new();
18549        MACRO_DUAL_ERA_TOOL_CALLS.store(0, Ordering::SeqCst);
18550        router
18551            .add_tool(MacroDualEraTool)
18552            .expect("macro tool registration succeeds");
18553        let cx = Cx::for_testing();
18554        let state = SessionState::new();
18555        let request_ctx = request_context(&cx, 91, Budget::INFINITE, &state);
18556
18557        let legacy = router
18558            .handle_tools_call(
18559                &request_ctx,
18560                CallToolParams {
18561                    name: "macro_dual_era_tool".to_string(),
18562                    arguments: Some(serde_json::json!({})),
18563                    meta: None,
18564                },
18565                state,
18566                None,
18567                None,
18568            )
18569            .expect("the legacy adapter still invokes the registered handler");
18570        let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
18571        assert!(
18572            legacy_wire.get("resultType").is_none(),
18573            "the exact legacy result shape remains unchanged"
18574        );
18575        assert_eq!(legacy_wire["content"][0]["text"], "macro final tool result");
18576
18577        let modern = router
18578            .dispatch_stateless(
18579                &request_ctx,
18580                &JsonRpcRequest::new(
18581                    "tools/call",
18582                    Some(serde_json::json!({
18583                        "_meta": {
18584                            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
18585                            "io.modelcontextprotocol/clientCapabilities": {},
18586                        },
18587                        "name": "macro_dual_era_tool",
18588                        "arguments": {},
18589                    })),
18590                    91_i64,
18591                ),
18592            )
18593            .expect("the modern router invokes the same installed handler");
18594
18595        assert_eq!(
18596            modern.get("resultType"),
18597            Some(&serde_json::json!("complete"))
18598        );
18599        assert_eq!(modern.get("content"), legacy_wire.get("content"));
18600        assert_eq!(modern.get("isError"), legacy_wire.get("isError"));
18601        assert!(modern.get("serverInfo").is_none());
18602        assert_eq!(
18603            modern["structuredContent"],
18604            serde_json::json!({"weather": "clear"})
18605        );
18606        assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 2);
18607    }
18608
18609    #[test]
18610    fn final_tools_call_input_schema_failure_is_bounded_tool_error_without_handler_call() {
18611        let final_calls = Arc::new(AtomicUsize::new(0));
18612        let legacy_calls = Arc::new(AtomicUsize::new(0));
18613        let mut router = Router::new();
18614        router
18615            .add_tool(SchemaBoundaryTool {
18616                final_calls: Arc::clone(&final_calls),
18617                legacy_calls: Arc::clone(&legacy_calls),
18618                output_matches_schema: true,
18619                output_is_error: false,
18620                output_has_unevaluated_property: false,
18621                invalid_final_input_schema: false,
18622                missing_final_input_object_type: false,
18623                invalid_final_output_schema: false,
18624            })
18625            .expect("schema-boundary tool registration succeeds");
18626        let cx = Cx::for_testing();
18627        let state = SessionState::new();
18628        let request_ctx = request_context(&cx, 150, Budget::INFINITE, &state);
18629
18630        let rejected = router
18631            .dispatch_stateless(
18632                &request_ctx,
18633                &final_tools_call_request(
18634                    "schema-boundary-tool",
18635                    serde_json::json!({"value": 7}),
18636                    150_i64,
18637                ),
18638            )
18639            .expect("registered final tool input failures are tool results");
18640        assert_eq!(rejected["resultType"], "complete");
18641        assert_eq!(rejected["isError"], true);
18642        assert_eq!(
18643            rejected["content"][0]["text"],
18644            "Tool arguments do not match the declared input schema."
18645        );
18646        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
18647        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
18648
18649        let accepted = router
18650            .dispatch_stateless(
18651                &request_ctx,
18652                &final_tools_call_request(
18653                    "schema-boundary-tool",
18654                    serde_json::json!({"value": "accepted"}),
18655                    151_i64,
18656                ),
18657            )
18658            .expect("changing only the input value to match the schema is accepted");
18659        assert_eq!(accepted["resultType"], "complete");
18660        assert!(accepted.get("isError").is_none());
18661        assert_eq!(
18662            accepted["structuredContent"],
18663            serde_json::json!({"accepted": true})
18664        );
18665        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18666        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
18667    }
18668
18669    #[test]
18670    fn final_unknown_tool_is_invalid_params_while_legacy_unknown_tool_is_unchanged() {
18671        let router = Router::new();
18672        let cx = Cx::for_testing();
18673        let state = SessionState::new();
18674        let request_ctx = request_context(&cx, 152, Budget::INFINITE, &state);
18675
18676        let modern_error = router
18677            .dispatch_stateless(
18678                &request_ctx,
18679                &final_tools_call_request("unknown-tool", serde_json::json!({}), 152_i64),
18680            )
18681            .expect_err("an unknown final tool is an invalid-params protocol error");
18682        assert_eq!(modern_error.code, McpErrorCode::InvalidParams);
18683
18684        let legacy_error = router
18685            .handle_tools_call(
18686                &request_ctx,
18687                CallToolParams {
18688                    name: "unknown-tool".to_owned(),
18689                    arguments: Some(serde_json::json!({})),
18690                    meta: None,
18691                },
18692                state,
18693                None,
18694                None,
18695            )
18696            .expect_err("the exact legacy unknown-tool result remains method-not-found");
18697        assert_eq!(legacy_error.code, McpErrorCode::MethodNotFound);
18698    }
18699
18700    #[test]
18701    fn final_tool_output_schema_is_checked_before_success_and_legacy_is_unchanged() {
18702        let final_calls = Arc::new(AtomicUsize::new(0));
18703        let legacy_calls = Arc::new(AtomicUsize::new(0));
18704        let mut router = Router::new();
18705        router
18706            .add_tool(SchemaBoundaryTool {
18707                final_calls: Arc::clone(&final_calls),
18708                legacy_calls: Arc::clone(&legacy_calls),
18709                output_matches_schema: true,
18710                output_is_error: false,
18711                output_has_unevaluated_property: false,
18712                invalid_final_input_schema: false,
18713                missing_final_input_object_type: false,
18714                invalid_final_output_schema: false,
18715            })
18716            .expect("schema-boundary tool registration succeeds");
18717        let cx = Cx::for_testing();
18718        let state = SessionState::new();
18719        let request_ctx = request_context(&cx, 153, Budget::INFINITE, &state);
18720
18721        let accepted = router
18722            .dispatch_stateless(
18723                &request_ctx,
18724                &final_tools_call_request(
18725                    "schema-boundary-tool",
18726                    serde_json::json!({"value": "accepted"}),
18727                    153_i64,
18728                ),
18729            )
18730            .expect("a complete result matching the declared output schema is emitted");
18731        assert_eq!(
18732            accepted["structuredContent"],
18733            serde_json::json!({"accepted": true})
18734        );
18735        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
18736
18737        let legacy = router
18738            .handle_tools_call(
18739                &request_ctx,
18740                CallToolParams {
18741                    name: "schema-boundary-tool".to_owned(),
18742                    arguments: Some(serde_json::json!({"value": "accepted"})),
18743                    meta: None,
18744                },
18745                state,
18746                None,
18747                None,
18748            )
18749            .expect("the legacy tool path does not apply final output-schema validation");
18750        assert!(!legacy.is_error);
18751        let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
18752        assert_eq!(
18753            legacy_wire["content"][0]["text"],
18754            "legacy schema-boundary result"
18755        );
18756        assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
18757
18758        let rejected_final_calls = Arc::new(AtomicUsize::new(0));
18759        let rejected_legacy_calls = Arc::new(AtomicUsize::new(0));
18760        let mut rejected_router = Router::new();
18761        rejected_router
18762            .add_tool(SchemaBoundaryTool {
18763                final_calls: Arc::clone(&rejected_final_calls),
18764                legacy_calls: Arc::clone(&rejected_legacy_calls),
18765                output_matches_schema: false,
18766                output_is_error: false,
18767                output_has_unevaluated_property: false,
18768                invalid_final_input_schema: false,
18769                missing_final_input_object_type: false,
18770                invalid_final_output_schema: false,
18771            })
18772            .expect("schema-boundary tool registration succeeds");
18773        let rejected = rejected_router
18774            .dispatch_stateless(
18775                &request_ctx,
18776                &final_tools_call_request(
18777                    "schema-boundary-tool",
18778                    serde_json::json!({"value": "accepted"}),
18779                    154_i64,
18780                ),
18781            )
18782            .expect_err("a complete result failing the declared output schema is not emitted");
18783        assert_eq!(rejected.code, McpErrorCode::InternalError);
18784        assert_eq!(
18785            rejected.message,
18786            "tool output does not match the declared output schema"
18787        );
18788        assert_eq!(rejected_final_calls.load(Ordering::SeqCst), 1);
18789        assert_eq!(rejected_legacy_calls.load(Ordering::SeqCst), 0);
18790    }
18791
18792    #[test]
18793    fn final_tool_output_schema_applies_to_complete_error_payloads() {
18794        let accepted_calls = Arc::new(AtomicUsize::new(0));
18795        let mut accepted_router = Router::new();
18796        accepted_router
18797            .add_tool(SchemaBoundaryTool {
18798                final_calls: Arc::clone(&accepted_calls),
18799                legacy_calls: Arc::new(AtomicUsize::new(0)),
18800                output_matches_schema: true,
18801                output_is_error: true,
18802                output_has_unevaluated_property: false,
18803                invalid_final_input_schema: false,
18804                missing_final_input_object_type: false,
18805                invalid_final_output_schema: false,
18806            })
18807            .expect("schema-boundary tool registration succeeds");
18808        let cx = Cx::for_testing();
18809        let state = SessionState::new();
18810        let request_ctx = request_context(&cx, 154, Budget::INFINITE, &state);
18811
18812        let accepted = accepted_router
18813            .dispatch_stateless(
18814                &request_ctx,
18815                &final_tools_call_request(
18816                    "schema-boundary-tool",
18817                    serde_json::json!({"value": "accepted"}),
18818                    154_i64,
18819                ),
18820            )
18821            .expect("a schema-conforming complete error payload is emitted");
18822        assert_eq!(accepted["resultType"], "complete");
18823        assert_eq!(accepted["isError"], true);
18824        assert_eq!(
18825            accepted["structuredContent"],
18826            serde_json::json!({"accepted": true})
18827        );
18828        assert_eq!(accepted_calls.load(Ordering::SeqCst), 1);
18829
18830        let rejected_calls = Arc::new(AtomicUsize::new(0));
18831        let mut rejected_router = Router::new();
18832        rejected_router
18833            .add_tool(SchemaBoundaryTool {
18834                final_calls: Arc::clone(&rejected_calls),
18835                legacy_calls: Arc::new(AtomicUsize::new(0)),
18836                output_matches_schema: false,
18837                output_is_error: true,
18838                output_has_unevaluated_property: false,
18839                invalid_final_input_schema: false,
18840                missing_final_input_object_type: false,
18841                invalid_final_output_schema: false,
18842            })
18843            .expect("schema-boundary tool registration succeeds");
18844
18845        let rejected = rejected_router
18846            .dispatch_stateless(
18847                &request_ctx,
18848                &final_tools_call_request(
18849                    "schema-boundary-tool",
18850                    serde_json::json!({"value": "accepted"}),
18851                    155_i64,
18852                ),
18853            )
18854            .expect_err("a nonconforming complete error payload is not emitted");
18855        assert_eq!(rejected.code, McpErrorCode::InternalError);
18856        assert_eq!(
18857            rejected.message,
18858            "tool output does not match the declared output schema"
18859        );
18860        assert_eq!(rejected_calls.load(Ordering::SeqCst), 1);
18861    }
18862
18863    #[test]
18864    fn final_tool_admitted_schemas_enforce_unevaluated_properties_on_shipped_path() {
18865        let input_final_calls = Arc::new(AtomicUsize::new(0));
18866        let input_legacy_calls = Arc::new(AtomicUsize::new(0));
18867        let mut input_router = Router::new();
18868        input_router
18869            .add_tool(SchemaBoundaryTool {
18870                final_calls: Arc::clone(&input_final_calls),
18871                legacy_calls: Arc::clone(&input_legacy_calls),
18872                output_matches_schema: true,
18873                output_is_error: false,
18874                output_has_unevaluated_property: false,
18875                invalid_final_input_schema: false,
18876                missing_final_input_object_type: false,
18877                invalid_final_output_schema: false,
18878            })
18879            .expect("schema-boundary tool registration succeeds");
18880        let cx = Cx::for_testing();
18881        let state = SessionState::new();
18882        let request_ctx = request_context(&cx, 155, Budget::INFINITE, &state);
18883
18884        let rejected_input = input_router
18885            .dispatch_stateless(
18886                &request_ctx,
18887                &final_tools_call_request(
18888                    "schema-boundary-tool",
18889                    serde_json::json!({"value": "accepted", "unexpected": true}),
18890                    155_i64,
18891                ),
18892            )
18893            .expect("unevaluated final input properties return the bounded tool error result");
18894        assert_eq!(rejected_input["resultType"], "complete");
18895        assert_eq!(rejected_input["isError"], true);
18896        assert_eq!(input_final_calls.load(Ordering::SeqCst), 0);
18897        assert_eq!(input_legacy_calls.load(Ordering::SeqCst), 0);
18898
18899        let accepted_input = input_router
18900            .dispatch_stateless(
18901                &request_ctx,
18902                &final_tools_call_request(
18903                    "schema-boundary-tool",
18904                    serde_json::json!({"value": "accepted"}),
18905                    156_i64,
18906                ),
18907            )
18908            .expect("removing only the unevaluated input property reaches the handler");
18909        assert_eq!(accepted_input["resultType"], "complete");
18910        assert_eq!(input_final_calls.load(Ordering::SeqCst), 1);
18911
18912        let output_final_calls = Arc::new(AtomicUsize::new(0));
18913        let output_legacy_calls = Arc::new(AtomicUsize::new(0));
18914        let mut output_router = Router::new();
18915        output_router
18916            .add_tool(SchemaBoundaryTool {
18917                final_calls: Arc::clone(&output_final_calls),
18918                legacy_calls: Arc::clone(&output_legacy_calls),
18919                output_matches_schema: true,
18920                output_is_error: false,
18921                output_has_unevaluated_property: true,
18922                invalid_final_input_schema: false,
18923                missing_final_input_object_type: false,
18924                invalid_final_output_schema: false,
18925            })
18926            .expect("schema-boundary tool registration succeeds");
18927
18928        let rejected_output = output_router
18929            .dispatch_stateless(
18930                &request_ctx,
18931                &final_tools_call_request(
18932                    "schema-boundary-tool",
18933                    serde_json::json!({"value": "accepted"}),
18934                    157_i64,
18935                ),
18936            )
18937            .expect_err("an unevaluated final output property is not emitted as success");
18938        assert_eq!(rejected_output.code, McpErrorCode::InternalError);
18939        assert_eq!(
18940            rejected_output.message,
18941            "tool output does not match the declared output schema"
18942        );
18943        assert_eq!(output_final_calls.load(Ordering::SeqCst), 1);
18944        assert_eq!(output_legacy_calls.load(Ordering::SeqCst), 0);
18945    }
18946
18947    #[test]
18948    fn valid_final_tool_replace_updates_both_catalogs_without_reordering() {
18949        let original_legacy_calls = Arc::new(AtomicUsize::new(0));
18950        let original_final_calls = Arc::new(AtomicUsize::new(0));
18951        let replacement_legacy_calls = Arc::new(AtomicUsize::new(0));
18952        let replacement_final_calls = Arc::new(AtomicUsize::new(0));
18953        let mut router = Router::new();
18954        router
18955            .add_tool(NamedTool::new("before"))
18956            .expect("tool registration succeeds");
18957        router
18958            .add_tool(AdmittedSchemaReplacementTool {
18959                legacy_calls: Arc::clone(&original_legacy_calls),
18960                final_calls: Arc::clone(&original_final_calls),
18961                legacy_label: "original",
18962                output_schema: serde_json::json!({"type": "string"}),
18963                structured_content: Some(serde_json::json!("original")),
18964            })
18965            .expect("original tool registration succeeds");
18966        router
18967            .add_tool(NamedTool::new("after"))
18968            .expect("tool registration succeeds");
18969        let order_before = router
18970            .tools()
18971            .into_iter()
18972            .map(|tool| tool.name)
18973            .collect::<Vec<_>>();
18974
18975        router
18976            .add_tool_with_behavior(
18977                AdmittedSchemaReplacementTool {
18978                    legacy_calls: Arc::clone(&replacement_legacy_calls),
18979                    final_calls: Arc::clone(&replacement_final_calls),
18980                    legacy_label: "replacement",
18981                    output_schema: serde_json::json!({"type": "boolean"}),
18982                    structured_content: Some(serde_json::json!(true)),
18983                },
18984                crate::DuplicateBehavior::Replace,
18985            )
18986            .expect("a fully admitted replacement commits both catalog views");
18987        assert_eq!(
18988            router
18989                .tools()
18990                .into_iter()
18991                .map(|tool| tool.name)
18992                .collect::<Vec<_>>(),
18993            order_before,
18994            "replacement retains the original registration position"
18995        );
18996
18997        let cx = Cx::for_testing();
18998        let state = SessionState::new();
18999        let request_ctx = request_context(&cx, 158, Budget::INFINITE, &state);
19000        let legacy = router
19001            .handle_tools_call(
19002                &request_ctx,
19003                CallToolParams {
19004                    name: "admitted-schema-replacement-tool".to_owned(),
19005                    arguments: Some(serde_json::json!({})),
19006                    meta: None,
19007                },
19008                state,
19009                None,
19010                None,
19011            )
19012            .expect("the replacement is installed for legacy dispatch");
19013        let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
19014        assert_eq!(legacy_wire["content"][0]["text"], "replacement");
19015        assert_eq!(replacement_legacy_calls.load(Ordering::SeqCst), 1);
19016        assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 0);
19017
19018        let modern = router
19019            .dispatch_stateless(
19020                &request_ctx,
19021                &final_tools_call_request(
19022                    "admitted-schema-replacement-tool",
19023                    serde_json::json!({}),
19024                    159_i64,
19025                ),
19026            )
19027            .expect("the replacement is installed for modern dispatch");
19028        assert_eq!(modern["structuredContent"], serde_json::json!(true));
19029        assert_eq!(replacement_final_calls.load(Ordering::SeqCst), 1);
19030        assert_eq!(original_final_calls.load(Ordering::SeqCst), 0);
19031
19032        let modern_catalog = router
19033            .dispatch_stateless(
19034                &request_ctx,
19035                &final_tools_list_request(None, None, None, 160_i64),
19036            )
19037            .expect("the admitted replacement remains visible to the modern catalog");
19038        assert_eq!(
19039            modern_catalog["tools"]
19040                .as_array()
19041                .expect("modern tools remain an array")
19042                .iter()
19043                .map(|tool| tool["name"].as_str().expect("tool name is a string"))
19044                .collect::<Vec<_>>(),
19045            vec!["before", "admitted-schema-replacement-tool", "after"]
19046        );
19047        assert_eq!(
19048            modern_catalog["tools"][1]["outputSchema"]["type"],
19049            "boolean"
19050        );
19051    }
19052
19053    #[test]
19054    fn invalid_final_schema_new_tool_leaves_both_catalogs_unchanged() {
19055        let mut router = Router::new();
19056        router
19057            .add_tool(NamedTool::new("existing"))
19058            .expect("baseline tool registration succeeds");
19059        let cx = Cx::for_testing();
19060        let state = SessionState::new();
19061        let request_ctx = request_context(&cx, 161, Budget::INFINITE, &state);
19062        let legacy_before =
19063            serde_json::to_value(router.tools()).expect("legacy catalog serializes");
19064        let modern_before = router
19065            .dispatch_stateless(
19066                &request_ctx,
19067                &final_tools_list_request(None, None, None, 161_i64),
19068            )
19069            .expect("baseline modern catalog is available");
19070
19071        let error = router
19072            .add_tool_with_behavior(
19073                InvalidFinalSchemaNamedTool::with_tags("new-invalid", Vec::new()),
19074                crate::DuplicateBehavior::Error,
19075            )
19076            .expect_err("a new normal tool with a scalar outputSchema is rejected");
19077        assert_eq!(error.code, McpErrorCode::InternalError);
19078        assert_eq!(
19079            serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
19080            legacy_before,
19081            "failed admission cannot add a legacy-only entry"
19082        );
19083        assert_eq!(
19084            router
19085                .dispatch_stateless(
19086                    &request_ctx,
19087                    &final_tools_list_request(None, None, None, 162_i64),
19088                )
19089                .expect("modern catalog remains available"),
19090            modern_before,
19091            "failed admission cannot alter the modern catalog"
19092        );
19093        assert!(router.get_tool("new-invalid").is_none());
19094    }
19095
19096    #[test]
19097    fn only_tokenized_upstream_schema_registration_bypasses_local_validation() {
19098        let cx = Cx::for_testing();
19099        let state = SessionState::new();
19100        let request_ctx = request_context(&cx, 1601, Budget::INFINITE, &state);
19101        let mut registered_proxy_router = Router::new();
19102        registered_proxy_router
19103            .add_tool(UpstreamScalarSchemaTool {
19104                registered_proxy: true,
19105            })
19106            .expect("an upstream-owned scalar schema is retained without local admission");
19107
19108        let response = registered_proxy_router
19109            .dispatch_stateless(
19110                &request_ctx,
19111                &final_tools_call_request(
19112                    "upstream-scalar-schema-tool",
19113                    serde_json::json!({}),
19114                    1601_i64,
19115                ),
19116            )
19117            .expect("upstream-owned structured content is not locally revalidated");
19118        assert_eq!(
19119            response["structuredContent"],
19120            serde_json::json!({"upstream": true})
19121        );
19122
19123        let mut forged_router = Router::new();
19124        let error = forged_router
19125            .add_tool(UpstreamScalarSchemaTool {
19126                registered_proxy: false,
19127            })
19128            .expect_err("a forgeable authority label cannot bypass local schema admission");
19129        assert_eq!(error.code, McpErrorCode::InternalError);
19130        assert!(
19131            forged_router
19132                .get_tool("upstream-scalar-schema-tool")
19133                .is_none()
19134        );
19135    }
19136
19137    #[test]
19138    fn tokenized_upstream_schema_still_honors_strict_input_validation() {
19139        let cx = Cx::for_testing();
19140        let state = SessionState::new();
19141        let request_ctx = request_context(&cx, 1602, Budget::INFINITE, &state);
19142        let mut router = Router::new();
19143        router
19144            .add_tool(UpstreamScalarSchemaTool {
19145                registered_proxy: true,
19146            })
19147            .expect("an upstream-owned object schema is retained without local admission");
19148        router.set_strict_input_validation(true);
19149
19150        let refused = router
19151            .dispatch_stateless(
19152                &request_ctx,
19153                &final_tools_call_request(
19154                    "upstream-scalar-schema-tool",
19155                    serde_json::json!({"extra": 1}),
19156                    1602_i64,
19157                ),
19158            )
19159            .expect("gateway strict mode returns a complete tools/call result");
19160        assert_eq!(refused["resultType"], "complete");
19161        assert_eq!(refused["isError"], true);
19162        assert_eq!(
19163            refused["content"][0]["text"],
19164            "Tool arguments do not match the declared input schema."
19165        );
19166
19167        let admitted = router
19168            .dispatch_stateless(
19169                &request_ctx,
19170                &final_tools_call_request(
19171                    "upstream-scalar-schema-tool",
19172                    serde_json::json!({}),
19173                    1603_i64,
19174                ),
19175            )
19176            .expect("declared empty object arguments still reach the proxy handler");
19177        assert_eq!(
19178            admitted["structuredContent"],
19179            serde_json::json!({"upstream": true})
19180        );
19181
19182        router.set_strict_input_validation(false);
19183        let extra = router
19184            .dispatch_stateless(
19185                &request_ctx,
19186                &final_tools_call_request(
19187                    "upstream-scalar-schema-tool",
19188                    serde_json::json!({"extra": 1}),
19189                    1604_i64,
19190                ),
19191            )
19192            .expect("changing only the strict flag must admit the extra property");
19193        assert_eq!(
19194            extra["structuredContent"],
19195            serde_json::json!({"upstream": true})
19196        );
19197    }
19198
19199    #[test]
19200    fn normal_registration_rejects_missing_input_schema_type_without_catalog_mutation() {
19201        let mut router = Router::new();
19202        router
19203            .add_tool(NamedTool::new("existing"))
19204            .expect("baseline tool registration succeeds");
19205        let legacy_before =
19206            serde_json::to_value(router.tools()).expect("legacy catalog serializes");
19207
19208        let error = router
19209            .add_tool_with_behavior(
19210                SchemaBoundaryTool {
19211                    final_calls: Arc::new(AtomicUsize::new(0)),
19212                    legacy_calls: Arc::new(AtomicUsize::new(0)),
19213                    output_matches_schema: true,
19214                    output_is_error: false,
19215                    output_has_unevaluated_property: false,
19216                    invalid_final_input_schema: false,
19217                    missing_final_input_object_type: true,
19218                    invalid_final_output_schema: false,
19219                },
19220                crate::DuplicateBehavior::Error,
19221            )
19222            .expect_err("a normal inputSchema must declare type object");
19223        assert_eq!(error.code, McpErrorCode::InternalError);
19224        assert_eq!(
19225            serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
19226            legacy_before
19227        );
19228        assert!(router.get_tool("schema-boundary-tool").is_none());
19229    }
19230
19231    #[test]
19232    fn final_tool_scalar_and_null_structured_content_are_present_and_validated() {
19233        let cx = Cx::for_testing();
19234        let state = SessionState::new();
19235        let request_ctx = request_context(&cx, 163, Budget::INFINITE, &state);
19236
19237        let mut scalar_router = Router::new();
19238        scalar_router
19239            .add_tool(AdmittedSchemaReplacementTool {
19240                legacy_calls: Arc::new(AtomicUsize::new(0)),
19241                final_calls: Arc::new(AtomicUsize::new(0)),
19242                legacy_label: "scalar",
19243                output_schema: serde_json::json!({"type": "string"}),
19244                structured_content: Some(serde_json::json!("")),
19245            })
19246            .expect("scalar-output tool registration succeeds");
19247        let scalar = scalar_router
19248            .dispatch_stateless(
19249                &request_ctx,
19250                &final_tools_call_request(
19251                    "admitted-schema-replacement-tool",
19252                    serde_json::json!({}),
19253                    163_i64,
19254                ),
19255            )
19256            .expect("an object-valued schema document may describe a scalar result");
19257        assert_eq!(
19258            scalar.get("structuredContent"),
19259            Some(&serde_json::json!("")),
19260            "an empty string is present structured content, not an omitted value"
19261        );
19262
19263        let mut null_router = Router::new();
19264        null_router
19265            .add_tool(AdmittedSchemaReplacementTool {
19266                legacy_calls: Arc::new(AtomicUsize::new(0)),
19267                final_calls: Arc::new(AtomicUsize::new(0)),
19268                legacy_label: "null",
19269                output_schema: serde_json::json!({"type": "null"}),
19270                structured_content: Some(serde_json::Value::Null),
19271            })
19272            .expect("null-output tool registration succeeds");
19273        let null = null_router
19274            .dispatch_stateless(
19275                &request_ctx,
19276                &final_tools_call_request(
19277                    "admitted-schema-replacement-tool",
19278                    serde_json::json!({}),
19279                    164_i64,
19280                ),
19281            )
19282            .expect("present JSON null is validated against a null output schema");
19283        assert_eq!(
19284            null.get("structuredContent"),
19285            Some(&serde_json::Value::Null),
19286            "explicit JSON null remains present on the server-emission path"
19287        );
19288    }
19289
19290    #[test]
19291    fn final_tool_declared_output_schema_rejects_absent_structured_content() {
19292        let mut router = Router::new();
19293        router
19294            .add_tool(AdmittedSchemaReplacementTool {
19295                legacy_calls: Arc::new(AtomicUsize::new(0)),
19296                final_calls: Arc::new(AtomicUsize::new(0)),
19297                legacy_label: "missing",
19298                output_schema: serde_json::json!({"type": "string"}),
19299                structured_content: None,
19300            })
19301            .expect("mapped tool registration succeeds");
19302        let cx = Cx::for_testing();
19303        let state = SessionState::new();
19304        let request_ctx = request_context(&cx, 165, Budget::INFINITE, &state);
19305
19306        let error = router
19307            .dispatch_stateless(
19308                &request_ctx,
19309                &final_tools_call_request(
19310                    "admitted-schema-replacement-tool",
19311                    serde_json::json!({}),
19312                    165_i64,
19313                ),
19314            )
19315            .expect_err("a declared output schema requires structured content on complete output");
19316        assert_eq!(error.code, McpErrorCode::InternalError);
19317        assert_eq!(
19318            error.message,
19319            "tool output is missing structuredContent required by the declared output schema"
19320        );
19321    }
19322
19323    #[test]
19324    fn final_tool_error_mapper_covers_input_validation_and_handler_errors() {
19325        let calls = Arc::new(AtomicUsize::new(0));
19326        let mut router = Router::new();
19327        router
19328            .add_tool(ErrorMappedTool {
19329                mode: ErrorMapperMode::Complete,
19330                calls: Arc::clone(&calls),
19331            })
19332            .expect("both bounded mapper branches satisfy outputSchema");
19333        let cx = Cx::for_testing();
19334        let state = SessionState::new();
19335        let request_ctx = request_context(&cx, 169, Budget::INFINITE, &state);
19336
19337        let input_error = router
19338            .dispatch_stateless(
19339                &request_ctx,
19340                &final_tools_call_request(
19341                    "error-mapped-tool",
19342                    serde_json::json!({"value": 7}),
19343                    169_i64,
19344                ),
19345            )
19346            .expect("input rejection is a schema-valid complete tool error");
19347        assert_eq!(input_error["isError"], true);
19348        assert_eq!(
19349            input_error["structuredContent"],
19350            serde_json::json!({"error": "input-validation"})
19351        );
19352        assert_eq!(calls.load(Ordering::SeqCst), 0);
19353
19354        let handler_error = router
19355            .dispatch_stateless(
19356                &request_ctx,
19357                &final_tools_call_request(
19358                    "error-mapped-tool",
19359                    serde_json::json!({"value": "accepted"}),
19360                    170_i64,
19361                ),
19362            )
19363            .expect("handler rejection is a schema-valid complete tool error");
19364        assert_eq!(handler_error["isError"], true);
19365        assert_eq!(
19366            handler_error["structuredContent"],
19367            serde_json::json!({"error": "handler"})
19368        );
19369        assert_eq!(calls.load(Ordering::SeqCst), 1);
19370    }
19371
19372    #[test]
19373    fn incomplete_invalid_or_oversized_tool_error_mapper_is_rejected_atomically() {
19374        for (mode, expected_message) in [
19375            (
19376                ErrorMapperMode::MissingHandler,
19377                "tool declares outputSchema without a complete tool-error structured-content mapper",
19378            ),
19379            (
19380                ErrorMapperMode::InvalidHandler,
19381                "tool error structured-content mapper does not satisfy outputSchema",
19382            ),
19383            (
19384                ErrorMapperMode::OversizedHandler,
19385                "tool error structured-content mapper exceeded the registration limit",
19386            ),
19387        ] {
19388            let calls = Arc::new(AtomicUsize::new(0));
19389            let mut router = Router::new();
19390            router
19391                .add_tool(NamedTool::new("existing"))
19392                .expect("baseline tool admission succeeds");
19393            let cx = Cx::for_testing();
19394            let state = SessionState::new();
19395            let request_ctx = request_context(&cx, 171, Budget::INFINITE, &state);
19396            let legacy_before =
19397                serde_json::to_value(router.tools()).expect("legacy catalog serializes");
19398            let modern_before = router
19399                .dispatch_stateless(
19400                    &request_ctx,
19401                    &final_tools_list_request(None, None, None, 171_i64),
19402                )
19403                .expect("modern catalog is available");
19404
19405            let error = router
19406                .add_tool(ErrorMappedTool {
19407                    mode,
19408                    calls: Arc::clone(&calls),
19409                })
19410                .expect_err("public add_tool exposes mapper admission failure");
19411            assert_eq!(error.code, McpErrorCode::InternalError);
19412            assert_eq!(error.message, expected_message);
19413            assert_eq!(
19414                serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
19415                legacy_before
19416            );
19417            assert_eq!(
19418                router
19419                    .dispatch_stateless(
19420                        &request_ctx,
19421                        &final_tools_list_request(None, None, None, 172_i64),
19422                    )
19423                    .expect("modern catalog remains available"),
19424                modern_before
19425            );
19426            assert!(router.get_tool("error-mapped-tool").is_none());
19427            assert_eq!(calls.load(Ordering::SeqCst), 0);
19428        }
19429    }
19430
19431    #[test]
19432    fn normal_registration_rejects_null_output_schema_without_catalog_mutation() {
19433        let mut router = Router::new();
19434        let error = router
19435            .add_tool_with_behavior(
19436                AdmittedSchemaReplacementTool {
19437                    legacy_calls: Arc::new(AtomicUsize::new(0)),
19438                    final_calls: Arc::new(AtomicUsize::new(0)),
19439                    legacy_label: "null-schema",
19440                    output_schema: serde_json::Value::Null,
19441                    structured_content: None,
19442                },
19443                crate::DuplicateBehavior::Error,
19444            )
19445            .expect_err("the outputSchema wire field itself must be an object");
19446        assert_eq!(error.code, McpErrorCode::InternalError);
19447        assert!(router.tools().is_empty());
19448        assert!(
19449            router
19450                .get_tool("admitted-schema-replacement-tool")
19451                .is_none()
19452        );
19453    }
19454
19455    #[test]
19456    fn invalid_final_schema_replace_leaves_handlers_and_catalogs_unchanged() {
19457        let original_legacy_calls = Arc::new(AtomicUsize::new(0));
19458        let original_final_calls = Arc::new(AtomicUsize::new(0));
19459        let mut router = Router::new();
19460        router
19461            .add_tool(AdmittedSchemaReplacementTool {
19462                legacy_calls: Arc::clone(&original_legacy_calls),
19463                final_calls: Arc::clone(&original_final_calls),
19464                legacy_label: "original",
19465                output_schema: serde_json::json!({"type": "string"}),
19466                structured_content: Some(serde_json::json!("original")),
19467            })
19468            .expect("original tool registration succeeds");
19469        let cx = Cx::for_testing();
19470        let state = SessionState::new();
19471        let request_ctx = request_context(&cx, 166, Budget::INFINITE, &state);
19472        let legacy_before =
19473            serde_json::to_value(router.tools()).expect("legacy catalog serializes");
19474        let modern_before = router
19475            .dispatch_stateless(
19476                &request_ctx,
19477                &final_tools_list_request(None, None, None, 166_i64),
19478            )
19479            .expect("baseline modern catalog is available");
19480
19481        let error = router
19482            .add_tool_with_behavior(
19483                InvalidFinalSchemaNamedTool::with_tags(
19484                    "admitted-schema-replacement-tool",
19485                    Vec::new(),
19486                ),
19487                crate::DuplicateBehavior::Replace,
19488            )
19489            .expect_err("changing only the candidate outputSchema rejects replacement");
19490        assert_eq!(error.code, McpErrorCode::InternalError);
19491        assert_eq!(
19492            serde_json::to_value(router.tools()).expect("legacy catalog serializes"),
19493            legacy_before,
19494            "a rejected replacement cannot replace the legacy handler"
19495        );
19496        assert_eq!(
19497            router
19498                .dispatch_stateless(
19499                    &request_ctx,
19500                    &final_tools_list_request(None, None, None, 167_i64),
19501                )
19502                .expect("modern catalog remains available"),
19503            modern_before,
19504            "a rejected replacement cannot remove the admitted modern entry"
19505        );
19506
19507        let legacy = router
19508            .handle_tools_call(
19509                &request_ctx,
19510                CallToolParams {
19511                    name: "admitted-schema-replacement-tool".to_owned(),
19512                    arguments: Some(serde_json::json!({})),
19513                    meta: None,
19514                },
19515                state,
19516                None,
19517                None,
19518            )
19519            .expect("the original legacy handler remains installed");
19520        let legacy_wire = serde_json::to_value(&legacy).expect("legacy result serializes");
19521        assert_eq!(legacy_wire["content"][0]["text"], "original");
19522        assert_eq!(original_legacy_calls.load(Ordering::SeqCst), 1);
19523
19524        let modern = router
19525            .dispatch_stateless(
19526                &request_ctx,
19527                &final_tools_call_request(
19528                    "admitted-schema-replacement-tool",
19529                    serde_json::json!({}),
19530                    168_i64,
19531                ),
19532            )
19533            .expect("the original modern handler remains installed");
19534        assert_eq!(modern["structuredContent"], serde_json::json!("original"));
19535        assert_eq!(original_final_calls.load(Ordering::SeqCst), 1);
19536    }
19537
19538    #[test]
19539    fn final_tool_and_prompt_argument_nulls_are_rejected_without_erasing_absence() {
19540        let tool_final_calls = Arc::new(AtomicUsize::new(0));
19541        let prompt_final_calls = Arc::new(AtomicUsize::new(0));
19542        let mut router = Router::new();
19543        router
19544            .add_tool(InputRequiredTool {
19545                legacy_calls: Arc::new(AtomicUsize::new(0)),
19546                final_calls: Arc::clone(&tool_final_calls),
19547            })
19548            .expect("tool registration succeeds");
19549        router.add_prompt(DirectFinalPrompt {
19550            final_calls: Arc::clone(&prompt_final_calls),
19551        });
19552        let cx = Cx::for_testing();
19553        let connection = ModernConnection::new();
19554        let inbound = InboundRequestContext::with_modern_connection(
19555            cx,
19556            160,
19557            InboundRequestTransport::Memory,
19558            &connection,
19559        );
19560        let request_ctx = inbound.request_context();
19561
19562        let absent_tool_arguments = JsonRpcRequest::new(
19563            "tools/call",
19564            Some(serde_json::json!({
19565                "_meta": {
19566                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19567                    "io.modelcontextprotocol/clientCapabilities": {},
19568                },
19569                "name": "input-required-tool",
19570            })),
19571            160_i64,
19572        );
19573        let mut null_tool_arguments = absent_tool_arguments.clone();
19574        null_tool_arguments
19575            .params
19576            .as_mut()
19577            .and_then(serde_json::Value::as_object_mut)
19578            .expect("final tool parameters are an object")
19579            .insert("arguments".to_owned(), serde_json::Value::Null);
19580
19581        let CoreRequest::Final(FinalCoreRequest::ToolsCall(absent_tool_params)) =
19582            CoreRequest::decode(
19583                ProtocolEra::Modern2026,
19584                "tools/call",
19585                absent_tool_arguments.params.as_ref(),
19586            )
19587            .expect("absent final tool arguments decode")
19588        else {
19589            panic!("the final tool parameter shape is selected");
19590        };
19591        assert!(absent_tool_params.arguments.is_absent());
19592        // Final arguments deserialization now rejects an explicit null outright,
19593        // so a null `arguments` member fails at decode rather than reaching the
19594        // handler as an explicit-null presence marker.
19595        assert!(
19596            CoreRequest::decode(
19597                ProtocolEra::Modern2026,
19598                "tools/call",
19599                null_tool_arguments.params.as_ref(),
19600            )
19601            .is_err(),
19602            "explicit-null final tool arguments are rejected at decode"
19603        );
19604
19605        let absent_tool_result = router
19606            .dispatch_stateless(&request_ctx, &absent_tool_arguments)
19607            .expect("absent final tool arguments default to an empty object");
19608        assert_eq!(absent_tool_result["resultType"], "input_required");
19609        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19610        let null_tool_error = router
19611            .dispatch_stateless(&request_ctx, &null_tool_arguments)
19612            .expect_err("explicit-null final tool arguments are rejected");
19613        assert_eq!(null_tool_error.code, McpErrorCode::InvalidParams);
19614        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
19615
19616        let absent_prompt_arguments = direct_final_prompt_request(161_i64);
19617        let mut null_prompt_arguments = absent_prompt_arguments.clone();
19618        null_prompt_arguments
19619            .params
19620            .as_mut()
19621            .and_then(serde_json::Value::as_object_mut)
19622            .expect("final prompt parameters are an object")
19623            .insert("arguments".to_owned(), serde_json::Value::Null);
19624
19625        let CoreRequest::Final(FinalCoreRequest::PromptsGet(absent_prompt_params)) =
19626            CoreRequest::decode(
19627                ProtocolEra::Modern2026,
19628                "prompts/get",
19629                absent_prompt_arguments.params.as_ref(),
19630            )
19631            .expect("absent final prompt arguments decode")
19632        else {
19633            panic!("the final prompt parameter shape is selected");
19634        };
19635        assert!(absent_prompt_params.arguments.is_absent());
19636        // As with tools/call, a null `arguments` member is rejected at decode.
19637        assert!(
19638            CoreRequest::decode(
19639                ProtocolEra::Modern2026,
19640                "prompts/get",
19641                null_prompt_arguments.params.as_ref(),
19642            )
19643            .is_err(),
19644            "explicit-null final prompt arguments are rejected at decode"
19645        );
19646
19647        let absent_prompt_result = router
19648            .dispatch_stateless(&request_ctx, &absent_prompt_arguments)
19649            .expect("absent final prompt arguments default to an empty map");
19650        assert_eq!(absent_prompt_result["resultType"], "complete");
19651        assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
19652        let null_prompt_error = router
19653            .dispatch_stateless(&request_ctx, &null_prompt_arguments)
19654            .expect_err("explicit-null final prompt arguments are rejected");
19655        assert_eq!(null_prompt_error.code, McpErrorCode::InvalidParams);
19656        assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
19657    }
19658
19659    #[cfg(feature = "tasks")]
19660    #[test]
19661    fn final_task_capable_tool_creates_work_bound_task_after_capability_and_service_admission() {
19662        let final_calls = Arc::new(AtomicUsize::new(0));
19663        let store = Arc::new(InMemoryFinalTaskStore::default());
19664        let runtime = task_runtime_for_router(Arc::clone(&store));
19665        let service_runner = runtime
19666            .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
19667            .expect("a bounded application-owned task service is installed");
19668        let service_cx = Cx::for_testing();
19669        let mut running_service = Box::pin(service_runner.run(&service_cx));
19670        let mut task_cx = std::task::Context::from_waker(std::task::Waker::noop());
19671        assert!(matches!(
19672            Future::poll(running_service.as_mut(), &mut task_cx),
19673            Poll::Pending
19674        ));
19675        let mut router = Router::new();
19676        router.set_final_task_runtime(Some(runtime));
19677        router
19678            .add_tool(TaskCapableRouterTool {
19679                final_calls: Arc::clone(&final_calls),
19680            })
19681            .expect("tool registration succeeds");
19682        let cx = Cx::for_testing();
19683        let state = SessionState::new();
19684        let request_ctx = request_context(&cx, 160, Budget::INFINITE, &state);
19685
19686        let result = router
19687            .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(160_i64))
19688            .expect("the admitted task-capable outcome creates a work-bound task");
19689        assert_eq!(result["resultType"], "task");
19690        assert_eq!(result["status"], "working");
19691        assert_eq!(result["statusMessage"], "router task created");
19692        assert!(
19693            result["taskId"].as_str().is_some(),
19694            "the created task has a final task identifier"
19695        );
19696        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19697        assert_eq!(store.task_count(), 1);
19698    }
19699
19700    #[cfg(feature = "tasks")]
19701    #[test]
19702    fn final_task_declaration_gates_only_the_create_task_outcome() {
19703        let final_calls = Arc::new(AtomicUsize::new(0));
19704        let store = Arc::new(InMemoryFinalTaskStore::default());
19705        let mut router = Router::new();
19706        router
19707            .add_tool(ConditionalTaskCapableRouterTool {
19708                final_calls: Arc::clone(&final_calls),
19709            })
19710            .expect("conditional task-capable tool registration succeeds");
19711        let cx = Cx::for_testing();
19712        let state = SessionState::new();
19713        let request_ctx = request_context(&cx, 164, Budget::INFINITE, &state);
19714
19715        let complete = final_tools_call_request(
19716            "conditional-task-capable-router-tool",
19717            serde_json::json!({ "createTask": false }),
19718            164_i64,
19719        );
19720        let task = final_tools_call_request(
19721            "conditional-task-capable-router-tool",
19722            serde_json::json!({ "createTask": true }),
19723            165_i64,
19724        );
19725        assert_eq!(complete.method, task.method);
19726        assert_eq!(
19727            complete
19728                .params
19729                .as_ref()
19730                .and_then(|params| params.get("_meta")),
19731            task.params.as_ref().and_then(|params| params.get("_meta")),
19732            "Tasks negotiation is unchanged between the paired requests"
19733        );
19734
19735        let result = router
19736            .dispatch_stateless(&request_ctx, &complete)
19737            .expect("a declared task-capable handler may complete without Tasks negotiation");
19738        assert_eq!(result["resultType"], "complete");
19739        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19740        assert_eq!(store.task_count(), 0);
19741
19742        let error = router
19743            .dispatch_stateless(&request_ctx, &task)
19744            .expect_err("only the CreateTask outcome requires Tasks negotiation");
19745        assert!(matches!(error.code, McpErrorCode::Custom(_)));
19746        assert_eq!(final_calls.load(Ordering::SeqCst), 2);
19747        assert_eq!(
19748            store.task_count(),
19749            0,
19750            "a rejected CreateTask outcome must not mutate the task store"
19751        );
19752    }
19753
19754    #[cfg(feature = "tasks")]
19755    #[test]
19756    fn final_task_outcome_without_runtime_rejects_after_handler_before_store_mutation() {
19757        let final_calls = Arc::new(AtomicUsize::new(0));
19758        let store = Arc::new(InMemoryFinalTaskStore::default());
19759        let mut router = Router::new();
19760        router
19761            .add_tool(TaskCapableRouterTool {
19762                final_calls: Arc::clone(&final_calls),
19763            })
19764            .expect("tool registration succeeds");
19765        let cx = Cx::for_testing();
19766        let state = SessionState::new();
19767        let request_ctx = request_context(&cx, 161, Budget::INFINITE, &state);
19768
19769        let error = router
19770            .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(161_i64))
19771            .expect_err("a CreateTask outcome cannot persist without a final Tasks runtime");
19772        assert_eq!(error.code, McpErrorCode::InternalError);
19773        assert_eq!(
19774            error.message,
19775            "task-capable tool requires an installed final Tasks runtime"
19776        );
19777        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19778        assert_eq!(
19779            store.task_count(),
19780            0,
19781            "outcome-aware runtime admission rejects before task-store mutation"
19782        );
19783    }
19784
19785    #[cfg(feature = "tasks")]
19786    #[test]
19787    fn final_task_outcome_with_unready_service_rejects_after_handler_before_store_mutation() {
19788        let final_calls = Arc::new(AtomicUsize::new(0));
19789        let store = Arc::new(InMemoryFinalTaskStore::default());
19790        let runtime = task_runtime_for_router(Arc::clone(&store));
19791        let _unready_service = runtime
19792            .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
19793            .expect("an installed but unpolled task service remains unready");
19794        let mut router = Router::new();
19795        router.set_final_task_runtime(Some(runtime));
19796        router
19797            .add_tool(TaskCapableRouterTool {
19798                final_calls: Arc::clone(&final_calls),
19799            })
19800            .expect("tool registration succeeds");
19801        let cx = Cx::for_testing();
19802        let state = SessionState::new();
19803        let request_ctx = request_context(&cx, 162, Budget::INFINITE, &state);
19804
19805        let error = router
19806            .dispatch_stateless(&request_ctx, &final_task_capable_tool_request(162_i64))
19807            .expect_err("an installed but unready task service is refused before task creation");
19808        assert_eq!(error.code, McpErrorCode::InvalidParams);
19809        assert_eq!(
19810            error.message,
19811            "Final task creation requires an installed ready task service"
19812        );
19813        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19814        assert_eq!(
19815            store.task_count(),
19816            0,
19817            "outcome-aware readiness admission cannot persist a task"
19818        );
19819    }
19820
19821    #[cfg(feature = "tasks")]
19822    #[test]
19823    fn final_task_outcome_requires_peer_capability_before_store_mutation() {
19824        let final_calls = Arc::new(AtomicUsize::new(0));
19825        let store = Arc::new(InMemoryFinalTaskStore::default());
19826        let runtime = task_runtime_for_router(Arc::clone(&store));
19827        let service_runner = runtime
19828            .install_task_service(1, Arc::new(NoopFinalTaskSupervisor))
19829            .expect("a bounded application-owned task service is installed");
19830        let service_cx = Cx::for_testing();
19831        let mut running_service = Box::pin(service_runner.run(&service_cx));
19832        let mut task_cx = std::task::Context::from_waker(std::task::Waker::noop());
19833        assert!(matches!(
19834            Future::poll(running_service.as_mut(), &mut task_cx),
19835            Poll::Pending
19836        ));
19837        let mut router = Router::new();
19838        router.set_final_task_runtime(Some(runtime));
19839        router
19840            .add_tool(TaskCapableRouterTool {
19841                final_calls: Arc::clone(&final_calls),
19842            })
19843            .expect("tool registration succeeds");
19844        let cx = Cx::for_testing();
19845        let state = SessionState::new();
19846        let request_ctx = request_context(&cx, 162, Budget::INFINITE, &state);
19847
19848        let error = router
19849            .dispatch_stateless(
19850                &request_ctx,
19851                &final_tools_call_request(
19852                    "task-capable-router-tool",
19853                    serde_json::json!({}),
19854                    162_i64,
19855                ),
19856            )
19857            .expect_err("a missing peer Tasks capability is refused before task-store mutation");
19858        assert!(matches!(error.code, McpErrorCode::Custom(_)));
19859        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19860        assert_eq!(
19861            store.task_count(),
19862            0,
19863            "a rejected CreateTask outcome must not reach the task store"
19864        );
19865    }
19866
19867    #[cfg(feature = "tasks")]
19868    #[test]
19869    fn final_router_defensively_rejects_an_undeclared_task_outcome() {
19870        let final_calls = Arc::new(AtomicUsize::new(0));
19871        let mut router = Router::new();
19872        router
19873            .add_tool(UndeclaredTaskOutcomeRouterTool {
19874                final_calls: Arc::clone(&final_calls),
19875            })
19876            .expect("tool registration succeeds");
19877        let cx = Cx::for_testing();
19878        let state = SessionState::new();
19879        let request_ctx = request_context(&cx, 163, Budget::INFINITE, &state);
19880        let request = JsonRpcRequest::new(
19881            "tools/call",
19882            Some(serde_json::json!({
19883                "_meta": {
19884                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19885                    "io.modelcontextprotocol/clientCapabilities": {
19886                        "extensions": {
19887                            "io.modelcontextprotocol/tasks": {}
19888                        }
19889                    },
19890                },
19891                "name": "undeclared-task-outcome-router-tool",
19892                "arguments": {},
19893            })),
19894            163_i64,
19895        );
19896
19897        let error = router
19898            .dispatch_stateless(&request_ctx, &request)
19899            .expect_err("the router refuses a task outcome without a preflight declaration");
19900        assert_eq!(error.code, McpErrorCode::InvalidRequest);
19901        assert_eq!(
19902            error.message,
19903            "tool returned CreateTask without declaring final Tasks capability"
19904        );
19905        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
19906    }
19907
19908    #[test]
19909    fn macro_tool_final_metadata_negative_is_non_mutating() {
19910        let _counter_guard = MACRO_DUAL_ERA_TOOL_LOCK
19911            .lock()
19912            .unwrap_or_else(std::sync::PoisonError::into_inner);
19913        let mut router = Router::new();
19914        MACRO_DUAL_ERA_TOOL_CALLS.store(0, Ordering::SeqCst);
19915        router
19916            .add_tool(MacroDualEraTool)
19917            .expect("macro tool registration succeeds");
19918        let cx = Cx::for_testing();
19919        let state = SessionState::new();
19920        let request_ctx = request_context(&cx, 92, Budget::INFINITE, &state);
19921        let baseline = JsonRpcRequest::new(
19922            "tools/call",
19923            Some(serde_json::json!({
19924                "_meta": {
19925                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
19926                    "io.modelcontextprotocol/clientCapabilities": {},
19927                },
19928                "name": "macro_dual_era_tool",
19929                "arguments": {},
19930            })),
19931            92_i64,
19932        );
19933        let mut planted = baseline.clone();
19934        planted
19935            .params
19936            .as_mut()
19937            .and_then(serde_json::Value::as_object_mut)
19938            .expect("tools/call parameters are an object")
19939            .remove("_meta");
19940
19941        assert_eq!(baseline.method, planted.method);
19942        assert_eq!(baseline.id, planted.id);
19943        assert_eq!(
19944            baseline
19945                .params
19946                .as_ref()
19947                .and_then(|params| params.get("name")),
19948            planted
19949                .params
19950                .as_ref()
19951                .and_then(|params| params.get("name")),
19952            "the final metadata object is the sole planted dimension"
19953        );
19954        let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
19955        let planted_before = serde_json::to_vec(&planted).expect("request serializes");
19956
19957        let baseline_result = router
19958            .dispatch_stateless(&request_ctx, &baseline)
19959            .expect("the baseline invokes the registered handler");
19960        assert_eq!(
19961            baseline_result.get("resultType"),
19962            Some(&serde_json::json!("complete"))
19963        );
19964        assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 1);
19965
19966        let error = router
19967            .dispatch_stateless(&request_ctx, &planted)
19968            .expect_err("only final metadata is refused");
19969        assert_eq!(error.code, McpErrorCode::InvalidParams);
19970        assert_eq!(
19971            serde_json::to_vec(&planted).expect("rejected request serializes"),
19972            planted_before,
19973            "typed refusal cannot mutate caller-owned input"
19974        );
19975        assert_eq!(
19976            serde_json::to_vec(&router.tools()).expect("catalog serializes"),
19977            catalog_before,
19978            "typed refusal cannot mutate the installed handler catalog"
19979        );
19980        assert_eq!(
19981            MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst),
19982            1,
19983            "metadata refusal cannot invoke the macro-generated tool"
19984        );
19985        assert_eq!(
19986            router
19987                .dispatch_stateless(&request_ctx, &baseline)
19988                .expect("the unchanged baseline remains accepted after the rejection"),
19989            baseline_result,
19990            "the one-field rejection cannot alter the accepted final result"
19991        );
19992        assert_eq!(MACRO_DUAL_ERA_TOOL_CALLS.load(Ordering::SeqCst), 2);
19993    }
19994
19995    #[test]
19996    fn public_final_dispatch_encodes_input_required_handler_outcomes() {
19997        let tool_legacy_calls = Arc::new(AtomicUsize::new(0));
19998        let tool_final_calls = Arc::new(AtomicUsize::new(0));
19999        let resource_legacy_calls = Arc::new(AtomicUsize::new(0));
20000        let resource_final_calls = Arc::new(AtomicUsize::new(0));
20001        let prompt_legacy_calls = Arc::new(AtomicUsize::new(0));
20002        let prompt_final_calls = Arc::new(AtomicUsize::new(0));
20003        let mut router = Router::new();
20004        router
20005            .add_tool(InputRequiredTool {
20006                legacy_calls: Arc::clone(&tool_legacy_calls),
20007                final_calls: Arc::clone(&tool_final_calls),
20008            })
20009            .expect("tool registration succeeds");
20010        router.add_resource(InputRequiredResource {
20011            legacy_calls: Arc::clone(&resource_legacy_calls),
20012            final_calls: Arc::clone(&resource_final_calls),
20013        });
20014        router.add_prompt(InputRequiredPrompt {
20015            legacy_calls: Arc::clone(&prompt_legacy_calls),
20016            final_calls: Arc::clone(&prompt_final_calls),
20017        });
20018
20019        let cx = Cx::for_testing();
20020        let connection = ModernConnection::new();
20021        let inbound = InboundRequestContext::with_modern_connection(
20022            cx,
20023            141,
20024            InboundRequestTransport::Memory,
20025            &connection,
20026        );
20027        let request_ctx = inbound.request_context();
20028        let metadata = serde_json::json!({
20029            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20030            "io.modelcontextprotocol/clientCapabilities": {},
20031        });
20032
20033        let tool_request = JsonRpcRequest::new(
20034            "tools/call",
20035            Some(serde_json::json!({
20036                "_meta": metadata.clone(),
20037                "name": "input-required-tool",
20038                "arguments": {},
20039            })),
20040            141_i64,
20041        );
20042        let tool_typed = CoreRequest::decode(
20043            ProtocolEra::Modern2026,
20044            "tools/call",
20045            tool_request.params.as_ref(),
20046        )
20047        .expect("final tools/call request decodes");
20048        let tool_response = router
20049            .dispatch_stateless(&request_ctx, &tool_request)
20050            .expect("public final tools/call dispatch encodes input_required");
20051        assert_eq!(tool_response["resultType"], "input_required");
20052        assert_ne!(tool_response["requestState"], "tool-retry-state");
20053        assert_eq!(
20054            tool_response["inputRequests"]["roots"]["method"],
20055            "roots/list"
20056        );
20057        let tool_wire = serde_json::to_string(&tool_response).expect("tool response serializes");
20058        assert!(matches!(
20059            tool_typed.decode_result(&tool_wire),
20060            Ok(CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { result, .. }))
20061                if result.request_state().is_some()
20062        ));
20063
20064        let resource_request = JsonRpcRequest::new(
20065            "resources/read",
20066            Some(serde_json::json!({
20067                "_meta": metadata.clone(),
20068                "uri": "file:///input-required-resource",
20069            })),
20070            142_i64,
20071        );
20072        let resource_typed = CoreRequest::decode(
20073            ProtocolEra::Modern2026,
20074            "resources/read",
20075            resource_request.params.as_ref(),
20076        )
20077        .expect("final resources/read request decodes");
20078        let resource_response = router
20079            .dispatch_stateless(&request_ctx, &resource_request)
20080            .expect("public final resources/read dispatch encodes input_required");
20081        assert_eq!(resource_response["resultType"], "input_required");
20082        assert_ne!(resource_response["requestState"], "resource-retry-state");
20083        assert_eq!(
20084            resource_response["inputRequests"]["roots"]["method"],
20085            "roots/list"
20086        );
20087        let resource_wire =
20088            serde_json::to_string(&resource_response).expect("resource response serializes");
20089        assert!(matches!(
20090            resource_typed.decode_result(&resource_wire),
20091            Ok(CoreResult::Final(FinalCoreResult::ResourcesReadInputRequired { result, .. }))
20092                if result.request_state().is_some()
20093        ));
20094
20095        let prompt_request = JsonRpcRequest::new(
20096            "prompts/get",
20097            Some(serde_json::json!({
20098                "_meta": metadata,
20099                "name": "input-required-prompt",
20100            })),
20101            143_i64,
20102        );
20103        let prompt_typed = CoreRequest::decode(
20104            ProtocolEra::Modern2026,
20105            "prompts/get",
20106            prompt_request.params.as_ref(),
20107        )
20108        .expect("final prompts/get request decodes");
20109        let prompt_response = router
20110            .dispatch_stateless(&request_ctx, &prompt_request)
20111            .expect("public final prompts/get dispatch encodes input_required");
20112        assert_eq!(prompt_response["resultType"], "input_required");
20113        assert_ne!(prompt_response["requestState"], "prompt-retry-state");
20114        assert_eq!(
20115            prompt_response["inputRequests"]["roots"]["method"],
20116            "roots/list"
20117        );
20118        let prompt_wire =
20119            serde_json::to_string(&prompt_response).expect("prompt response serializes");
20120        assert!(matches!(
20121            prompt_typed.decode_result(&prompt_wire),
20122            Ok(CoreResult::Final(FinalCoreResult::PromptsGetInputRequired { result, .. }))
20123                if result.request_state().is_some()
20124        ));
20125
20126        assert_eq!(tool_legacy_calls.load(Ordering::SeqCst), 0);
20127        assert_eq!(resource_legacy_calls.load(Ordering::SeqCst), 0);
20128        assert_eq!(prompt_legacy_calls.load(Ordering::SeqCst), 0);
20129        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
20130        assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
20131        assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
20132    }
20133
20134    #[test]
20135    fn final_context_elicitation_round_trips_through_tools_call_mrtr() {
20136        let initial_calls = Arc::new(AtomicUsize::new(0));
20137        let resumed_calls = Arc::new(AtomicUsize::new(0));
20138        let mut router = Router::new();
20139        router
20140            .add_tool(ContextElicitationTool {
20141                initial_calls: Arc::clone(&initial_calls),
20142                resumed_calls: Arc::clone(&resumed_calls),
20143            })
20144            .expect("context elicitation tool registers");
20145
20146        let cx = Cx::for_testing();
20147        let connection = ModernConnection::new();
20148        let mut initial_request =
20149            final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 940_i64);
20150        initial_request
20151            .params
20152            .as_mut()
20153            .expect("tool params are present")["_meta"]["io.modelcontextprotocol/clientCapabilities"] = serde_json::json!({
20154            "elicitation": {"form": {}},
20155        });
20156        let initial_context = InboundRequestContext::with_modern_connection(
20157            cx.clone(),
20158            940,
20159            InboundRequestTransport::Memory,
20160            &connection,
20161        )
20162        .request_context()
20163        .with_client_capabilities(ClientCapabilityInfo::new().with_elicitation(true, false));
20164
20165        let initial = router
20166            .dispatch_stateless(&initial_context, &initial_request)
20167            .expect("an elicitation-capable final client receives MRTR input_required");
20168        assert_eq!(initial["resultType"], "input_required");
20169        assert_eq!(
20170            initial["inputRequests"]["approval"]["method"],
20171            "elicitation/create"
20172        );
20173        assert_eq!(
20174            initial["inputRequests"]["approval"]["params"]["mode"],
20175            "form"
20176        );
20177        assert!(
20178            initial["inputRequests"]["approval"]
20179                .get("jsonrpc")
20180                .is_none()
20181                && initial["inputRequests"]["approval"].get("id").is_none(),
20182            "final elicitation must be embedded input, never a reverse JSON-RPC request"
20183        );
20184        let request_state = initial["requestState"]
20185            .as_str()
20186            .expect("router mints an opaque MRTR request state")
20187            .to_owned();
20188
20189        let mut retry_request =
20190            final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 941_i64);
20191        let retry_params = retry_request
20192            .params
20193            .as_mut()
20194            .expect("tool params are present");
20195        retry_params["_meta"]["io.modelcontextprotocol/clientCapabilities"] = serde_json::json!({
20196            "elicitation": {"form": {}},
20197        });
20198        retry_params["inputResponses"] = serde_json::json!({
20199            "approval": {"action": "accept", "content": {"approved": true}},
20200        });
20201        retry_params["requestState"] = serde_json::Value::String(request_state);
20202        let retry_context = InboundRequestContext::with_modern_connection(
20203            cx,
20204            941,
20205            InboundRequestTransport::Memory,
20206            &connection,
20207        )
20208        .request_context()
20209        .with_client_capabilities(ClientCapabilityInfo::new().with_elicitation(true, false));
20210
20211        let completed = router
20212            .dispatch_stateless(&retry_context, &retry_request)
20213            .expect("the accepted final elicitation resumes the original tools/call");
20214        assert_eq!(completed["resultType"], "complete");
20215        assert_eq!(completed["content"][0]["text"], "approved");
20216        assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20217        assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
20218    }
20219
20220    #[test]
20221    fn final_context_elicitation_without_capability_rejects_before_mrtr_state_mutation() {
20222        let initial_calls = Arc::new(AtomicUsize::new(0));
20223        let resumed_calls = Arc::new(AtomicUsize::new(0));
20224        let mut router = Router::new();
20225        router
20226            .add_tool(ContextElicitationTool {
20227                initial_calls: Arc::clone(&initial_calls),
20228                resumed_calls: Arc::clone(&resumed_calls),
20229            })
20230            .expect("context elicitation tool registers");
20231
20232        let cx = Cx::for_testing();
20233        let connection = ModernConnection::new();
20234        let request =
20235            final_tools_call_request("context-elicitation-tool", serde_json::json!({}), 942_i64);
20236        let context = InboundRequestContext::with_modern_connection(
20237            cx,
20238            942,
20239            InboundRequestTransport::Memory,
20240            &connection,
20241        )
20242        .request_context();
20243
20244        let error = router
20245            .dispatch_stateless(&context, &request)
20246            .expect_err("changing only client elicitation capability must refuse the request");
20247        assert_eq!(error.code, McpErrorCode::InvalidRequest);
20248        assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20249        assert_eq!(resumed_calls.load(Ordering::SeqCst), 0);
20250        assert_eq!(
20251            router.mrtr_exchanges.active_len(),
20252            0,
20253            "capability refusal cannot mint MRTR continuation state"
20254        );
20255    }
20256
20257    #[test]
20258    fn final_sampling_capability_preserves_tool_choice_and_rejection_leaves_registry_unchanged() {
20259        let calls = Arc::new(AtomicUsize::new(0));
20260        let mut router = Router::new();
20261        router
20262            .add_tool(ContextSamplingTool {
20263                calls: Arc::clone(&calls),
20264            })
20265            .expect("context sampling tool registers");
20266
20267        let cx = Cx::for_testing();
20268        let connection = ModernConnection::new();
20269        let request =
20270            final_tools_call_request("context-sampling-tool", serde_json::json!({}), 943_i64);
20271        let admitted_context = InboundRequestContext::with_modern_connection(
20272            cx.clone(),
20273            943,
20274            InboundRequestTransport::Memory,
20275            &connection,
20276        )
20277        .request_context()
20278        .with_client_capabilities(ClientCapabilityInfo::new().with_sampling());
20279        let admitted = router
20280            .dispatch_stateless(&admitted_context, &request)
20281            .expect("sampling capability admits final MRTR sampling");
20282        assert_eq!(admitted["resultType"], "input_required");
20283        assert_eq!(
20284            admitted["inputRequests"]["sample"]["params"]["toolChoice"],
20285            serde_json::json!({"mode": "required"})
20286        );
20287        assert_eq!(
20288            admitted["inputRequests"]["sample"]["params"]["messages"][0]["content"]["type"],
20289            "tool_use"
20290        );
20291        assert_eq!(router.mrtr_exchanges.active_len(), 1);
20292
20293        let removed_capability_context = InboundRequestContext::with_modern_connection(
20294            cx,
20295            944,
20296            InboundRequestTransport::Memory,
20297            &connection,
20298        )
20299        .request_context();
20300        let rejection = router
20301            .dispatch_stateless(&removed_capability_context, &request)
20302            .expect_err("removing only sampling capability rejects the descriptor");
20303        assert_eq!(rejection.code, McpErrorCode::InvalidRequest);
20304        assert_eq!(
20305            router.mrtr_exchanges.active_len(),
20306            1,
20307            "the one-field capability removal cannot mutate the admitted registry"
20308        );
20309        assert_eq!(calls.load(Ordering::SeqCst), 2);
20310    }
20311
20312    #[test]
20313    fn http_admission_raw_sidecar_reaches_tool_resource_and_prompt_mrtr_retries() {
20314        let tool_calls = Arc::new(AtomicUsize::new(0));
20315        let resource_calls = Arc::new(AtomicUsize::new(0));
20316        let prompt_calls = Arc::new(AtomicUsize::new(0));
20317        let mut router = Router::new();
20318        router
20319            .add_tool(InputRequiredTool {
20320                legacy_calls: Arc::new(AtomicUsize::new(0)),
20321                final_calls: Arc::clone(&tool_calls),
20322            })
20323            .expect("tool registers");
20324        router.add_resource(InputRequiredResource {
20325            legacy_calls: Arc::new(AtomicUsize::new(0)),
20326            final_calls: Arc::clone(&resource_calls),
20327        });
20328        router.add_prompt(InputRequiredPrompt {
20329            legacy_calls: Arc::new(AtomicUsize::new(0)),
20330            final_calls: Arc::clone(&prompt_calls),
20331        });
20332
20333        let cx = Cx::for_testing();
20334        let connection = ModernConnection::new();
20335        let inbound = InboundRequestContext::with_modern_connection(
20336            cx,
20337            1438,
20338            InboundRequestTransport::Http,
20339            &connection,
20340        );
20341        let request_ctx = inbound.request_context();
20342        let cancellation = inbound
20343            .mrtr_continuation_cancellation()
20344            .expect("HTTP-bound modern context owns continuations");
20345        let metadata = serde_json::json!({
20346            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20347            "io.modelcontextprotocol/clientCapabilities": {},
20348        });
20349        let roots = serde_json::to_string(&router_roots_response_wire())
20350            .expect("roots response serializes");
20351
20352        let tool_initial_body = serde_json::to_vec(&serde_json::json!({
20353            "jsonrpc": "2.0", "id": 1438, "method": "tools/call",
20354            "params": {"_meta": metadata.clone(), "name": "input-required-tool", "arguments": {}},
20355        }))
20356        .expect("tool body serializes");
20357        let (tool_initial, tool_initial_raw) =
20358            admit_http_wire("tools/call", "input-required-tool", &tool_initial_body);
20359        let tool_issued = router
20360            .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20361                &request_ctx,
20362                &tool_initial,
20363                tool_initial_raw.as_deref(),
20364                &cancellation,
20365            )
20366            .expect("HTTP-admitted tool issues state");
20367        let tool_retry_body = format!(
20368            r#"{{"jsonrpc":"2.0","id":1439,"method":"tools/call","params":{{"_meta":{},"name":"input-required-tool","arguments":{{}},"inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
20369            serde_json::to_string(&metadata).expect("metadata serializes"),
20370            roots,
20371            roots,
20372            serde_json::to_string(&tool_issued["requestState"]).expect("tool state serializes"),
20373        );
20374        let (tool_retry, tool_retry_raw) = admit_http_wire(
20375            "tools/call",
20376            "input-required-tool",
20377            tool_retry_body.as_bytes(),
20378        );
20379        router
20380            .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20381                &request_ctx,
20382                &tool_retry,
20383                tool_retry_raw.as_deref(),
20384                &cancellation,
20385            )
20386            .expect("HTTP-admitted ordered tool retry reaches the handler");
20387
20388        let resource_initial_body = serde_json::to_vec(&serde_json::json!({
20389            "jsonrpc": "2.0", "id": 1440, "method": "resources/read",
20390            "params": {"_meta": metadata.clone(), "uri": "file:///input-required-resource"},
20391        }))
20392        .expect("resource body serializes");
20393        let (resource_initial, resource_initial_raw) = admit_http_wire(
20394            "resources/read",
20395            "file:///input-required-resource",
20396            &resource_initial_body,
20397        );
20398        let resource_issued = router
20399            .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20400                &request_ctx,
20401                &resource_initial,
20402                resource_initial_raw.as_deref(),
20403                &cancellation,
20404            )
20405            .expect("HTTP-admitted resource issues state");
20406        let resource_retry_body = format!(
20407            r#"{{"jsonrpc":"2.0","id":1441,"method":"resources/read","params":{{"_meta":{},"uri":"file:///input-required-resource","inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
20408            serde_json::to_string(&metadata).expect("metadata serializes"),
20409            roots,
20410            roots,
20411            serde_json::to_string(&resource_issued["requestState"])
20412                .expect("resource state serializes"),
20413        );
20414        let (resource_retry, resource_retry_raw) = admit_http_wire(
20415            "resources/read",
20416            "file:///input-required-resource",
20417            resource_retry_body.as_bytes(),
20418        );
20419        let mut sanitized_resource_retry = resource_retry.clone();
20420        sanitized_resource_retry
20421            .params
20422            .as_mut()
20423            .and_then(serde_json::Value::as_object_mut)
20424            .expect("HTTP-admitted resource parameters are an object")
20425            .insert(
20426                "uri".to_owned(),
20427                serde_json::json!("file:///sanitized-resource"),
20428            );
20429        let resource_sidecar_error = router
20430            .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20431                &request_ctx,
20432                &sanitized_resource_retry,
20433                resource_retry_raw.as_deref(),
20434                &cancellation,
20435            )
20436            .expect_err(
20437                "a resource raw sidecar cannot survive a one-field sanitized typed mismatch",
20438            );
20439        assert_eq!(resource_sidecar_error.code, McpErrorCode::InvalidParams);
20440        assert_eq!(
20441            resource_calls.load(Ordering::SeqCst),
20442            1,
20443            "the mismatched resource sidecar is rejected before handler invocation"
20444        );
20445        assert_eq!(
20446            router.mrtr_exchanges.active_len(),
20447            2,
20448            "the rejected resource sidecar leaves its continuation available"
20449        );
20450        router
20451            .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20452                &request_ctx,
20453                &resource_retry,
20454                resource_retry_raw.as_deref(),
20455                &cancellation,
20456            )
20457            .expect("HTTP-admitted ordered resource retry reaches the handler");
20458
20459        let prompt_initial_body = serde_json::to_vec(&serde_json::json!({
20460            "jsonrpc": "2.0", "id": 1442, "method": "prompts/get",
20461            "params": {"_meta": metadata.clone(), "name": "input-required-prompt"},
20462        }))
20463        .expect("prompt body serializes");
20464        let (prompt_initial, prompt_initial_raw) =
20465            admit_http_wire("prompts/get", "input-required-prompt", &prompt_initial_body);
20466        let prompt_issued = router
20467            .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20468                &request_ctx,
20469                &prompt_initial,
20470                prompt_initial_raw.as_deref(),
20471                &cancellation,
20472            )
20473            .expect("HTTP-admitted prompt issues state");
20474        let prompt_retry_body = format!(
20475            r#"{{"jsonrpc":"2.0","id":1443,"method":"prompts/get","params":{{"_meta":{},"name":"input-required-prompt","inputResponses":{{"inert":{},"roots":{}}},"requestState":{}}}}}"#,
20476            serde_json::to_string(&metadata).expect("metadata serializes"),
20477            roots,
20478            roots,
20479            serde_json::to_string(&prompt_issued["requestState"]).expect("prompt state serializes"),
20480        );
20481        let (prompt_retry, prompt_retry_raw) = admit_http_wire(
20482            "prompts/get",
20483            "input-required-prompt",
20484            prompt_retry_body.as_bytes(),
20485        );
20486        let mut sanitized_prompt_retry = prompt_retry.clone();
20487        sanitized_prompt_retry
20488            .params
20489            .as_mut()
20490            .and_then(serde_json::Value::as_object_mut)
20491            .expect("HTTP-admitted prompt parameters are an object")
20492            .insert("name".to_owned(), serde_json::json!("sanitized-prompt"));
20493        let prompt_sidecar_error = router
20494            .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20495                &request_ctx,
20496                &sanitized_prompt_retry,
20497                prompt_retry_raw.as_deref(),
20498                &cancellation,
20499            )
20500            .expect_err("a prompt raw sidecar cannot survive a one-field sanitized typed mismatch");
20501        assert_eq!(prompt_sidecar_error.code, McpErrorCode::InvalidParams);
20502        assert_eq!(
20503            prompt_calls.load(Ordering::SeqCst),
20504            1,
20505            "the mismatched prompt sidecar is rejected before handler invocation"
20506        );
20507        assert_eq!(
20508            router.mrtr_exchanges.active_len(),
20509            3,
20510            "the rejected prompt sidecar leaves its continuation available"
20511        );
20512        router
20513            .dispatch_stateless_with_continuation_cancellation_and_raw_params(
20514                &request_ctx,
20515                &prompt_retry,
20516                prompt_retry_raw.as_deref(),
20517                &cancellation,
20518            )
20519            .expect("HTTP-admitted ordered prompt retry reaches the handler");
20520
20521        assert_eq!(tool_calls.load(Ordering::SeqCst), 2);
20522        assert_eq!(resource_calls.load(Ordering::SeqCst), 2);
20523        assert_eq!(prompt_calls.load(Ordering::SeqCst), 2);
20524    }
20525
20526    #[test]
20527    fn stateless_final_dispatch_keeps_complete_only_handlers_available_for_all_families() {
20528        let mut router = Router::new();
20529        router
20530            .add_tool(NamedTool::new("complete-only-tool"))
20531            .expect("complete-only tool registers");
20532        router.add_resource(NamedResource::new("file:///complete-only-resource"));
20533        router.add_prompt(NamedPrompt::new("complete-only-prompt"));
20534
20535        let cx = Cx::for_testing();
20536        let state = SessionState::new();
20537        let request_ctx = request_context(&cx, 1430, Budget::INFINITE, &state);
20538        let metadata = serde_json::json!({
20539            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20540            "io.modelcontextprotocol/clientCapabilities": {},
20541        });
20542
20543        let tool = router
20544            .dispatch_stateless(
20545                &request_ctx,
20546                &final_tools_call_request("complete-only-tool", serde_json::json!({}), 1430_i64),
20547            )
20548            .expect("a complete-only tool remains available without a session partition");
20549        assert_eq!(tool["resultType"], "complete");
20550
20551        let resource = router
20552            .dispatch_stateless(
20553                &request_ctx,
20554                &JsonRpcRequest::new(
20555                    "resources/read",
20556                    Some(serde_json::json!({
20557                        "_meta": metadata.clone(),
20558                        "uri": "file:///complete-only-resource",
20559                    })),
20560                    1431_i64,
20561                ),
20562            )
20563            .expect("a complete-only resource remains available without a session partition");
20564        assert_eq!(resource["resultType"], "complete");
20565
20566        let prompt = router
20567            .dispatch_stateless(
20568                &request_ctx,
20569                &JsonRpcRequest::new(
20570                    "prompts/get",
20571                    Some(serde_json::json!({
20572                        "_meta": metadata,
20573                        "name": "complete-only-prompt",
20574                    })),
20575                    1432_i64,
20576                ),
20577            )
20578            .expect("a complete-only prompt remains available without a session partition");
20579        assert_eq!(prompt["resultType"], "complete");
20580    }
20581
20582    #[test]
20583    fn stateless_mrtr_handlers_are_rejected_before_all_family_invocations() {
20584        let tool_calls = Arc::new(AtomicUsize::new(0));
20585        let resource_calls = Arc::new(AtomicUsize::new(0));
20586        let prompt_calls = Arc::new(AtomicUsize::new(0));
20587        let mut router = Router::new();
20588        router
20589            .add_tool(InputRequiredTool {
20590                legacy_calls: Arc::new(AtomicUsize::new(0)),
20591                final_calls: Arc::clone(&tool_calls),
20592            })
20593            .expect("MRTR tool registers");
20594        router.add_resource(InputRequiredResource {
20595            legacy_calls: Arc::new(AtomicUsize::new(0)),
20596            final_calls: Arc::clone(&resource_calls),
20597        });
20598        router.add_prompt(InputRequiredPrompt {
20599            legacy_calls: Arc::new(AtomicUsize::new(0)),
20600            final_calls: Arc::clone(&prompt_calls),
20601        });
20602
20603        let cx = Cx::for_testing();
20604        let state = SessionState::new();
20605        let request_ctx = request_context(&cx, 1433, Budget::INFINITE, &state);
20606        let metadata = serde_json::json!({
20607            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20608            "io.modelcontextprotocol/clientCapabilities": {},
20609        });
20610
20611        let tool_error = router
20612            .dispatch_stateless(
20613                &request_ctx,
20614                &final_tools_call_request("input-required-tool", serde_json::json!({}), 1433_i64),
20615            )
20616            .expect_err("a stateless MRTR tool is rejected before its handler runs");
20617        assert_eq!(tool_error.code, McpErrorCode::InvalidParams);
20618
20619        let resource_error = router
20620            .dispatch_stateless(
20621                &request_ctx,
20622                &JsonRpcRequest::new(
20623                    "resources/read",
20624                    Some(serde_json::json!({
20625                        "_meta": metadata.clone(),
20626                        "uri": "file:///input-required-resource",
20627                    })),
20628                    1434_i64,
20629                ),
20630            )
20631            .expect_err("a stateless MRTR resource is rejected before its handler runs");
20632        assert_eq!(resource_error.code, McpErrorCode::InvalidParams);
20633
20634        let prompt_error = router
20635            .dispatch_stateless(
20636                &request_ctx,
20637                &JsonRpcRequest::new(
20638                    "prompts/get",
20639                    Some(serde_json::json!({
20640                        "_meta": metadata,
20641                        "name": "input-required-prompt",
20642                    })),
20643                    1435_i64,
20644                ),
20645            )
20646            .expect_err("a stateless MRTR prompt is rejected before its handler runs");
20647        assert_eq!(prompt_error.code, McpErrorCode::InvalidParams);
20648        assert_eq!(tool_calls.load(Ordering::SeqCst), 0);
20649        assert_eq!(resource_calls.load(Ordering::SeqCst), 0);
20650        assert_eq!(prompt_calls.load(Ordering::SeqCst), 0);
20651        assert_eq!(router.mrtr_exchanges.active_len(), 0);
20652    }
20653
20654    #[test]
20655    fn bound_owned_mrtr_retry_rejects_foreign_and_stale_state_without_mutation_then_resumes() {
20656        let calls = Arc::new(AtomicUsize::new(0));
20657        let mut unshared_router = Router::new();
20658        unshared_router
20659            .add_tool(OneShotMrtrTool {
20660                calls: Arc::clone(&calls),
20661            })
20662            .expect("one-shot MRTR tool registers");
20663        let router = Arc::new(unshared_router);
20664        let cx = Cx::for_testing();
20665        let origin_connection = ModernConnection::new();
20666        let origin_inbound = InboundRequestContext::with_modern_connection(
20667            cx.clone(),
20668            1436,
20669            InboundRequestTransport::Memory,
20670            &origin_connection,
20671        );
20672        let origin_ctx = origin_inbound.request_context();
20673        let origin_cancellation = origin_inbound
20674            .mrtr_continuation_cancellation()
20675            .expect("a bound origin supplies continuation ownership");
20676        let initial =
20677            final_tools_call_request("one-shot-mrtr-tool", serde_json::json!({}), 1436_i64);
20678        let issued = block_on(
20679            Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
20680                origin_ctx,
20681                initial,
20682                origin_cancellation.clone(),
20683            ),
20684        )
20685        .expect("the bound owned path issues one continuation");
20686        let request_state = issued["requestState"]
20687            .as_str()
20688            .expect("issued continuation has opaque state")
20689            .to_owned();
20690        assert_eq!(calls.load(Ordering::SeqCst), 1);
20691        assert_eq!(router.mrtr_exchanges.active_len(), 1);
20692
20693        let retry = JsonRpcRequest::new(
20694            "tools/call",
20695            Some(serde_json::json!({
20696                "_meta": {
20697                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20698                    "io.modelcontextprotocol/clientCapabilities": {},
20699                },
20700                "name": "one-shot-mrtr-tool",
20701                "arguments": {},
20702                "inputResponses": {"roots": router_roots_response_wire()},
20703                "requestState": request_state,
20704            })),
20705            1437_i64,
20706        );
20707
20708        let mismatched_sidecar = serde_json::to_string(
20709            retry
20710                .params
20711                .as_ref()
20712                .expect("retry has materialized parameters"),
20713        )
20714        .expect("retry parameters serialize")
20715        .replacen("one-shot-mrtr-tool", "other-tool", 1);
20716        let sidecar_error = block_on(
20717            Arc::clone(&router)
20718                .dispatch_stateless_owned_with_continuation_cancellation_and_raw_params(
20719                    origin_inbound.request_context(),
20720                    retry.clone(),
20721                    Some(Arc::<str>::from(mismatched_sidecar)),
20722                    origin_cancellation.clone(),
20723                ),
20724        )
20725        .expect_err("a mismatched raw sidecar cannot reach continuation admission");
20726        assert_eq!(sidecar_error.code, McpErrorCode::InvalidParams);
20727        assert_eq!(calls.load(Ordering::SeqCst), 1);
20728        assert_eq!(router.mrtr_exchanges.active_len(), 1);
20729
20730        let foreign_connection = ModernConnection::new();
20731        let foreign_inbound = InboundRequestContext::with_modern_connection(
20732            cx.clone(),
20733            1437,
20734            InboundRequestTransport::Memory,
20735            &foreign_connection,
20736        );
20737        let foreign_error = block_on(
20738            Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
20739                foreign_inbound.request_context(),
20740                retry.clone(),
20741                foreign_inbound
20742                    .mrtr_continuation_cancellation()
20743                    .expect("a foreign connection still supplies its own cancellation"),
20744            ),
20745        )
20746        .expect_err("a foreign connection cannot consume origin continuation state");
20747        assert_eq!(foreign_error.code, McpErrorCode::InvalidParams);
20748        assert_eq!(calls.load(Ordering::SeqCst), 1);
20749        assert_eq!(router.mrtr_exchanges.active_len(), 1);
20750
20751        let mut stale_retry = retry.clone();
20752        stale_retry
20753            .params
20754            .as_mut()
20755            .and_then(serde_json::Value::as_object_mut)
20756            .expect("retry parameters are an object")
20757            .insert(
20758                "requestState".to_owned(),
20759                serde_json::json!("stale-request-state"),
20760            );
20761        let stale_error = block_on(
20762            Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
20763                origin_inbound.request_context(),
20764                stale_retry,
20765                origin_cancellation.clone(),
20766            ),
20767        )
20768        .expect_err("a stale state cannot consume the live continuation");
20769        assert_eq!(stale_error.code, McpErrorCode::InvalidParams);
20770        assert_eq!(calls.load(Ordering::SeqCst), 1);
20771        assert_eq!(router.mrtr_exchanges.active_len(), 1);
20772
20773        let completed = block_on(
20774            Arc::clone(&router).dispatch_stateless_owned_with_continuation_cancellation(
20775                origin_inbound.request_context(),
20776                retry,
20777                origin_cancellation,
20778            ),
20779        )
20780        .expect("the unchanged origin retry resumes exactly one continuation");
20781        assert_eq!(completed["resultType"], "complete");
20782        assert_eq!(calls.load(Ordering::SeqCst), 2);
20783        assert_eq!(router.mrtr_exchanges.active_len(), 0);
20784    }
20785
20786    #[test]
20787    fn modern_mrtr_state_only_retry_round_trips_only_when_input_responses_are_absent() {
20788        let initial_calls = Arc::new(AtomicUsize::new(0));
20789        let resumed_calls = Arc::new(AtomicUsize::new(0));
20790        let mut router = Router::new();
20791        router
20792            .add_tool(StateOnlyInputRequiredTool {
20793                initial_calls: Arc::clone(&initial_calls),
20794                resumed_calls: Arc::clone(&resumed_calls),
20795            })
20796            .expect("state-only tool registration succeeds");
20797
20798        let cx = Cx::for_testing();
20799        let connection = ModernConnection::new();
20800        let inbound = InboundRequestContext::with_modern_connection(
20801            cx,
20802            144,
20803            InboundRequestTransport::Memory,
20804            &connection,
20805        );
20806        let request_ctx = inbound.request_context();
20807        let metadata = serde_json::json!({
20808            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20809            "io.modelcontextprotocol/clientCapabilities": {},
20810        });
20811        let initial = JsonRpcRequest::new(
20812            "tools/call",
20813            Some(serde_json::json!({
20814                "_meta": metadata.clone(),
20815                "name": "state-only-input-required-tool",
20816                "arguments": {},
20817            })),
20818            144_i64,
20819        );
20820        let input_required = router
20821            .dispatch_stateless(&request_ctx, &initial)
20822            .expect("state-only handler outcome is framework-bound");
20823        assert_eq!(input_required["resultType"], "input_required");
20824        assert!(
20825            input_required.get("inputRequests").is_none(),
20826            "the framework preserves state-only input_required without an empty request map"
20827        );
20828        let request_state = input_required["requestState"]
20829            .as_str()
20830            .expect("framework output includes opaque state")
20831            .to_owned();
20832
20833        let retry = JsonRpcRequest::new(
20834            "tools/call",
20835            Some(serde_json::json!({
20836                "_meta": metadata,
20837                "name": "state-only-input-required-tool",
20838                "arguments": {},
20839                "requestState": request_state,
20840            })),
20841            145_i64,
20842        );
20843        assert!(
20844            retry
20845                .params
20846                .as_ref()
20847                .and_then(serde_json::Value::as_object)
20848                .is_some_and(|params| !params.contains_key("inputResponses")),
20849            "the admitted retry keeps inputResponses absent"
20850        );
20851        let mut explicit_empty = retry.clone();
20852        explicit_empty
20853            .params
20854            .as_mut()
20855            .and_then(serde_json::Value::as_object_mut)
20856            .expect("retry parameters are an object")
20857            .insert("inputResponses".to_owned(), serde_json::json!({}));
20858        let explicit_empty_raw_params = serde_json::to_string(
20859            explicit_empty
20860                .params
20861                .as_ref()
20862                .expect("explicit-empty retry has parameters"),
20863        )
20864        .expect("explicit-empty retry parameters serialize");
20865        let empty_error = router
20866            .dispatch_stateless_with_raw_params(
20867                &request_ctx,
20868                &explicit_empty,
20869                Some(&explicit_empty_raw_params),
20870            )
20871            .expect_err("an explicit empty inputResponses map cannot impersonate an absent member");
20872        assert_eq!(empty_error.code, McpErrorCode::InvalidParams);
20873        assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20874        assert_eq!(resumed_calls.load(Ordering::SeqCst), 0);
20875
20876        let retry_raw_params = serde_json::to_string(
20877            retry
20878                .params
20879                .as_ref()
20880                .expect("absent-member retry has parameters"),
20881        )
20882        .expect("absent-member retry parameters serialize");
20883        let completed = router
20884            .dispatch_stateless_with_raw_params(&request_ctx, &retry, Some(&retry_raw_params))
20885            .expect("the unchanged absent-member retry resumes exactly once");
20886        assert_eq!(completed["resultType"], "complete");
20887        assert_eq!(completed["content"][0]["text"], "state-only resumed");
20888        assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
20889        assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
20890    }
20891
20892    #[test]
20893    fn modern_mrtr_retries_resume_each_method_once_and_refuse_replay_or_kind_mismatch() {
20894        let tool_final_calls = Arc::new(AtomicUsize::new(0));
20895        let resource_final_calls = Arc::new(AtomicUsize::new(0));
20896        let prompt_final_calls = Arc::new(AtomicUsize::new(0));
20897        let mut router = Router::new();
20898        router
20899            .add_tool(InputRequiredTool {
20900                legacy_calls: Arc::new(AtomicUsize::new(0)),
20901                final_calls: Arc::clone(&tool_final_calls),
20902            })
20903            .expect("tool registration succeeds");
20904        router.add_resource(InputRequiredResource {
20905            legacy_calls: Arc::new(AtomicUsize::new(0)),
20906            final_calls: Arc::clone(&resource_final_calls),
20907        });
20908        router.add_prompt(InputRequiredPrompt {
20909            legacy_calls: Arc::new(AtomicUsize::new(0)),
20910            final_calls: Arc::clone(&prompt_final_calls),
20911        });
20912
20913        let cx = Cx::for_testing();
20914        let connection = ModernConnection::new();
20915        let inbound = InboundRequestContext::with_modern_connection(
20916            cx.clone(),
20917            145,
20918            InboundRequestTransport::Memory,
20919            &connection,
20920        );
20921        let request_ctx = inbound.request_context();
20922        let metadata = serde_json::json!({
20923            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
20924            "io.modelcontextprotocol/clientCapabilities": {},
20925        });
20926
20927        let tool_initial = JsonRpcRequest::new(
20928            "tools/call",
20929            Some(serde_json::json!({
20930                "_meta": metadata.clone(),
20931                "name": "input-required-tool",
20932                "arguments": {},
20933            })),
20934            145_i64,
20935        );
20936        let tool_initial_result = router
20937            .dispatch_stateless(&request_ctx, &tool_initial)
20938            .expect("normal final dispatch mints the tool request state");
20939        let tool_state = tool_initial_result["requestState"]
20940            .as_str()
20941            .expect("framework result carries opaque tool state")
20942            .to_owned();
20943        let tool_retry = JsonRpcRequest::new(
20944            "tools/call",
20945            Some(serde_json::json!({
20946                "_meta": metadata.clone(),
20947                "name": "input-required-tool",
20948                "arguments": {},
20949                "inputResponses": {"roots": router_roots_response_wire()},
20950                "requestState": tool_state,
20951            })),
20952            145_i64,
20953        );
20954        let raw_params_for = |request: &JsonRpcRequest| {
20955            serde_json::to_string(request.params.as_ref().expect("retry has parameters"))
20956                .expect("retry parameters serialize")
20957        };
20958        let mut kind_mismatch = tool_retry.clone();
20959        kind_mismatch
20960            .params
20961            .as_mut()
20962            .and_then(serde_json::Value::as_object_mut)
20963            .and_then(|params| params.get_mut("inputResponses"))
20964            .and_then(serde_json::Value::as_object_mut)
20965            .expect("tool retry contains an inputResponses object")
20966            .insert(
20967                "roots".to_owned(),
20968                serde_json::to_value(
20969                    MrtrInputResponse::sampling(
20970                        serde_json::from_value(serde_json::json!({
20971                            "content": {"type": "text", "text": "not roots"},
20972                            "role": "assistant",
20973                            "model": "test-model",
20974                        }))
20975                        .expect("final sampling response must decode"),
20976                    )
20977                    .expect("sampling response serializes"),
20978                )
20979                .expect("sampling response converts to a wire value"),
20980            );
20981        let kind_mismatch_raw_params = raw_params_for(&kind_mismatch);
20982        let kind_error = router
20983            .dispatch_stateless_with_raw_params(
20984                &request_ctx,
20985                &kind_mismatch,
20986                Some(&kind_mismatch_raw_params),
20987            )
20988            .expect_err("changing only the response kind is refused before handler invocation");
20989        assert_eq!(kind_error.code, McpErrorCode::InvalidParams);
20990        assert_eq!(
20991            tool_final_calls.load(Ordering::SeqCst),
20992            1,
20993            "a wrong-kind retry must leave the matching state unconsumed"
20994        );
20995
20996        let missing_responses = JsonRpcRequest::new(
20997            "tools/call",
20998            Some(serde_json::json!({
20999                "_meta": metadata.clone(),
21000                "name": "input-required-tool",
21001                "arguments": {},
21002                "inputResponses": {},
21003                "requestState": tool_state,
21004            })),
21005            145_i64,
21006        );
21007        let missing_raw_params = raw_params_for(&missing_responses);
21008        let missing_error = router
21009            .dispatch_stateless_with_raw_params(
21010                &request_ctx,
21011                &missing_responses,
21012                Some(&missing_raw_params),
21013            )
21014            .expect_err("a missing roots response cannot consume a tool continuation");
21015        assert_eq!(missing_error.code, McpErrorCode::InvalidParams);
21016        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21017
21018        let roots_wire = serde_json::to_string(&router_roots_response_wire())
21019            .expect("roots response serializes for duplicate-key raw ingress");
21020        let duplicate_raw_params = format!(
21021            r#"{{"_meta":{},"name":"input-required-tool","arguments":{{}},"inputResponses":{{"roots":{roots_wire},"roots":{roots_wire}}},"requestState":{}}}"#,
21022            serde_json::to_string(&metadata).expect("metadata serializes"),
21023            serde_json::to_string(&tool_state).expect("opaque state serializes"),
21024        );
21025        let duplicate_retry = JsonRpcRequest::new(
21026            "tools/call",
21027            Some(
21028                serde_json::from_str(&duplicate_raw_params)
21029                    .expect("duplicate raw parameters materialize for source equality"),
21030            ),
21031            145_i64,
21032        );
21033        let duplicate_error = router
21034            .dispatch_stateless_with_raw_params(
21035                &request_ctx,
21036                &duplicate_retry,
21037                Some(&duplicate_raw_params),
21038            )
21039            .expect_err("duplicate raw response keys cannot collapse before MRTR admission");
21040        assert_eq!(duplicate_error.code, McpErrorCode::InvalidParams);
21041        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21042
21043        let mut unknown_only = tool_retry.clone();
21044        *unknown_only
21045            .params
21046            .as_mut()
21047            .and_then(serde_json::Value::as_object_mut)
21048            .and_then(|params| params.get_mut("inputResponses"))
21049            .expect("tool retry contains inputResponses") = serde_json::json!({"inert": null});
21050        let unknown_only_raw_params = raw_params_for(&unknown_only);
21051        let unknown_error = router
21052            .dispatch_stateless_with_raw_params(
21053                &request_ctx,
21054                &unknown_only,
21055                Some(&unknown_only_raw_params),
21056            )
21057            .expect_err("unknown-only inputResponses cannot consume a tool continuation");
21058        assert_eq!(unknown_error.code, McpErrorCode::InvalidParams);
21059        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21060
21061        let mut oversized_map = tool_retry.clone();
21062        let responses = oversized_map
21063            .params
21064            .as_mut()
21065            .and_then(serde_json::Value::as_object_mut)
21066            .and_then(|params| params.get_mut("inputResponses"))
21067            .and_then(serde_json::Value::as_object_mut)
21068            .expect("tool retry contains an inputResponses object");
21069        for index in 0..crate::bidirectional::DEFAULT_MAX_MRTR_INPUT_REQUESTS_PER_ROUND {
21070            responses.insert(format!("inert-{index}"), serde_json::Value::Null);
21071        }
21072        let oversized_error = router
21073            .dispatch_stateless(&request_ctx, &oversized_map)
21074            .expect_err("an oversized raw response map is refused before retry decoding");
21075        assert_eq!(oversized_error.code, McpErrorCode::InvalidParams);
21076        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21077
21078        let mut tool_oversized_bytes = tool_retry.clone();
21079        tool_oversized_bytes
21080            .params
21081            .as_mut()
21082            .and_then(serde_json::Value::as_object_mut)
21083            .and_then(|params| params.get_mut("inputResponses"))
21084            .and_then(serde_json::Value::as_object_mut)
21085            .expect("tool retry contains an inputResponses object")
21086            .insert(
21087                "roots".to_owned(),
21088                serde_json::Value::String("x".repeat(MAX_MRTR_RAW_INPUT_RESPONSES_BYTES + 1)),
21089            );
21090        let tool_bytes_error = router
21091            .dispatch_stateless(&request_ctx, &tool_oversized_bytes)
21092            .expect_err("only an oversized tool response value is refused before retry decoding");
21093        assert_eq!(tool_bytes_error.code, McpErrorCode::InvalidParams);
21094        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21095
21096        let mut target_mismatch = tool_retry.clone();
21097        target_mismatch
21098            .params
21099            .as_mut()
21100            .and_then(serde_json::Value::as_object_mut)
21101            .expect("tool retry parameters are an object")
21102            .insert("name".to_owned(), serde_json::json!("other-tool"));
21103        let target_error = router
21104            .dispatch_stateless(&request_ctx, &target_mismatch)
21105            .expect_err("changing only the target cannot consume a tool state");
21106        assert_eq!(target_error.code, McpErrorCode::InvalidParams);
21107        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21108
21109        let other_connection = ModernConnection::new();
21110        let other_inbound = InboundRequestContext::with_modern_connection(
21111            cx.clone(),
21112            145,
21113            InboundRequestTransport::Memory,
21114            &other_connection,
21115        );
21116        let other_session_ctx = other_inbound.request_context();
21117        let session_error = router
21118            .dispatch_stateless(&other_session_ctx, &tool_retry)
21119            .expect_err("changing only the session cannot consume a tool state");
21120        assert_eq!(session_error.code, McpErrorCode::InvalidParams);
21121        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21122
21123        let principal_ctx = inbound.request_context();
21124        assert!(principal_ctx.set_auth(fastmcp_core::AuthContext::with_subject("other-user")));
21125        let principal_error = router
21126            .dispatch_stateless(&principal_ctx, &tool_retry)
21127            .expect_err("changing only the principal cannot consume a tool state");
21128        assert_eq!(principal_error.code, McpErrorCode::InvalidParams);
21129        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21130
21131        let tool_retry_raw_params = raw_params_for(&tool_retry);
21132        let tool_response = router
21133            .dispatch_stateless_with_raw_params(
21134                &request_ctx,
21135                &tool_retry,
21136                Some(&tool_retry_raw_params),
21137            )
21138            .expect("a framework-minted tool state resumes through the final handler");
21139        assert_eq!(tool_response["resultType"], "input_required");
21140        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 2);
21141
21142        let resource_initial = JsonRpcRequest::new(
21143            "resources/read",
21144            Some(serde_json::json!({
21145                "_meta": metadata.clone(),
21146                "uri": "file:///input-required-resource",
21147            })),
21148            146_i64,
21149        );
21150        let resource_initial_result = router
21151            .dispatch_stateless(&request_ctx, &resource_initial)
21152            .expect("normal final dispatch mints the resource request state");
21153        let resource_state = resource_initial_result["requestState"]
21154            .as_str()
21155            .expect("framework result carries opaque resource state")
21156            .to_owned();
21157        let resource_retry = JsonRpcRequest::new(
21158            "resources/read",
21159            Some(serde_json::json!({
21160                "_meta": metadata.clone(),
21161                "uri": "file:///input-required-resource",
21162                "inputResponses": {"roots": router_roots_response_wire()},
21163                "requestState": resource_state,
21164            })),
21165            146_i64,
21166        );
21167        let mut resource_unknown_only = resource_retry.clone();
21168        *resource_unknown_only
21169            .params
21170            .as_mut()
21171            .and_then(serde_json::Value::as_object_mut)
21172            .and_then(|params| params.get_mut("inputResponses"))
21173            .expect("resource retry contains inputResponses") = serde_json::json!({"inert": null});
21174        let resource_unknown_error = router
21175            .dispatch_stateless(&request_ctx, &resource_unknown_only)
21176            .expect_err("unknown-only inputResponses cannot consume a resource continuation");
21177        assert_eq!(resource_unknown_error.code, McpErrorCode::InvalidParams);
21178        assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
21179
21180        let mut resource_nested_value = serde_json::Value::Null;
21181        for _ in 0..=MAX_MRTR_RAW_JSON_DEPTH {
21182            resource_nested_value = serde_json::Value::Array(vec![resource_nested_value]);
21183        }
21184        let mut resource_oversized_depth = resource_retry.clone();
21185        resource_oversized_depth
21186            .params
21187            .as_mut()
21188            .and_then(serde_json::Value::as_object_mut)
21189            .and_then(|params| params.get_mut("inputResponses"))
21190            .and_then(serde_json::Value::as_object_mut)
21191            .expect("resource retry contains an inputResponses object")
21192            .insert("roots".to_owned(), resource_nested_value);
21193        let resource_depth_error = router
21194            .dispatch_stateless(&request_ctx, &resource_oversized_depth)
21195            .expect_err("only excessive response nesting is refused before retry decoding");
21196        assert_eq!(resource_depth_error.code, McpErrorCode::InvalidParams);
21197        assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
21198
21199        let resource_retry_raw_params = raw_params_for(&resource_retry);
21200        let resource_response = router
21201            .dispatch_stateless_with_raw_params(
21202                &request_ctx,
21203                &resource_retry,
21204                Some(&resource_retry_raw_params),
21205            )
21206            .expect("a framework-minted resource state resumes through the final handler");
21207        assert_eq!(resource_response["resultType"], "input_required");
21208        assert_eq!(resource_final_calls.load(Ordering::SeqCst), 2);
21209
21210        let prompt_initial = JsonRpcRequest::new(
21211            "prompts/get",
21212            Some(serde_json::json!({
21213                "_meta": metadata.clone(),
21214                "name": "input-required-prompt",
21215            })),
21216            147_i64,
21217        );
21218        let prompt_initial_result = router
21219            .dispatch_stateless(&request_ctx, &prompt_initial)
21220            .expect("normal final dispatch mints the prompt request state");
21221        let prompt_state = prompt_initial_result["requestState"]
21222            .as_str()
21223            .expect("framework result carries opaque prompt state")
21224            .to_owned();
21225        let prompt_retry = JsonRpcRequest::new(
21226            "prompts/get",
21227            Some(serde_json::json!({
21228                "_meta": metadata.clone(),
21229                "name": "input-required-prompt",
21230                "inputResponses": {"roots": router_roots_response_wire()},
21231                "requestState": prompt_state,
21232            })),
21233            147_i64,
21234        );
21235        let mut prompt_unknown_only = prompt_retry.clone();
21236        *prompt_unknown_only
21237            .params
21238            .as_mut()
21239            .and_then(serde_json::Value::as_object_mut)
21240            .and_then(|params| params.get_mut("inputResponses"))
21241            .expect("prompt retry contains inputResponses") = serde_json::json!({"inert": null});
21242        let prompt_unknown_error = router
21243            .dispatch_stateless(&request_ctx, &prompt_unknown_only)
21244            .expect_err("unknown-only inputResponses cannot consume a prompt continuation");
21245        assert_eq!(prompt_unknown_error.code, McpErrorCode::InvalidParams);
21246        assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
21247
21248        let mut prompt_oversized_values = prompt_retry.clone();
21249        prompt_oversized_values
21250            .params
21251            .as_mut()
21252            .and_then(serde_json::Value::as_object_mut)
21253            .and_then(|params| params.get_mut("inputResponses"))
21254            .and_then(serde_json::Value::as_object_mut)
21255            .expect("prompt retry contains an inputResponses object")
21256            .insert(
21257                "roots".to_owned(),
21258                serde_json::Value::Array(vec![serde_json::Value::Null; MAX_MRTR_RAW_JSON_VALUES]),
21259            );
21260        let prompt_values_error = router
21261            .dispatch_stateless(&request_ctx, &prompt_oversized_values)
21262            .expect_err(
21263                "only an oversized prompt response value set is refused before retry decoding",
21264            );
21265        assert_eq!(prompt_values_error.code, McpErrorCode::InvalidParams);
21266        assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
21267
21268        let prompt_retry_raw_params = raw_params_for(&prompt_retry);
21269        let prompt_response = router
21270            .dispatch_stateless_with_raw_params(
21271                &request_ctx,
21272                &prompt_retry,
21273                Some(&prompt_retry_raw_params),
21274            )
21275            .expect("a framework-minted prompt state resumes through the final handler");
21276        assert_eq!(prompt_response["resultType"], "input_required");
21277        assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 2);
21278
21279        // Each retry receives the prior round's typed roots response through
21280        // the handler resume hook, then emits a distinct continuation for the
21281        // next JSON-RPC round. This exercises the public final dispatch path
21282        // across tools, resources, and prompts rather than only the registry.
21283        let mut tool_second_retry = tool_retry.clone();
21284        tool_second_retry
21285            .params
21286            .as_mut()
21287            .and_then(serde_json::Value::as_object_mut)
21288            .expect("tool retry parameters are an object")
21289            .insert(
21290                "requestState".to_owned(),
21291                tool_response["requestState"].clone(),
21292            );
21293        let tool_second_response = router
21294            .dispatch_stateless(&request_ctx, &tool_second_retry)
21295            .expect("a second public tools/call round reaches the resumed handler");
21296        assert_eq!(tool_second_response["resultType"], "input_required");
21297
21298        let mut resource_second_retry = resource_retry.clone();
21299        resource_second_retry
21300            .params
21301            .as_mut()
21302            .and_then(serde_json::Value::as_object_mut)
21303            .expect("resource retry parameters are an object")
21304            .insert(
21305                "requestState".to_owned(),
21306                resource_response["requestState"].clone(),
21307            );
21308        let resource_second_response = router
21309            .dispatch_stateless(&request_ctx, &resource_second_retry)
21310            .expect("a second public resources/read round reaches the resumed handler");
21311        assert_eq!(resource_second_response["resultType"], "input_required");
21312
21313        let mut prompt_second_retry = prompt_retry.clone();
21314        prompt_second_retry
21315            .params
21316            .as_mut()
21317            .and_then(serde_json::Value::as_object_mut)
21318            .expect("prompt retry parameters are an object")
21319            .insert(
21320                "requestState".to_owned(),
21321                prompt_response["requestState"].clone(),
21322            );
21323        let prompt_second_response = router
21324            .dispatch_stateless(&request_ctx, &prompt_second_retry)
21325            .expect("a second public prompts/get round reaches the resumed handler");
21326        assert_eq!(prompt_second_response["resultType"], "input_required");
21327        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 3);
21328        assert_eq!(resource_final_calls.load(Ordering::SeqCst), 3);
21329        assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 3);
21330
21331        let replay = router
21332            .dispatch_stateless(&request_ctx, &tool_retry)
21333            .expect_err("replaying only the already consumed tool state is refused");
21334        assert_eq!(replay.code, McpErrorCode::InvalidParams);
21335        assert_eq!(
21336            tool_final_calls.load(Ordering::SeqCst),
21337            3,
21338            "replay must fail before the tool handler is invoked again"
21339        );
21340    }
21341
21342    #[test]
21343    fn modern_connection_disablements_block_final_handlers_before_mrtr_state_is_minted() {
21344        let tool_final_calls = Arc::new(AtomicUsize::new(0));
21345        let resource_final_calls = Arc::new(AtomicUsize::new(0));
21346        let prompt_final_calls = Arc::new(AtomicUsize::new(0));
21347        let mut router = Router::new();
21348        router
21349            .add_tool(InputRequiredTool {
21350                legacy_calls: Arc::new(AtomicUsize::new(0)),
21351                final_calls: Arc::clone(&tool_final_calls),
21352            })
21353            .expect("tool registration succeeds");
21354        router.add_resource(InputRequiredResource {
21355            legacy_calls: Arc::new(AtomicUsize::new(0)),
21356            final_calls: Arc::clone(&resource_final_calls),
21357        });
21358        router.add_prompt(InputRequiredPrompt {
21359            legacy_calls: Arc::new(AtomicUsize::new(0)),
21360            final_calls: Arc::clone(&prompt_final_calls),
21361        });
21362
21363        let metadata = serde_json::json!({
21364            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21365            "io.modelcontextprotocol/clientCapabilities": {},
21366        });
21367        let tool_request = JsonRpcRequest::new(
21368            "tools/call",
21369            Some(serde_json::json!({
21370                "_meta": metadata.clone(),
21371                "name": "input-required-tool",
21372                "arguments": {},
21373            })),
21374            611_i64,
21375        );
21376        let resource_request = JsonRpcRequest::new(
21377            "resources/read",
21378            Some(serde_json::json!({
21379                "_meta": metadata.clone(),
21380                "uri": "file:///input-required-resource",
21381            })),
21382            612_i64,
21383        );
21384        let prompt_request = JsonRpcRequest::new(
21385            "prompts/get",
21386            Some(serde_json::json!({
21387                "_meta": metadata,
21388                "name": "input-required-prompt",
21389            })),
21390            613_i64,
21391        );
21392        let request_bytes = [
21393            serde_json::to_vec(&tool_request).expect("tool request serializes"),
21394            serde_json::to_vec(&resource_request).expect("resource request serializes"),
21395            serde_json::to_vec(&prompt_request).expect("prompt request serializes"),
21396        ];
21397
21398        let cx = Cx::for_testing();
21399        let allowed_connection = ModernConnection::new();
21400        let allowed_inbound = InboundRequestContext::with_modern_connection(
21401            cx.clone(),
21402            611,
21403            InboundRequestTransport::Stdio,
21404            &allowed_connection,
21405        );
21406        let allowed_ctx = allowed_inbound.request_context();
21407        let allowed_cancellation = allowed_inbound
21408            .mrtr_continuation_cancellation()
21409            .expect("modern connection supplies continuation ownership");
21410
21411        for request in [&tool_request, &resource_request, &prompt_request] {
21412            let result = router
21413                .dispatch_stateless_with_continuation_cancellation(
21414                    &allowed_ctx,
21415                    request,
21416                    &allowed_cancellation,
21417                )
21418                .expect("enabled final component reaches its handler");
21419            assert_eq!(result["resultType"], "input_required");
21420            assert!(
21421                result["requestState"].is_string(),
21422                "an enabled MRTR-capable handler mints framework-owned state"
21423            );
21424        }
21425        assert_eq!(tool_final_calls.load(Ordering::SeqCst), 1);
21426        assert_eq!(resource_final_calls.load(Ordering::SeqCst), 1);
21427        assert_eq!(prompt_final_calls.load(Ordering::SeqCst), 1);
21428
21429        let denied_connection = ModernConnection::new();
21430        let denied_inbound = InboundRequestContext::with_modern_connection(
21431            cx,
21432            611,
21433            InboundRequestTransport::Stdio,
21434            &denied_connection,
21435        );
21436        let denied_ctx = denied_inbound.request_context();
21437        let denied_cancellation = denied_inbound
21438            .mrtr_continuation_cancellation()
21439            .expect("modern connection supplies continuation ownership");
21440        assert!(denied_ctx.disable_tool("input-required-tool"));
21441        assert!(denied_ctx.disable_resource("file:///input-required-resource"));
21442        assert!(denied_ctx.disable_prompt("input-required-prompt"));
21443
21444        assert_eq!(
21445            serde_json::to_vec(&tool_request).expect("tool request remains serializable"),
21446            request_bytes[0],
21447            "connection state is the sole tool-request admission difference"
21448        );
21449        let tool_error = router
21450            .dispatch_stateless_with_continuation_cancellation(
21451                &denied_ctx,
21452                &tool_request,
21453                &denied_cancellation,
21454            )
21455            .expect_err("a disabled tool cannot mint final MRTR state");
21456        assert_eq!(tool_error.code, McpErrorCode::MethodNotFound);
21457
21458        assert_eq!(
21459            serde_json::to_vec(&resource_request).expect("resource request remains serializable"),
21460            request_bytes[1],
21461            "connection state is the sole resource-request admission difference"
21462        );
21463        let resource_error = router
21464            .dispatch_stateless_with_continuation_cancellation(
21465                &denied_ctx,
21466                &resource_request,
21467                &denied_cancellation,
21468            )
21469            .expect_err("a disabled resource cannot mint final MRTR state");
21470        assert_eq!(resource_error.code, McpErrorCode::ResourceNotFound);
21471
21472        assert_eq!(
21473            serde_json::to_vec(&prompt_request).expect("prompt request remains serializable"),
21474            request_bytes[2],
21475            "connection state is the sole prompt-request admission difference"
21476        );
21477        let prompt_error = router
21478            .dispatch_stateless_with_continuation_cancellation(
21479                &denied_ctx,
21480                &prompt_request,
21481                &denied_cancellation,
21482            )
21483            .expect_err("a disabled prompt cannot mint final MRTR state");
21484        assert_eq!(prompt_error.code, McpErrorCode::PromptNotFound);
21485
21486        assert_eq!(
21487            tool_final_calls.load(Ordering::SeqCst),
21488            1,
21489            "refused tool dispatch must not invoke the MRTR-capable handler"
21490        );
21491        assert_eq!(
21492            resource_final_calls.load(Ordering::SeqCst),
21493            1,
21494            "refused resource dispatch must not invoke the MRTR-capable handler"
21495        );
21496        assert_eq!(
21497            prompt_final_calls.load(Ordering::SeqCst),
21498            1,
21499            "refused prompt dispatch must not invoke the MRTR-capable handler"
21500        );
21501    }
21502
21503    #[test]
21504    fn modern_final_catalogs_ignore_connection_disablements_while_exact_legacy_lists_remain_stateful()
21505     {
21506        let template_uri = "file:///input-required-template/{id}";
21507        let mut router = Router::new();
21508        router
21509            .add_tool(NamedTool::new("connection-scoped-tool"))
21510            .expect("tool registration succeeds");
21511        router.add_resource(NamedResource::new("file:///connection-scoped-resource"));
21512        router
21513            .add_resource_template_with_behavior(
21514                ResourceTemplate {
21515                    uri_template: template_uri.to_owned(),
21516                    name: "connection-scoped-template".to_owned(),
21517                    description: None,
21518                    mime_type: None,
21519                    icon: None,
21520                    version: None,
21521                    tags: Vec::new(),
21522                },
21523                crate::DuplicateBehavior::Replace,
21524            )
21525            .expect("resource-template registration succeeds");
21526        router.add_prompt(NamedPrompt::new("connection-scoped-prompt"));
21527
21528        let cx = Cx::for_testing();
21529        let connection = ModernConnection::new();
21530        let inbound = InboundRequestContext::with_modern_connection(
21531            cx,
21532            614,
21533            InboundRequestTransport::Stdio,
21534            &connection,
21535        );
21536        let request_ctx = inbound.request_context();
21537        let metadata = serde_json::json!({
21538            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21539            "io.modelcontextprotocol/clientCapabilities": {},
21540        });
21541        let list_request = |method, id| {
21542            JsonRpcRequest::new(
21543                method,
21544                Some(serde_json::json!({"_meta": metadata.clone()})),
21545                id,
21546            )
21547        };
21548
21549        let tools = router
21550            .dispatch_stateless(&request_ctx, &list_request("tools/list", 614_i64))
21551            .expect("enabled tool is discovered");
21552        let resources = router
21553            .dispatch_stateless(&request_ctx, &list_request("resources/list", 615_i64))
21554            .expect("enabled resource is discovered");
21555        let templates = router
21556            .dispatch_stateless(
21557                &request_ctx,
21558                &list_request("resources/templates/list", 616_i64),
21559            )
21560            .expect("enabled resource template is discovered");
21561        let prompts = router
21562            .dispatch_stateless(&request_ctx, &list_request("prompts/list", 617_i64))
21563            .expect("enabled prompt is discovered");
21564        assert_eq!(tools["tools"][0]["name"], "connection-scoped-tool");
21565        assert_eq!(
21566            resources["resources"][0]["uri"],
21567            "file:///connection-scoped-resource"
21568        );
21569        assert_eq!(
21570            templates["resourceTemplates"][0]["uriTemplate"],
21571            template_uri
21572        );
21573        assert_eq!(prompts["prompts"][0]["name"], "connection-scoped-prompt");
21574
21575        assert!(request_ctx.disable_tool("connection-scoped-tool"));
21576        assert!(request_ctx.disable_resource("file:///connection-scoped-resource"));
21577        assert!(request_ctx.disable_resource(template_uri));
21578        assert!(request_ctx.disable_prompt("connection-scoped-prompt"));
21579
21580        let discovered_tools = router
21581            .dispatch_stateless(&request_ctx, &list_request("tools/list", 618_i64))
21582            .expect("disabled tool remains in the immutable final catalog");
21583        let discovered_resources = router
21584            .dispatch_stateless(&request_ctx, &list_request("resources/list", 619_i64))
21585            .expect("disabled resource remains in the immutable final catalog");
21586        let discovered_templates = router
21587            .dispatch_stateless(
21588                &request_ctx,
21589                &list_request("resources/templates/list", 620_i64),
21590            )
21591            .expect("disabled template remains in the immutable final catalog");
21592        let discovered_prompts = router
21593            .dispatch_stateless(&request_ctx, &list_request("prompts/list", 621_i64))
21594            .expect("disabled prompt remains in the immutable final catalog");
21595        assert_eq!(
21596            discovered_tools["tools"][0]["name"],
21597            "connection-scoped-tool"
21598        );
21599        assert_eq!(
21600            discovered_resources["resources"][0]["uri"],
21601            "file:///connection-scoped-resource"
21602        );
21603        assert_eq!(
21604            discovered_templates["resourceTemplates"][0]["uriTemplate"],
21605            template_uri
21606        );
21607        assert_eq!(
21608            discovered_prompts["prompts"][0]["name"],
21609            "connection-scoped-prompt"
21610        );
21611
21612        let legacy_state = SessionState::new();
21613        let legacy_request_ctx =
21614            request_context(request_ctx.cx(), 622, Budget::INFINITE, &legacy_state);
21615        assert!(legacy_request_ctx.disable_tool("connection-scoped-tool"));
21616        assert!(legacy_request_ctx.disable_resource("file:///connection-scoped-resource"));
21617        assert!(legacy_request_ctx.disable_resource(template_uri));
21618        assert!(legacy_request_ctx.disable_prompt("connection-scoped-prompt"));
21619
21620        let legacy_tools = router
21621            .handle_tools_list(
21622                &legacy_request_ctx,
21623                ListToolsParams {
21624                    cursor: None,
21625                    include_tags: None,
21626                    exclude_tags: None,
21627                },
21628                Some(&legacy_state),
21629            )
21630            .expect("exact legacy tools/list remains session-stateful");
21631        let legacy_resources = router
21632            .handle_resources_list(
21633                &legacy_request_ctx,
21634                ListResourcesParams {
21635                    cursor: None,
21636                    include_tags: None,
21637                    exclude_tags: None,
21638                },
21639                Some(&legacy_state),
21640            )
21641            .expect("exact legacy resources/list remains session-stateful");
21642        let legacy_templates = router
21643            .handle_resource_templates_list(
21644                &legacy_request_ctx,
21645                ListResourceTemplatesParams {
21646                    cursor: None,
21647                    include_tags: None,
21648                    exclude_tags: None,
21649                },
21650                Some(&legacy_state),
21651            )
21652            .expect("exact legacy resources/templates/list remains session-stateful");
21653        let legacy_prompts = router
21654            .handle_prompts_list(
21655                &legacy_request_ctx,
21656                ListPromptsParams {
21657                    cursor: None,
21658                    include_tags: None,
21659                    exclude_tags: None,
21660                },
21661                Some(&legacy_state),
21662            )
21663            .expect("exact legacy prompts/list remains session-stateful");
21664        assert!(legacy_tools.tools.is_empty());
21665        assert!(legacy_resources.resources.is_empty());
21666        assert!(legacy_templates.resource_templates.is_empty());
21667        assert!(legacy_prompts.prompts.is_empty());
21668
21669        let later_inbound = InboundRequestContext::with_modern_connection(
21670            request_ctx.cx().clone(),
21671            630,
21672            InboundRequestTransport::Stdio,
21673            &connection,
21674        );
21675        let later_ctx = later_inbound.request_context();
21676        let read_error = router
21677            .dispatch_stateless(
21678                &later_ctx,
21679                &JsonRpcRequest::new(
21680                    "resources/read",
21681                    Some(serde_json::json!({
21682                        "_meta": metadata,
21683                        "uri": "file:///connection-scoped-resource",
21684                    })),
21685                    631_i64,
21686                ),
21687            )
21688            .expect_err(
21689                "a later inbound on the same modern connection must refuse the disabled resource",
21690            );
21691        assert_eq!(read_error.code, McpErrorCode::ResourceNotFound);
21692        assert!(
21693            read_error.message.contains("disabled"),
21694            "the refused resource read must keep the session-disabled message: {read_error:?}"
21695        );
21696        let prompt_error = router
21697            .dispatch_stateless(
21698                &later_ctx,
21699                &JsonRpcRequest::new(
21700                    "prompts/get",
21701                    Some(serde_json::json!({
21702                        "_meta": metadata,
21703                        "name": "connection-scoped-prompt",
21704                    })),
21705                    632_i64,
21706                ),
21707            )
21708            .expect_err(
21709                "a later inbound on the same modern connection must refuse the disabled prompt",
21710            );
21711        assert_eq!(prompt_error.code, McpErrorCode::PromptNotFound);
21712        assert!(
21713            prompt_error.message.contains("disabled"),
21714            "the refused prompt get must keep the session-disabled message: {prompt_error:?}"
21715        );
21716        let tool_error = router
21717            .dispatch_stateless(
21718                &later_ctx,
21719                &JsonRpcRequest::new(
21720                    "tools/call",
21721                    Some(serde_json::json!({
21722                        "_meta": metadata,
21723                        "name": "connection-scoped-tool",
21724                        "arguments": {},
21725                    })),
21726                    633_i64,
21727                ),
21728            )
21729            .expect_err(
21730                "a later inbound on the same modern connection must refuse the disabled tool",
21731            );
21732        assert_eq!(tool_error.code, McpErrorCode::MethodNotFound);
21733        assert!(
21734            tool_error.message.contains("disabled"),
21735            "the refused tool call must keep the session-disabled message: {tool_error:?}"
21736        );
21737    }
21738
21739    #[test]
21740    fn final_catalog_cursor_survives_a_changed_session_state() {
21741        let mut router = Router::new();
21742        router.set_list_page_size(Some(1));
21743        router
21744            .add_tool(NamedTool::new("first-connection-cursor-tool"))
21745            .expect("first tool registration succeeds");
21746        router
21747            .add_tool(NamedTool::new("second-connection-cursor-tool"))
21748            .expect("second tool registration succeeds");
21749
21750        let cx = Cx::for_testing();
21751        let state = SessionState::new();
21752        let request_ctx = request_context(&cx, 622, Budget::INFINITE, &state);
21753        let initial = final_tools_list_request(None, None, None, 622_i64);
21754        let initial_bytes = serde_json::to_vec(&initial).expect("initial request serializes");
21755        let first_page = router
21756            .dispatch_stateless(&request_ctx, &initial)
21757            .expect("the initial final catalog page is admitted");
21758        let cursor = first_page["nextCursor"]
21759            .as_str()
21760            .expect("the first page has a continuation")
21761            .to_owned();
21762
21763        assert!(request_ctx.disable_tool("first-connection-cursor-tool"));
21764        assert_eq!(
21765            serde_json::to_vec(&initial).expect("initial request remains serializable"),
21766            initial_bytes,
21767            "connection state cannot alter the request or its catalog continuation"
21768        );
21769        let continued = router
21770            .dispatch_stateless(
21771                &request_ctx,
21772                &final_tools_list_request(Some(&cursor), None, None, 623_i64),
21773            )
21774            .expect("a disabled-component change cannot invalidate an immutable final cursor");
21775        assert_eq!(
21776            continued["tools"][0]["name"],
21777            "second-connection-cursor-tool"
21778        );
21779        assert!(continued.get("nextCursor").is_none());
21780
21781        let refreshed = router
21782            .dispatch_stateless(
21783                &request_ctx,
21784                &final_tools_list_request(None, None, None, 624_i64),
21785            )
21786            .expect("a cursor-free request keeps the immutable final catalog");
21787        assert_eq!(
21788            refreshed["tools"][0]["name"],
21789            "first-connection-cursor-tool"
21790        );
21791    }
21792
21793    #[test]
21794    fn final_catalog_cursor_continues_across_modern_connection_partitions() {
21795        let mut router = Router::new();
21796        router.set_list_page_size(Some(1));
21797        router
21798            .add_tool(NamedTool::new("first-cross-connection-cursor-tool"))
21799            .expect("first tool registration succeeds");
21800        router
21801            .add_tool(NamedTool::new("second-cross-connection-cursor-tool"))
21802            .expect("second tool registration succeeds");
21803
21804        let cx = Cx::for_testing();
21805        let origin_connection = ModernConnection::new();
21806        let origin_inbound = InboundRequestContext::with_modern_connection(
21807            cx.clone(),
21808            625,
21809            InboundRequestTransport::Stdio,
21810            &origin_connection,
21811        );
21812        let origin_context = origin_inbound.request_context();
21813        let continuation_connection = ModernConnection::new();
21814        let continuation_inbound = InboundRequestContext::with_modern_connection(
21815            cx,
21816            626,
21817            InboundRequestTransport::Stdio,
21818            &continuation_connection,
21819        );
21820        let continuation_context = continuation_inbound.request_context();
21821        let origin_partition = origin_context
21822            .session_cache_partition()
21823            .expect("a modern origin context has a durable partition")
21824            .0;
21825        let continuation_partition = continuation_context
21826            .session_cache_partition()
21827            .expect("a modern continuation context has a durable partition")
21828            .0;
21829        assert_ne!(
21830            origin_partition, continuation_partition,
21831            "distinct modern connections have distinct durable partitions"
21832        );
21833
21834        let first_page = router
21835            .dispatch_stateless(
21836                &origin_context,
21837                &final_tools_list_request(None, None, None, 625_i64),
21838            )
21839            .expect("the origin connection receives the first final catalog page");
21840        let cursor = first_page["nextCursor"]
21841            .as_str()
21842            .expect("the first page has a continuation")
21843            .to_owned();
21844
21845        let continued = router
21846            .dispatch_stateless(
21847                &continuation_context,
21848                &final_tools_list_request(Some(&cursor), None, None, 626_i64),
21849            )
21850            .expect("a final catalog cursor is not bound to its origin connection partition");
21851        assert_eq!(
21852            continued["tools"][0]["name"],
21853            "second-cross-connection-cursor-tool"
21854        );
21855        assert!(continued.get("nextCursor").is_none());
21856    }
21857
21858    #[test]
21859    fn modern_connection_disconnect_is_the_only_changed_retry_dimension_and_cancels_state() {
21860        let final_calls = Arc::new(AtomicUsize::new(0));
21861        let mut router = Router::new();
21862        router
21863            .add_tool(InputRequiredTool {
21864                legacy_calls: Arc::new(AtomicUsize::new(0)),
21865                final_calls: Arc::clone(&final_calls),
21866            })
21867            .expect("tool registration succeeds");
21868
21869        let cx = Cx::for_testing();
21870        let connection = ModernConnection::new();
21871        let inbound = InboundRequestContext::with_modern_connection(
21872            cx.clone(),
21873            301,
21874            InboundRequestTransport::Stdio,
21875            &connection,
21876        );
21877        let request_ctx = inbound.request_context();
21878        let continuation_cancellation = inbound
21879            .mrtr_continuation_cancellation()
21880            .expect("a modern connection supplies continuation ownership");
21881        let metadata = serde_json::json!({
21882            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21883            "io.modelcontextprotocol/clientCapabilities": {},
21884        });
21885        let initial = JsonRpcRequest::new(
21886            "tools/call",
21887            Some(serde_json::json!({
21888                "_meta": metadata.clone(),
21889                "name": "input-required-tool",
21890                "arguments": {},
21891            })),
21892            301_i64,
21893        );
21894        let initial_result = router
21895            .dispatch_stateless_with_continuation_cancellation(
21896                &request_ctx,
21897                &initial,
21898                &continuation_cancellation,
21899            )
21900            .expect("the connected request mints a continuation");
21901        let retry = JsonRpcRequest::new(
21902            "tools/call",
21903            Some(serde_json::json!({
21904                "_meta": metadata,
21905                "name": "input-required-tool",
21906                "arguments": {},
21907                "inputResponses": {"roots": router_roots_response_wire()},
21908                "requestState": initial_result["requestState"].clone(),
21909            })),
21910            302_i64,
21911        );
21912        let retry_before_disconnect = serde_json::to_vec(&retry).expect("retry serializes");
21913
21914        // The request, opaque state, durable partition, and response map all
21915        // remain unchanged. Peer disconnect is the sole changed dimension.
21916        connection.disconnect();
21917        let error = router
21918            .dispatch_stateless_with_continuation_cancellation(
21919                &request_ctx,
21920                &retry,
21921                &continuation_cancellation,
21922            )
21923            .expect_err("disconnect must cancel the retained continuation");
21924        assert_eq!(error.code, McpErrorCode::RequestCancelled);
21925        assert_eq!(
21926            serde_json::to_vec(&retry).expect("retry serializes"),
21927            retry_before_disconnect,
21928            "disconnect cancellation cannot mutate client retry state"
21929        );
21930        assert_eq!(
21931            final_calls.load(Ordering::SeqCst),
21932            1,
21933            "a disconnected continuation cannot reach the resumed handler"
21934        );
21935    }
21936
21937    #[cfg(feature = "tasks")]
21938    #[test]
21939    fn task_capable_input_required_retry_does_not_require_tasks_capability() {
21940        let final_calls = Arc::new(AtomicUsize::new(0));
21941        let mut router = Router::new();
21942        router
21943            .add_tool(TaskCapableInputRequiredTool {
21944                final_calls: Arc::clone(&final_calls),
21945            })
21946            .expect("task-capable input-required tool registration succeeds");
21947        let cx = Cx::for_testing();
21948        let connection = ModernConnection::new();
21949        let inbound = InboundRequestContext::with_modern_connection(
21950            cx,
21951            148,
21952            InboundRequestTransport::Memory,
21953            &connection,
21954        );
21955        let request_ctx = inbound.request_context();
21956        let metadata_without_tasks = serde_json::json!({
21957            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
21958            "io.modelcontextprotocol/clientCapabilities": {},
21959        });
21960
21961        let initial = JsonRpcRequest::new(
21962            "tools/call",
21963            Some(serde_json::json!({
21964                "_meta": metadata_without_tasks.clone(),
21965                "name": "task-capable-input-required-tool",
21966                "arguments": {},
21967            })),
21968            148_i64,
21969        );
21970        let initial_result = router
21971            .dispatch_stateless(&request_ctx, &initial)
21972            .expect("a task-capable tool may return input_required without Tasks negotiation");
21973        let request_state = initial_result["requestState"]
21974            .as_str()
21975            .expect("framework result carries opaque task-capable state")
21976            .to_owned();
21977        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
21978
21979        let retry = JsonRpcRequest::new(
21980            "tools/call",
21981            Some(serde_json::json!({
21982                "_meta": metadata_without_tasks,
21983                "name": "task-capable-input-required-tool",
21984                "arguments": {},
21985                "inputResponses": {"roots": router_roots_response_wire()},
21986                "requestState": request_state,
21987            })),
21988            149_i64,
21989        );
21990        let resumed = router
21991            .dispatch_stateless(&request_ctx, &retry)
21992            .expect("a task-capable input-required retry remains an ordinary MRTR operation");
21993        assert_eq!(resumed["resultType"], "input_required");
21994        assert_eq!(final_calls.load(Ordering::SeqCst), 2);
21995    }
21996
21997    #[test]
21998    fn final_input_required_does_not_fallback_without_negotiation_metadata() {
21999        let legacy_calls = Arc::new(AtomicUsize::new(0));
22000        let final_calls = Arc::new(AtomicUsize::new(0));
22001        let mut router = Router::new();
22002        router
22003            .add_tool(InputRequiredTool {
22004                legacy_calls: Arc::clone(&legacy_calls),
22005                final_calls: Arc::clone(&final_calls),
22006            })
22007            .expect("tool registration succeeds");
22008        let cx = Cx::for_testing();
22009        let connection = ModernConnection::new();
22010        let inbound = InboundRequestContext::with_modern_connection(
22011            cx,
22012            144,
22013            InboundRequestTransport::Memory,
22014            &connection,
22015        );
22016        let request_ctx = inbound.request_context();
22017        let baseline = JsonRpcRequest::new(
22018            "tools/call",
22019            Some(serde_json::json!({
22020                "_meta": {
22021                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22022                    "io.modelcontextprotocol/clientCapabilities": {},
22023                },
22024                "name": "input-required-tool",
22025                "arguments": {},
22026            })),
22027            144_i64,
22028        );
22029        let mut planted = baseline.clone();
22030        planted
22031            .params
22032            .as_mut()
22033            .and_then(serde_json::Value::as_object_mut)
22034            .expect("tools/call parameters are an object")
22035            .remove("_meta");
22036
22037        assert_eq!(baseline.method, planted.method);
22038        assert_eq!(baseline.id, planted.id);
22039        assert_eq!(
22040            baseline
22041                .params
22042                .as_ref()
22043                .and_then(|params| params.get("name")),
22044            planted
22045                .params
22046                .as_ref()
22047                .and_then(|params| params.get("name")),
22048            "negotiation metadata is the sole planted dimension"
22049        );
22050        let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
22051        let planted_before = serde_json::to_vec(&planted).expect("request serializes");
22052
22053        let accepted = router
22054            .dispatch_stateless(&request_ctx, &baseline)
22055            .expect("negotiated final request is accepted");
22056        assert_eq!(accepted["resultType"], "input_required");
22057        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22058        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22059
22060        let error = router
22061            .dispatch_stateless(&request_ctx, &planted)
22062            .expect_err("one-field no-negotiation request is rejected instead of falling back");
22063        assert_eq!(error.code, McpErrorCode::InvalidParams);
22064        assert_eq!(
22065            serde_json::to_vec(&planted).expect("rejected request serializes"),
22066            planted_before,
22067            "rejection cannot mutate caller-owned no-negotiation parameters"
22068        );
22069        assert_eq!(
22070            serde_json::to_vec(&router.tools()).expect("catalog serializes"),
22071            catalog_before,
22072            "rejection cannot mutate the installed handler catalog"
22073        );
22074        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22075        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22076        let retried = router
22077            .dispatch_stateless(&request_ctx, &baseline)
22078            .expect("the negotiated baseline remains accepted after rejection");
22079        assert_eq!(retried["resultType"], "input_required");
22080        assert_ne!(
22081            retried["requestState"], accepted["requestState"],
22082            "every framework-issued MRTR continuation has fresh opaque state"
22083        );
22084        assert_eq!(final_calls.load(Ordering::SeqCst), 2);
22085        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22086    }
22087
22088    #[test]
22089    fn final_prompts_get_dispatches_direct_request_owned_handler() {
22090        let final_calls = Arc::new(AtomicUsize::new(0));
22091        let mut router = Router::new();
22092        router.add_prompt(DirectFinalPrompt {
22093            final_calls: Arc::clone(&final_calls),
22094        });
22095        let cx = Cx::for_testing();
22096        let state = SessionState::new();
22097        let request_ctx = request_context(&cx, 96, Budget::INFINITE, &state);
22098        let request = direct_final_prompt_request(96);
22099        let typed_request = CoreRequest::decode(
22100            ProtocolEra::Modern2026,
22101            "prompts/get",
22102            request.params.as_ref(),
22103        )
22104        .expect("final prompts/get request decodes through the public core surface");
22105
22106        let response = router
22107            .dispatch_stateless(&request_ctx, &request)
22108            .expect("final prompts/get reaches the direct final handler");
22109
22110        assert_eq!(
22111            response.get("resultType"),
22112            Some(&serde_json::json!("complete"))
22113        );
22114        assert_eq!(
22115            response["description"],
22116            serde_json::json!("direct final prompt description")
22117        );
22118        assert_eq!(response["messages"][0]["content"]["type"], "audio");
22119        assert_eq!(
22120            response["messages"][0]["content"]["_meta"]["com.example/direct-prompt"]["source"],
22121            "final-handler"
22122        );
22123        assert_eq!(
22124            response["messages"][0]["content"]["com.example/direct-field"],
22125            true
22126        );
22127        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22128
22129        let wire = serde_json::to_string(&response).expect("final prompt response serializes");
22130        let CoreResult::Final(FinalCoreResult::PromptsGet { result, .. }) = typed_request
22131            .decode_result(&wire)
22132            .expect("final prompts/get result decodes through the public core surface")
22133        else {
22134            panic!("prompts/get selects the exact final result");
22135        };
22136        assert_eq!(
22137            result.payload.description.as_deref(),
22138            Some("direct final prompt description")
22139        );
22140        assert!(matches!(
22141            result.payload.messages.as_slice(),
22142            [FinalPromptMessage {
22143                content: ContentBlock::Audio { data, mime_type, .. },
22144                ..
22145            }] if data == "aGVsbG8=" && mime_type == "audio/mpeg"
22146        ));
22147    }
22148
22149    #[test]
22150    fn public_final_resource_uri_policy_admits_client_direct_https_and_rejects_only_policy_change_without_mutation()
22151     {
22152        let client_direct = HttpsCatalogResource {
22153            client_direct_https: true,
22154        };
22155        let server_mediated = HttpsCatalogResource {
22156            client_direct_https: false,
22157        };
22158        assert_eq!(
22159            client_direct.definition(),
22160            server_mediated.definition(),
22161            "the URI-use policy is the sole registration difference"
22162        );
22163
22164        let cx = Cx::for_testing();
22165        let state = SessionState::new();
22166        let request_ctx = request_context(&cx, 971, Budget::INFINITE, &state);
22167        let final_metadata = serde_json::json!({
22168            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22169            "io.modelcontextprotocol/clientCapabilities": {},
22170        });
22171
22172        let mut accepted_router = Router::new();
22173        accepted_router
22174            .add_resource_with_behavior(client_direct, crate::DuplicateBehavior::Replace)
22175            .expect("an explicitly client-direct HTTPS catalog resource is admitted");
22176        let accepted = accepted_router
22177            .dispatch_stateless(
22178                &request_ctx,
22179                &JsonRpcRequest::new(
22180                    "resources/list",
22181                    Some(serde_json::json!({"_meta": final_metadata.clone()})),
22182                    971_i64,
22183                ),
22184            )
22185            .expect("public final resource listing succeeds");
22186        assert_eq!(
22187            accepted["resources"][0]["uri"],
22188            "https://client.example.test/catalog.txt"
22189        );
22190        let direct_read = accepted_router
22191            .dispatch_stateless(
22192                &request_ctx,
22193                &JsonRpcRequest::new(
22194                    "resources/read",
22195                    Some(serde_json::json!({
22196                        "_meta": final_metadata.clone(),
22197                        "uri": "https://client.example.test/catalog.txt",
22198                    })),
22199                    972_i64,
22200                ),
22201            )
22202            .expect_err("client-direct HTTPS catalog entries are not MCP-read identities");
22203        assert_eq!(direct_read.code, McpErrorCode::InvalidParams);
22204
22205        let mut rejected_router = Router::new();
22206        rejected_router.add_resource(NamedResource::new("mcp://uri-policy/unchanged"));
22207        let catalog_before =
22208            serde_json::to_vec(&rejected_router.resources()).expect("existing catalog serializes");
22209        let count_before = rejected_router.resources_count();
22210        let error = rejected_router
22211            .add_resource_with_behavior(server_mediated, crate::DuplicateBehavior::Replace)
22212            .expect_err("changing only to server-mediated policy rejects HTTPS registration");
22213        assert_eq!(error.code, McpErrorCode::InvalidParams);
22214        assert_eq!(
22215            rejected_router.resources_count(),
22216            count_before,
22217            "rejected final admission cannot add or replace a resource handler"
22218        );
22219        assert_eq!(
22220            serde_json::to_vec(&rejected_router.resources()).expect("catalog remains serializable"),
22221            catalog_before,
22222            "rejected URI-use admission leaves the prior catalog unchanged"
22223        );
22224        let unchanged = rejected_router
22225            .dispatch_stateless(
22226                &request_ctx,
22227                &JsonRpcRequest::new(
22228                    "resources/list",
22229                    Some(serde_json::json!({"_meta": final_metadata})),
22230                    972_i64,
22231                ),
22232            )
22233            .expect("rejection leaves public final dispatch usable");
22234        assert_eq!(unchanged["resources"].as_array().map(Vec::len), Some(1));
22235        assert_eq!(
22236            unchanged["resources"][0]["uri"],
22237            "mcp://uri-policy/unchanged"
22238        );
22239    }
22240
22241    #[test]
22242    fn resource_uri_use_policy_leaves_exact_2024_resource_registration_unchanged() {
22243        let mut router = Router::new();
22244        router
22245            .add_legacy_resource_with_behavior(
22246                HttpsCatalogResource {
22247                    client_direct_https: false,
22248                },
22249                crate::DuplicateBehavior::Replace,
22250            )
22251            .expect("exact-2024 registration does not consult final URI-use admission");
22252
22253        let cx = Cx::for_testing();
22254        let request_ctx = McpContext::new(cx, 976);
22255        let listed = router
22256            .handle_resources_list(&request_ctx, ListResourcesParams::default(), None)
22257            .expect("exact-2024 listing remains available");
22258        assert_eq!(listed.resources.len(), 1);
22259        assert_eq!(
22260            listed.resources[0].uri,
22261            "https://client.example.test/catalog.txt"
22262        );
22263    }
22264
22265    #[test]
22266    fn public_final_uri_policy_rechecks_prompt_and_mrtr_resource_emissions() {
22267        let initial_calls = Arc::new(AtomicUsize::new(0));
22268        let resumed_calls = Arc::new(AtomicUsize::new(0));
22269        let mut router = Router::new();
22270        router.add_prompt(ClientDirectHttpsPrompt);
22271        router.add_resource(MrtrHttpsEmbeddedResource {
22272            initial_calls: Arc::clone(&initial_calls),
22273            resumed_calls: Arc::clone(&resumed_calls),
22274        });
22275
22276        let cx = Cx::for_testing();
22277        let state = SessionState::new();
22278        let request_ctx = request_context(&cx, 973, Budget::INFINITE, &state);
22279        let metadata = serde_json::json!({
22280            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22281            "io.modelcontextprotocol/clientCapabilities": {},
22282        });
22283
22284        let prompt = router
22285            .dispatch_stateless(
22286                &request_ctx,
22287                &JsonRpcRequest::new(
22288                    "prompts/get",
22289                    Some(serde_json::json!({
22290                        "_meta": metadata.clone(),
22291                        "name": "client-direct-https-prompt",
22292                    })),
22293                    973_i64,
22294                ),
22295            )
22296            .expect("a client-direct HTTPS resource link is emitted from the public prompt path");
22297        assert_eq!(
22298            prompt["messages"][0]["content"]["uri"],
22299            "https://client.example.test/prompt-link"
22300        );
22301
22302        let initial = router
22303            .dispatch_stateless(
22304                &request_ctx,
22305                &JsonRpcRequest::new(
22306                    "resources/read",
22307                    Some(serde_json::json!({
22308                        "_meta": metadata.clone(),
22309                        "uri": "mcp://uri-policy/mrtr",
22310                    })),
22311                    974_i64,
22312                ),
22313            )
22314            .expect("initial public resource request mints MRTR state");
22315        assert_eq!(initial["resultType"], "input_required");
22316        let request_state = initial["requestState"]
22317            .as_str()
22318            .expect("framework minted MRTR state")
22319            .to_owned();
22320        let catalog_before = serde_json::to_vec(&router.resources())
22321            .expect("catalog serializes before the resumed rejection");
22322        let retry = JsonRpcRequest::new(
22323            "resources/read",
22324            Some(serde_json::json!({
22325                "_meta": metadata,
22326                "uri": "mcp://uri-policy/mrtr",
22327                "inputResponses": {"roots": router_roots_response_wire()},
22328                "requestState": request_state,
22329            })),
22330            975_i64,
22331        );
22332        let error = router.dispatch_stateless(&request_ctx, &retry).expect_err(
22333            "an MRTR-resumed HTTPS embedded resource remains server-mediated and is refused",
22334        );
22335        assert_eq!(error.code, McpErrorCode::InternalError);
22336        assert_eq!(initial_calls.load(Ordering::SeqCst), 1);
22337        assert_eq!(resumed_calls.load(Ordering::SeqCst), 1);
22338        assert_eq!(
22339            serde_json::to_vec(&router.resources()).expect("catalog remains serializable"),
22340            catalog_before,
22341            "rejected dynamic MRTR output cannot mutate registered resource state"
22342        );
22343    }
22344
22345    #[test]
22346    fn final_prompt_arguments_are_validated_before_handler_and_legacy_is_unchanged() {
22347        let final_calls = Arc::new(AtomicUsize::new(0));
22348        let legacy_calls = Arc::new(AtomicUsize::new(0));
22349        let mut router = Router::new();
22350        router.add_prompt(PromptArgumentBoundary {
22351            final_calls: Arc::clone(&final_calls),
22352            legacy_calls: Arc::clone(&legacy_calls),
22353        });
22354        let cx = Cx::for_testing();
22355        let state = SessionState::new();
22356        let request_ctx = request_context(&cx, 155, Budget::INFINITE, &state);
22357        let baseline = final_prompt_get_request(
22358            "prompt-argument-boundary",
22359            serde_json::json!({"topic": "release"}),
22360            155_i64,
22361        );
22362        let mut missing_required = baseline.clone();
22363        let removed_topic = missing_required
22364            .params
22365            .as_mut()
22366            .and_then(serde_json::Value::as_object_mut)
22367            .and_then(|params| params.get_mut("arguments"))
22368            .and_then(serde_json::Value::as_object_mut)
22369            .expect("prompt arguments are an object")
22370            .remove("topic");
22371        assert_eq!(removed_topic, Some(serde_json::json!("release")));
22372
22373        assert_eq!(baseline.method, missing_required.method);
22374        assert_eq!(baseline.id, missing_required.id);
22375        assert_eq!(
22376            baseline
22377                .params
22378                .as_ref()
22379                .and_then(|params| params.get("_meta")),
22380            missing_required
22381                .params
22382                .as_ref()
22383                .and_then(|params| params.get("_meta")),
22384            "the required argument is the sole planted dimension"
22385        );
22386        assert_eq!(
22387            baseline
22388                .params
22389                .as_ref()
22390                .and_then(|params| params.get("name")),
22391            missing_required
22392                .params
22393                .as_ref()
22394                .and_then(|params| params.get("name")),
22395            "the required argument is the sole planted dimension"
22396        );
22397
22398        let accepted = router
22399            .dispatch_stateless(&request_ctx, &baseline)
22400            .expect("the complete required-argument request is accepted");
22401        assert_eq!(accepted["resultType"], "complete");
22402        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22403        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22404
22405        let missing_error = router
22406            .dispatch_stateless(&request_ctx, &missing_required)
22407            .expect_err("removing only a required prompt argument is rejected");
22408        assert_eq!(missing_error.code, McpErrorCode::InvalidParams);
22409        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22410        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22411
22412        let unknown_error = router
22413            .dispatch_stateless(
22414                &request_ctx,
22415                &final_prompt_get_request("unknown-prompt", serde_json::json!({}), 156_i64),
22416            )
22417            .expect_err("an unknown final prompt is an invalid-params protocol error");
22418        assert_eq!(unknown_error.code, McpErrorCode::InvalidParams);
22419        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22420
22421        let legacy = router
22422            .handle_prompts_get(
22423                &request_ctx,
22424                GetPromptParams {
22425                    name: "prompt-argument-boundary".to_owned(),
22426                    arguments: None,
22427                    meta: None,
22428                },
22429                state,
22430                None,
22431                None,
22432            )
22433            .expect("the exact legacy prompt path retains its existing argument behavior");
22434        let legacy_wire = serde_json::to_value(&legacy).expect("legacy prompt result serializes");
22435        assert_eq!(
22436            legacy_wire["messages"][0]["content"]["text"],
22437            "legacy prompt-argument-boundary result"
22438        );
22439        assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
22440    }
22441
22442    #[test]
22443    fn final_prompt_argument_snapshot_is_immutable_after_registration() {
22444        let expose_admitted_argument = Arc::new(AtomicBool::new(true));
22445        let final_calls = Arc::new(AtomicUsize::new(0));
22446        let mut router = Router::new();
22447        router.add_prompt(MutablePromptDefinition {
22448            expose_admitted_argument: Arc::clone(&expose_admitted_argument),
22449            final_calls: Arc::clone(&final_calls),
22450        });
22451        expose_admitted_argument.store(false, Ordering::SeqCst);
22452
22453        let cx = Cx::for_testing();
22454        let state = SessionState::new();
22455        let request_ctx = request_context(&cx, 156, Budget::INFINITE, &state);
22456        let accepted = final_prompt_get_request(
22457            "mutable-prompt-definition",
22458            serde_json::json!({"topic": "release"}),
22459            156_i64,
22460        );
22461        let mut missing_required = accepted.clone();
22462        let removed = missing_required
22463            .params
22464            .as_mut()
22465            .and_then(serde_json::Value::as_object_mut)
22466            .and_then(|params| params.get_mut("arguments"))
22467            .and_then(serde_json::Value::as_object_mut)
22468            .expect("prompt arguments are an object")
22469            .remove("topic");
22470        assert_eq!(removed, Some(serde_json::json!("release")));
22471        assert_eq!(accepted.method, missing_required.method);
22472        assert_eq!(accepted.id, missing_required.id);
22473        assert_eq!(
22474            accepted
22475                .params
22476                .as_ref()
22477                .and_then(|params| params.get("_meta")),
22478            missing_required
22479                .params
22480                .as_ref()
22481                .and_then(|params| params.get("_meta")),
22482            "the required argument is the sole planted dimension"
22483        );
22484        assert_eq!(
22485            accepted
22486                .params
22487                .as_ref()
22488                .and_then(|params| params.get("name")),
22489            missing_required
22490                .params
22491                .as_ref()
22492                .and_then(|params| params.get("name")),
22493            "the required argument is the sole planted dimension"
22494        );
22495
22496        let catalog = router
22497            .dispatch_stateless(
22498                &request_ctx,
22499                &JsonRpcRequest::new(
22500                    "prompts/list",
22501                    Some(serde_json::json!({
22502                        "_meta": {
22503                            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22504                            "io.modelcontextprotocol/clientCapabilities": {},
22505                        },
22506                    })),
22507                    157_i64,
22508                ),
22509            )
22510            .expect("the final prompt catalog retains its admitted argument metadata");
22511        assert_eq!(catalog["prompts"][0]["arguments"][0]["name"], "topic");
22512        assert_eq!(catalog["prompts"][0]["arguments"][0]["required"], true);
22513
22514        let response = router
22515            .dispatch_stateless(&request_ctx, &accepted)
22516            .expect("the admitted final argument remains accepted after the legacy hook changes");
22517        assert_eq!(response["resultType"], "complete");
22518        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22519
22520        let error = router
22521            .dispatch_stateless(&request_ctx, &missing_required)
22522            .expect_err("removing only the admitted required argument is rejected");
22523        assert_eq!(error.code, McpErrorCode::InvalidParams);
22524        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22525    }
22526
22527    #[test]
22528    fn final_prompts_get_rejects_one_field_incompatible_result_type() {
22529        let final_calls = Arc::new(AtomicUsize::new(0));
22530        let mut router = Router::new();
22531        router.add_prompt(DirectFinalPrompt {
22532            final_calls: Arc::clone(&final_calls),
22533        });
22534        let cx = Cx::for_testing();
22535        let state = SessionState::new();
22536        let request_ctx = request_context(&cx, 97, Budget::INFINITE, &state);
22537        let request = direct_final_prompt_request(97);
22538        let typed_request = CoreRequest::decode(
22539            ProtocolEra::Modern2026,
22540            "prompts/get",
22541            request.params.as_ref(),
22542        )
22543        .expect("final prompts/get request decodes through the public core surface");
22544        let accepted = router
22545            .dispatch_stateless(&request_ctx, &request)
22546            .expect("the direct final prompt response is accepted");
22547        let accepted_wire = serde_json::to_string(&accepted).expect("accepted result serializes");
22548        let mut incompatible = accepted.clone();
22549        incompatible["resultType"] = serde_json::json!("input_required");
22550
22551        let mut accepted_without_type = accepted.clone();
22552        let mut incompatible_without_type = incompatible.clone();
22553        let accepted_type = accepted_without_type
22554            .as_object_mut()
22555            .and_then(|object| object.remove("resultType"));
22556        let incompatible_type = incompatible_without_type
22557            .as_object_mut()
22558            .and_then(|object| object.remove("resultType"));
22559        assert_eq!(accepted_type, Some(serde_json::json!("complete")));
22560        assert_eq!(incompatible_type, Some(serde_json::json!("input_required")));
22561        assert_eq!(
22562            incompatible_without_type, accepted_without_type,
22563            "resultType is the sole incompatible result dimension"
22564        );
22565
22566        let incompatible_wire =
22567            serde_json::to_string(&incompatible).expect("incompatible result serializes");
22568        assert!(matches!(
22569            typed_request.decode_result(&incompatible_wire),
22570            Err(fastmcp_protocol::CoreDispatchError::ResultCodec(_))
22571        ));
22572        assert_eq!(
22573            serde_json::to_string(&accepted).expect("accepted result remains serializable"),
22574            accepted_wire,
22575            "rejecting the one-field incompatible result cannot mutate the accepted response"
22576        );
22577        assert_eq!(
22578            final_calls.load(Ordering::SeqCst),
22579            1,
22580            "local result admission cannot invoke or mutate the direct prompt handler"
22581        );
22582        let reaccepted = typed_request
22583            .decode_result(&accepted_wire)
22584            .expect("the original direct final result remains accepted");
22585        assert_eq!(
22586            serde_json::from_str::<serde_json::Value>(
22587                &reaccepted.encode().expect("reaccepted result encodes"),
22588            )
22589            .expect("reaccepted result is JSON"),
22590            accepted,
22591            "the incompatible result cannot alter the accepted final prompt contract"
22592        );
22593    }
22594
22595    #[test]
22596    fn final_resources_read_dispatches_direct_handler_without_legacy_projection() {
22597        let legacy_calls = Arc::new(AtomicUsize::new(0));
22598        let final_calls = Arc::new(AtomicUsize::new(0));
22599        let mut router = Router::new();
22600        router.add_resource(DirectFinalResource {
22601            legacy_calls: Arc::clone(&legacy_calls),
22602            final_calls: Arc::clone(&final_calls),
22603        });
22604        let cx = Cx::for_testing();
22605        let state = SessionState::new();
22606        let request_ctx = request_context(&cx, 98, Budget::INFINITE, &state);
22607
22608        let legacy = router
22609            .handle_resources_read(
22610                &request_ctx,
22611                &ReadResourceParams {
22612                    uri: "file:///direct-final-resource".to_owned(),
22613                    meta: None,
22614                },
22615                state.clone(),
22616                None,
22617                None,
22618            )
22619            .expect("legacy resource requests retain their exact handler path");
22620        let [LegacyResourceContent::Text { text, .. }] = legacy.contents.as_slice() else {
22621            panic!("legacy resource result retains its exact text variant");
22622        };
22623        assert_eq!(text, "legacy resource result");
22624        assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
22625        assert_eq!(final_calls.load(Ordering::SeqCst), 0);
22626
22627        let request = direct_final_resource_request(98);
22628        let typed_request = CoreRequest::decode(
22629            ProtocolEra::Modern2026,
22630            "resources/read",
22631            request.params.as_ref(),
22632        )
22633        .expect("final resources/read request decodes through the public core surface");
22634        let response = router
22635            .dispatch_stateless(&request_ctx, &request)
22636            .expect("final resources/read reaches the direct final handler");
22637
22638        assert_eq!(response["resultType"], "complete");
22639        assert_eq!(response["ttlMs"], 321);
22640        assert_eq!(response["cacheScope"], "public");
22641        assert_eq!(
22642            response["contents"][0]["text"],
22643            "direct final resource result"
22644        );
22645        assert_eq!(response["contents"][0]["mimeType"], "text/markdown");
22646        assert_eq!(
22647            response["contents"][0]["_meta"]["com.example/direct-resource"]["source"],
22648            "final-handler"
22649        );
22650        assert_eq!(response["contents"][0]["com.example/direct-field"], true);
22651        assert_eq!(legacy_calls.load(Ordering::SeqCst), 1);
22652        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22653
22654        let wire = serde_json::to_string(&response).expect("final resource response serializes");
22655        let CoreResult::Final(FinalCoreResult::ResourcesRead { result, .. }) = typed_request
22656            .decode_result(&wire)
22657            .expect("final resource result decodes through the public core surface")
22658        else {
22659            panic!("resources/read selects the exact final result");
22660        };
22661        assert_eq!(result.payload.ttl_ms.as_str(), "321");
22662        assert_eq!(result.payload.cache_scope, CacheScope::Public);
22663        assert!(matches!(
22664            result.payload.contents.as_slice(),
22665            [EmbeddedResourceContents::Text { text, mime_type, .. }]
22666                if text == "direct final resource result"
22667                    && mime_type.as_deref() == Some("text/markdown")
22668        ));
22669    }
22670
22671    #[test]
22672    fn resource_read_cache_hint_provenance_controls_router_policy_not_wire_value() {
22673        let cx = Cx::for_testing();
22674        let state = SessionState::new();
22675        let request_ctx = request_context(&cx, 981, Budget::INFINITE, &state);
22676        let request = JsonRpcRequest::new(
22677            "resources/read",
22678            Some(serde_json::json!({
22679                "_meta": {
22680                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22681                    "io.modelcontextprotocol/clientCapabilities": {},
22682                },
22683                "uri": "file:///sentinel-hint-resource",
22684            })),
22685            981_i64,
22686        );
22687
22688        for (provenance, expected_ttl, expected_scope) in [
22689            (
22690                FinalResourceReadCacheHintProvenance::Explicit,
22691                DEFAULT_FINAL_RESOURCE_TTL_MS,
22692                "private",
22693            ),
22694            (
22695                FinalResourceReadCacheHintProvenance::RouterPolicy,
22696                23,
22697                "public",
22698            ),
22699        ] {
22700            let mut router = Router::new();
22701            router.set_final_cache_hint_policy(
22702                CacheTtl::milliseconds(17),
22703                CacheTtl::milliseconds(23),
22704                CacheScope::Public,
22705            );
22706            router.add_resource(SentinelHintResource { provenance });
22707
22708            let response = router
22709                .dispatch_stateless(&request_ctx, &request)
22710                .expect("final resource result dispatches");
22711            assert_eq!(response["ttlMs"], expected_ttl);
22712            assert_eq!(response["cacheScope"], expected_scope);
22713        }
22714    }
22715
22716    #[test]
22717    fn final_unknown_resource_is_invalid_params_with_exact_uri_and_legacy_is_unchanged() {
22718        let legacy_calls = Arc::new(AtomicUsize::new(0));
22719        let final_calls = Arc::new(AtomicUsize::new(0));
22720        let mut router = Router::new();
22721        router.add_resource(DirectFinalResource {
22722            legacy_calls: Arc::clone(&legacy_calls),
22723            final_calls: Arc::clone(&final_calls),
22724        });
22725        let cx = Cx::for_testing();
22726        let state = SessionState::new();
22727        let request_ctx = request_context(&cx, 157, Budget::INFINITE, &state);
22728        let baseline = direct_final_resource_request(157);
22729        let mut unknown = baseline.clone();
22730        let replaced_uri = unknown
22731            .params
22732            .as_mut()
22733            .and_then(serde_json::Value::as_object_mut)
22734            .expect("resource parameters are an object")
22735            .insert(
22736                "uri".to_owned(),
22737                serde_json::json!("file:///unknown-final-resource"),
22738            );
22739        assert_eq!(
22740            replaced_uri,
22741            Some(serde_json::json!("file:///direct-final-resource"))
22742        );
22743
22744        assert_eq!(baseline.method, unknown.method);
22745        assert_eq!(baseline.id, unknown.id);
22746        assert_eq!(
22747            baseline
22748                .params
22749                .as_ref()
22750                .and_then(|params| params.get("_meta")),
22751            unknown
22752                .params
22753                .as_ref()
22754                .and_then(|params| params.get("_meta")),
22755            "the resource URI is the sole planted dimension"
22756        );
22757
22758        let accepted = router
22759            .dispatch_stateless(&request_ctx, &baseline)
22760            .expect("the registered final resource is accepted");
22761        assert_eq!(accepted["resultType"], "complete");
22762        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22763        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22764
22765        let error = router
22766            .dispatch_stateless(&request_ctx, &unknown)
22767            .expect_err("changing only the URI to an unknown resource is rejected");
22768        assert_eq!(error.code, McpErrorCode::InvalidParams);
22769        assert_eq!(error.message, "Resource not found");
22770        assert_eq!(
22771            error.data,
22772            Some(serde_json::json!({"uri": "file:///unknown-final-resource"}))
22773        );
22774        assert_eq!(final_calls.load(Ordering::SeqCst), 1);
22775        assert_eq!(legacy_calls.load(Ordering::SeqCst), 0);
22776
22777        let legacy_error = router
22778            .handle_resources_read(
22779                &request_ctx,
22780                &ReadResourceParams {
22781                    uri: "file:///unknown-final-resource".to_owned(),
22782                    meta: None,
22783                },
22784                state,
22785                None,
22786                None,
22787            )
22788            .expect_err("the exact legacy missing-resource error remains unchanged");
22789        assert_eq!(legacy_error.code, McpErrorCode::ResourceNotFound);
22790        assert_eq!(
22791            legacy_error.message,
22792            "Resource not found: file:///unknown-final-resource"
22793        );
22794    }
22795
22796    #[test]
22797    fn final_resources_read_rejects_one_field_incompatible_result_type() {
22798        let legacy_calls = Arc::new(AtomicUsize::new(0));
22799        let final_calls = Arc::new(AtomicUsize::new(0));
22800        let mut router = Router::new();
22801        router.add_resource(DirectFinalResource {
22802            legacy_calls,
22803            final_calls: Arc::clone(&final_calls),
22804        });
22805        let cx = Cx::for_testing();
22806        let state = SessionState::new();
22807        let request_ctx = request_context(&cx, 99, Budget::INFINITE, &state);
22808        let request = direct_final_resource_request(99);
22809        let typed_request = CoreRequest::decode(
22810            ProtocolEra::Modern2026,
22811            "resources/read",
22812            request.params.as_ref(),
22813        )
22814        .expect("final resources/read request decodes through the public core surface");
22815        let accepted = router
22816            .dispatch_stateless(&request_ctx, &request)
22817            .expect("the direct final resource response is accepted");
22818        let accepted_wire = serde_json::to_string(&accepted).expect("accepted result serializes");
22819        let mut incompatible = accepted.clone();
22820        incompatible["resultType"] = serde_json::json!("input_required");
22821
22822        let mut accepted_without_type = accepted.clone();
22823        let mut incompatible_without_type = incompatible.clone();
22824        let accepted_type = accepted_without_type
22825            .as_object_mut()
22826            .and_then(|object| object.remove("resultType"));
22827        let incompatible_type = incompatible_without_type
22828            .as_object_mut()
22829            .and_then(|object| object.remove("resultType"));
22830        assert_eq!(accepted_type, Some(serde_json::json!("complete")));
22831        assert_eq!(incompatible_type, Some(serde_json::json!("input_required")));
22832        assert_eq!(
22833            incompatible_without_type, accepted_without_type,
22834            "resultType is the sole incompatible result dimension"
22835        );
22836
22837        let incompatible_wire =
22838            serde_json::to_string(&incompatible).expect("incompatible result serializes");
22839        assert!(matches!(
22840            typed_request.decode_result(&incompatible_wire),
22841            Err(fastmcp_protocol::CoreDispatchError::ResultCodec(_))
22842        ));
22843        assert_eq!(
22844            serde_json::to_string(&accepted).expect("accepted result remains serializable"),
22845            accepted_wire,
22846            "rejecting the one-field incompatible result cannot mutate the accepted response"
22847        );
22848        assert_eq!(
22849            final_calls.load(Ordering::SeqCst),
22850            1,
22851            "local result admission cannot invoke or mutate the direct resource handler"
22852        );
22853        let reaccepted = typed_request
22854            .decode_result(&accepted_wire)
22855            .expect("the original direct final result remains accepted");
22856        assert_eq!(
22857            serde_json::from_str::<serde_json::Value>(
22858                &reaccepted.encode().expect("reaccepted result encodes"),
22859            )
22860            .expect("reaccepted result is JSON"),
22861            accepted,
22862            "the incompatible result cannot alter the accepted final resource contract"
22863        );
22864    }
22865
22866    #[test]
22867    fn final_catalog_preserves_resource_template_and_prompt_fields() {
22868        let resource_metadata = OpenMetadata::try_from_entries([(
22869            "com.example/resource".to_owned(),
22870            serde_json::json!({"source": "resource-handler"}),
22871        )])
22872        .expect("resource metadata is valid");
22873        let template_metadata = OpenMetadata::try_from_entries([(
22874            "com.example/template".to_owned(),
22875            serde_json::json!({"source": "template-handler"}),
22876        )])
22877        .expect("template metadata is valid");
22878        let prompt_metadata = OpenMetadata::try_from_entries([(
22879            "com.example/prompt".to_owned(),
22880            serde_json::json!({"source": "prompt-handler"}),
22881        )])
22882        .expect("prompt metadata is valid");
22883        let resource_annotations = Annotations {
22884            audience: None,
22885            priority: Some(0.25),
22886            last_modified: Some("2026-08-08T00:00:00Z".to_owned()),
22887            additional: BTreeMap::new(),
22888        };
22889        let template_annotations = Annotations {
22890            audience: None,
22891            priority: Some(0.75),
22892            last_modified: Some("2026-08-08T00:00:01Z".to_owned()),
22893            additional: BTreeMap::new(),
22894        };
22895        let resource_icon = RawIcon::try_with_details(
22896            "https://example.test/resource.png",
22897            Some("image/png".to_owned()),
22898            Some(vec!["32x32".to_owned()]),
22899            None,
22900        )
22901        .expect("resource icon is valid");
22902        let template_icon = RawIcon::try_with_details(
22903            "https://example.test/template.png",
22904            Some("image/png".to_owned()),
22905            Some(vec!["48x48".to_owned()]),
22906            None,
22907        )
22908        .expect("template icon is valid");
22909        let prompt_icon = RawIcon::try_with_details(
22910            "https://example.test/prompt.png",
22911            Some("image/png".to_owned()),
22912            Some(vec!["64x64".to_owned()]),
22913            None,
22914        )
22915        .expect("prompt icon is valid");
22916
22917        let mut router = Router::new();
22918        router.add_resource(FinalCatalogResource {
22919            metadata: resource_metadata,
22920            icons: vec![resource_icon],
22921            annotations: resource_annotations,
22922        });
22923        router.add_resource(FinalCatalogResourceTemplate {
22924            metadata: template_metadata,
22925            icons: vec![template_icon],
22926            annotations: template_annotations,
22927        });
22928        router.add_prompt(FinalCatalogPrompt {
22929            metadata: prompt_metadata,
22930            icons: vec![prompt_icon],
22931        });
22932
22933        let cx = Cx::for_testing();
22934        let state = SessionState::new();
22935        let request_ctx = request_context(&cx, 96, Budget::INFINITE, &state);
22936        let final_metadata = serde_json::json!({
22937            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
22938            "io.modelcontextprotocol/clientCapabilities": {},
22939        });
22940
22941        let resources = router
22942            .dispatch_stateless(
22943                &request_ctx,
22944                &JsonRpcRequest::new(
22945                    "resources/list",
22946                    Some(serde_json::json!({"_meta": final_metadata.clone()})),
22947                    96_i64,
22948                ),
22949            )
22950            .expect("final resource catalog is encoded");
22951        assert_eq!(resources["resources"][0]["title"], "Final Catalog Resource");
22952        assert_eq!(
22953            resources["resources"][0]["icons"][0]["src"],
22954            "https://example.test/resource.png"
22955        );
22956        assert_eq!(resources["resources"][0]["annotations"]["priority"], 0.25);
22957        assert_eq!(
22958            resources["resources"][0]["_meta"]["com.example/resource"]["source"],
22959            "resource-handler"
22960        );
22961
22962        let templates = router
22963            .dispatch_stateless(
22964                &request_ctx,
22965                &JsonRpcRequest::new(
22966                    "resources/templates/list",
22967                    Some(serde_json::json!({"_meta": final_metadata.clone()})),
22968                    97_i64,
22969                ),
22970            )
22971            .expect("final resource-template catalog is encoded");
22972        assert_eq!(
22973            templates["resourceTemplates"][0]["title"],
22974            "Final Catalog Template"
22975        );
22976        assert_eq!(
22977            templates["resourceTemplates"][0]["icons"][0]["src"],
22978            "https://example.test/template.png"
22979        );
22980        assert_eq!(
22981            templates["resourceTemplates"][0]["annotations"]["priority"],
22982            0.75
22983        );
22984        assert_eq!(
22985            templates["resourceTemplates"][0]["_meta"]["com.example/template"]["source"],
22986            "template-handler"
22987        );
22988
22989        let prompts = router
22990            .dispatch_stateless(
22991                &request_ctx,
22992                &JsonRpcRequest::new(
22993                    "prompts/list",
22994                    Some(serde_json::json!({"_meta": final_metadata})),
22995                    98_i64,
22996                ),
22997            )
22998            .expect("final prompt catalog is encoded");
22999        assert_eq!(prompts["prompts"][0]["title"], "Final Catalog Prompt");
23000        assert_eq!(
23001            prompts["prompts"][0]["icons"][0]["src"],
23002            "https://example.test/prompt.png"
23003        );
23004        assert_eq!(
23005            prompts["prompts"][0]["_meta"]["com.example/prompt"]["source"],
23006            "prompt-handler"
23007        );
23008        assert_eq!(prompts["prompts"][0]["arguments"][0]["required"], false);
23009    }
23010
23011    #[test]
23012    fn final_resource_catalog_missing_metadata_is_non_mutating() {
23013        let metadata = OpenMetadata::try_from_entries([(
23014            "com.example/resource".to_owned(),
23015            serde_json::json!({"source": "resource-handler"}),
23016        )])
23017        .expect("resource metadata is valid");
23018        let icon = RawIcon::try_with_details(
23019            "https://example.test/resource.png",
23020            Some("image/png".to_owned()),
23021            None,
23022            None,
23023        )
23024        .expect("resource icon is valid");
23025        let mut router = Router::new();
23026        router.add_resource(FinalCatalogResource {
23027            metadata,
23028            icons: vec![icon],
23029            annotations: Annotations::default(),
23030        });
23031        let cx = Cx::for_testing();
23032        let state = SessionState::new();
23033        let request_ctx = request_context(&cx, 99, Budget::INFINITE, &state);
23034        let baseline = JsonRpcRequest::new(
23035            "resources/list",
23036            Some(serde_json::json!({
23037                "_meta": {
23038                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
23039                    "io.modelcontextprotocol/clientCapabilities": {},
23040                },
23041            })),
23042            99_i64,
23043        );
23044        let mut planted = baseline.clone();
23045        planted
23046            .params
23047            .as_mut()
23048            .and_then(serde_json::Value::as_object_mut)
23049            .expect("final resource-list parameters are an object")
23050            .remove("_meta");
23051
23052        assert_eq!(baseline.method, planted.method);
23053        assert_eq!(baseline.id, planted.id);
23054        assert_eq!(
23055            baseline
23056                .params
23057                .as_ref()
23058                .and_then(serde_json::Value::as_object)
23059                .map(serde_json::Map::len),
23060            Some(1),
23061            "the final metadata object is the sole baseline parameter"
23062        );
23063        assert_eq!(
23064            planted
23065                .params
23066                .as_ref()
23067                .and_then(serde_json::Value::as_object)
23068                .map(serde_json::Map::len),
23069            Some(0),
23070            "the planted request differs only by final metadata removal"
23071        );
23072        let catalog_before = serde_json::to_vec(&router.resources()).expect("catalog serializes");
23073        let planted_before = serde_json::to_vec(&planted).expect("request serializes");
23074        let baseline_result = router
23075            .dispatch_stateless(&request_ctx, &baseline)
23076            .expect("final baseline is accepted");
23077        assert_eq!(
23078            baseline_result["resources"][0]["title"],
23079            "Final Catalog Resource"
23080        );
23081
23082        let error = router
23083            .dispatch_stateless(&request_ctx, &planted)
23084            .expect_err("only final request metadata is refused");
23085        assert_eq!(error.code, McpErrorCode::InvalidParams);
23086        assert_eq!(
23087            serde_json::to_vec(&planted).expect("rejected request serializes"),
23088            planted_before,
23089            "the one-field rejection cannot mutate caller-owned input"
23090        );
23091        assert_eq!(
23092            serde_json::to_vec(&router.resources()).expect("catalog serializes"),
23093            catalog_before,
23094            "the one-field rejection cannot mutate the resource catalog"
23095        );
23096        assert_eq!(
23097            router
23098                .dispatch_stateless(&request_ctx, &baseline)
23099                .expect("the baseline remains accepted after rejection"),
23100            baseline_result,
23101            "the one-field rejection cannot alter final field preservation"
23102        );
23103    }
23104
23105    #[test]
23106    fn core_request_decode_result_round_trips_final_catalog_and_read_cache_hints() {
23107        let metadata = OpenMetadata::try_from_entries([(
23108            "com.example/catalog".to_owned(),
23109            serde_json::json!({"source": "handler"}),
23110        )])
23111        .expect("valid final catalog metadata");
23112        let icon = RawIcon::try_with_details(
23113            "https://example.test/tool.png",
23114            Some("image/png".to_owned()),
23115            Some(vec!["48x48".to_owned()]),
23116            None,
23117        )
23118        .expect("valid final icon");
23119        let alternate_icon = RawIcon::try_with_details(
23120            "https://example.test/tool-dark.svg",
23121            Some("image/svg+xml".to_owned()),
23122            Some(vec!["any".to_owned()]),
23123            Some(fastmcp_protocol::common_types::IconTheme::Dark),
23124        )
23125        .expect("valid alternate final icon");
23126        let mut router = Router::new();
23127        let list_ttl: CacheTtl = serde_json::from_str("922337203685477580812345678901234567890")
23128            .expect("an arbitrary-width final list TTL is valid");
23129        let resource_read_ttl: CacheTtl =
23130            serde_json::from_str("184467440737095516160000000000000000000")
23131                .expect("an arbitrary-width final resource-read TTL is valid");
23132        router.set_final_cache_hint_policy(
23133            list_ttl.clone(),
23134            resource_read_ttl.clone(),
23135            CacheScope::Private,
23136        );
23137        router
23138            .add_tool(FinalCatalogTool {
23139                metadata,
23140                icons: vec![icon, alternate_icon],
23141            })
23142            .expect("final catalog tool registration succeeds");
23143        router.add_resource(NamedResource::new("file:///catalog-resource"));
23144        let cx = Cx::for_testing();
23145        let state = SessionState::new();
23146        let request_ctx = request_context(&cx, 93, Budget::INFINITE, &state);
23147
23148        let legacy = router
23149            .handle_tools_list(&request_ctx, ListToolsParams::default(), None)
23150            .expect("legacy catalog remains available");
23151        let legacy_wire = serde_json::to_value(&legacy).expect("legacy catalog serializes");
23152        assert!(legacy_wire.get("ttlMs").is_none());
23153        assert!(legacy_wire.get("cacheScope").is_none());
23154        assert!(legacy_wire["tools"][0].get("icon").is_some());
23155        assert!(legacy_wire["tools"][0].get("version").is_some());
23156        assert!(legacy_wire["tools"][0].get("tags").is_some());
23157
23158        let final_list_params = serde_json::json!({
23159            "_meta": {
23160                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
23161                "io.modelcontextprotocol/clientCapabilities": {},
23162            },
23163        });
23164        let final_list_request = CoreRequest::decode(
23165            ProtocolEra::Modern2026,
23166            "tools/list",
23167            Some(&final_list_params),
23168        )
23169        .expect("final catalog request decodes through the public core surface");
23170        let modern_list = router
23171            .dispatch_stateless(
23172                &request_ctx,
23173                &JsonRpcRequest::new("tools/list", Some(final_list_params), 93_i64),
23174            )
23175            .expect("final catalog is projected through the exact model");
23176        assert_eq!(modern_list["resultType"], "complete");
23177        assert_eq!(modern_list["ttlMs"].to_string(), list_ttl.as_str());
23178        assert_eq!(modern_list["cacheScope"], "private");
23179        assert_eq!(modern_list["tools"][0]["title"], "Exact Final Catalog Tool");
23180        assert_eq!(
23181            modern_list["tools"][0]["annotations"]["title"],
23182            "Exact annotation title"
23183        );
23184        assert_eq!(
23185            modern_list["tools"][0]["icons"].as_array().map(Vec::len),
23186            Some(2)
23187        );
23188        assert_eq!(
23189            modern_list["tools"][0]["icons"][0]["sizes"],
23190            serde_json::json!(["48x48"])
23191        );
23192        assert_eq!(
23193            modern_list["tools"][0]["_meta"]["com.example/catalog"]["source"],
23194            "handler"
23195        );
23196        assert_eq!(modern_list["tools"][0]["outputSchema"]["type"], "object");
23197        assert!(modern_list["tools"][0].get("icon").is_none());
23198        assert!(modern_list["tools"][0].get("version").is_none());
23199        assert!(modern_list["tools"][0].get("tags").is_none());
23200        let modern_list_wire =
23201            serde_json::to_string(&modern_list).expect("final catalog response serializes");
23202        let CoreResult::Final(FinalCoreResult::ToolsList { result, .. }) = final_list_request
23203            .decode_result(&modern_list_wire)
23204            .expect("final catalog response decodes through the public core surface")
23205        else {
23206            panic!("tools/list selects the exact final catalog result");
23207        };
23208        assert_eq!(result.payload.ttl_ms.as_str(), list_ttl.as_str());
23209        assert_eq!(result.payload.cache_scope, CacheScope::Private);
23210        let final_tool = result
23211            .payload
23212            .tools
23213            .first()
23214            .expect("final catalog contains the registered tool");
23215        assert_eq!(
23216            final_tool.title.as_deref(),
23217            Some("Exact Final Catalog Tool")
23218        );
23219        assert_eq!(
23220            final_tool
23221                .annotations
23222                .as_ref()
23223                .and_then(|annotations| annotations.title.as_deref()),
23224            Some("Exact annotation title")
23225        );
23226        assert_eq!(final_tool.icons.as_ref().map(Vec::len), Some(2));
23227        assert_eq!(
23228            final_tool
23229                .icons
23230                .as_ref()
23231                .and_then(|icons| icons.first())
23232                .map(|icon| icon.src.as_str()),
23233            Some("https://example.test/tool.png")
23234        );
23235        assert_eq!(
23236            final_tool
23237                .meta
23238                .as_ref()
23239                .and_then(|metadata| metadata.get("com.example/catalog"))
23240                .and_then(|value| value.get("source"))
23241                .and_then(serde_json::Value::as_str),
23242            Some("handler")
23243        );
23244        assert_eq!(
23245            final_tool
23246                .output_schema
23247                .as_ref()
23248                .and_then(|schema| schema.get("type"))
23249                .and_then(serde_json::Value::as_str),
23250            Some("object")
23251        );
23252
23253        let final_read_params = serde_json::json!({
23254            "_meta": {
23255                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
23256                "io.modelcontextprotocol/clientCapabilities": {},
23257            },
23258            "uri": "file:///catalog-resource",
23259        });
23260        let final_read_request = CoreRequest::decode(
23261            ProtocolEra::Modern2026,
23262            "resources/read",
23263            Some(&final_read_params),
23264        )
23265        .expect("final resource-read request decodes through the public core surface");
23266        let modern_read = router
23267            .dispatch_stateless(
23268                &request_ctx,
23269                &JsonRpcRequest::new("resources/read", Some(final_read_params), 94_i64),
23270            )
23271            .expect("final resource content is projected through the final model");
23272        assert_eq!(modern_read["ttlMs"].to_string(), resource_read_ttl.as_str());
23273        assert_eq!(modern_read["cacheScope"], "private");
23274        assert_eq!(
23275            modern_read["contents"][0]["uri"],
23276            "file:///catalog-resource"
23277        );
23278        assert_eq!(modern_read["contents"][0]["text"], "content");
23279        let modern_read_wire =
23280            serde_json::to_string(&modern_read).expect("final resource-read response serializes");
23281        let CoreResult::Final(FinalCoreResult::ResourcesRead { result, .. }) = final_read_request
23282            .decode_result(&modern_read_wire)
23283            .expect("final resource-read response decodes through the public core surface")
23284        else {
23285            panic!("resources/read selects the exact final read result");
23286        };
23287        assert_eq!(result.payload.ttl_ms.as_str(), resource_read_ttl.as_str());
23288        assert_eq!(result.payload.cache_scope, CacheScope::Private);
23289        assert!(matches!(
23290            result.payload.contents.as_slice(),
23291            [EmbeddedResourceContents::Text { text, .. }] if text == "content"
23292        ));
23293    }
23294
23295    #[test]
23296    fn final_catalog_missing_metadata_is_non_mutating() {
23297        let mut router = Router::new();
23298        router
23299            .add_tool(NamedTool::new("metadata-guarded-tool"))
23300            .expect("tool registration succeeds");
23301        let cx = Cx::for_testing();
23302        let state = SessionState::new();
23303        let request_ctx = request_context(&cx, 95, Budget::INFINITE, &state);
23304        let baseline = JsonRpcRequest::new(
23305            "tools/list",
23306            Some(serde_json::json!({
23307                "_meta": {
23308                    "io.modelcontextprotocol/protocolVersion": "2026-07-28",
23309                    "io.modelcontextprotocol/clientCapabilities": {},
23310                },
23311            })),
23312            95_i64,
23313        );
23314        let mut planted = baseline.clone();
23315        planted
23316            .params
23317            .as_mut()
23318            .and_then(serde_json::Value::as_object_mut)
23319            .expect("final list parameters are an object")
23320            .remove("_meta");
23321        assert_eq!(baseline.method, planted.method);
23322        assert_eq!(baseline.id, planted.id);
23323        let catalog_before = serde_json::to_vec(&router.tools()).expect("catalog serializes");
23324        let planted_before = serde_json::to_vec(&planted).expect("request serializes");
23325
23326        let baseline_result = router
23327            .dispatch_stateless(&request_ctx, &baseline)
23328            .expect("the final baseline is accepted");
23329        let error = router
23330            .dispatch_stateless(&request_ctx, &planted)
23331            .expect_err("only missing final metadata is refused");
23332        assert_eq!(error.code, McpErrorCode::InvalidParams);
23333        assert_eq!(
23334            serde_json::to_vec(&planted).expect("rejected request serializes"),
23335            planted_before,
23336            "the rejected one-field request remains unchanged"
23337        );
23338        assert_eq!(
23339            serde_json::to_vec(&router.tools()).expect("catalog serializes"),
23340            catalog_before,
23341            "the rejected one-field request cannot mutate the catalog"
23342        );
23343        assert_eq!(
23344            router
23345                .dispatch_stateless(&request_ctx, &baseline)
23346                .expect("the unchanged final baseline remains accepted"),
23347            baseline_result,
23348            "the rejection cannot alter final cache hints or catalog projection"
23349        );
23350    }
23351
23352    #[test]
23353    fn srv_04_modern_owned_dispatch_runs_requests_concurrently() {
23354        let started = Arc::new(AtomicUsize::new(0));
23355        let completed = Arc::new(Mutex::new(Vec::new()));
23356        let mut router = Router::new();
23357        router
23358            .add_tool(ConcurrentModernTool::new(
23359                Arc::clone(&started),
23360                Arc::clone(&completed),
23361            ))
23362            .expect("tool registration succeeds");
23363        let router = Arc::new(router);
23364        let runtime = RuntimeBuilder::current_thread()
23365            .build()
23366            .expect("the test runtime is available");
23367        let runtime_handle = runtime.handle();
23368        let mut first = spawn_owned_modern_request(
23369            &runtime_handle,
23370            Arc::clone(&router),
23371            401,
23372            "modern-one",
23373            "one",
23374            None,
23375        );
23376        let mut second =
23377            spawn_owned_modern_request(&runtime_handle, router, 402, "modern-two", "two", None);
23378
23379        let (first, second) = runtime.block_on(async {
23380            let cx = Cx::current().expect("block_on installs an observer context");
23381            let first = first
23382                .recv(&cx)
23383                .await
23384                .expect("the first owner reports a terminal result");
23385            let second = second
23386                .recv(&cx)
23387                .await
23388                .expect("the second owner reports a terminal result");
23389            (first, second)
23390        });
23391        let first = first.expect("the first modern request completes");
23392        let second = second.expect("the second modern request completes");
23393
23394        assert_eq!(started.load(Ordering::SeqCst), 2);
23395        assert_eq!(
23396            first.get("resultType"),
23397            Some(&serde_json::json!("complete"))
23398        );
23399        assert_eq!(
23400            second.get("resultType"),
23401            Some(&serde_json::json!("complete"))
23402        );
23403        let mut completed = completed
23404            .lock()
23405            .expect("completion probe lock is not poisoned")
23406            .clone();
23407        completed.sort();
23408        assert_eq!(completed, vec!["one".to_string(), "two".to_string()]);
23409    }
23410
23411    #[test]
23412    fn srv_04_modern_owned_cancellation_does_not_change_sibling() {
23413        let started = Arc::new(AtomicUsize::new(0));
23414        let completed = Arc::new(Mutex::new(Vec::new()));
23415        let mut router = Router::new();
23416        router
23417            .add_tool(ConcurrentModernTool::new(
23418                Arc::clone(&started),
23419                Arc::clone(&completed),
23420            ))
23421            .expect("tool registration succeeds");
23422        let router = Arc::new(router);
23423        let runtime = RuntimeBuilder::current_thread()
23424            .build()
23425            .expect("the test runtime is available");
23426        let runtime_handle = runtime.handle();
23427        let (cancel_control_sender, mut cancel_control) = oneshot::channel();
23428        let mut cancelled = spawn_owned_modern_request(
23429            &runtime_handle,
23430            Arc::clone(&router),
23431            403,
23432            "modern-cancelled",
23433            "cancelled",
23434            Some(cancel_control_sender),
23435        );
23436        let mut sibling = spawn_owned_modern_request(
23437            &runtime_handle,
23438            router,
23439            404,
23440            "modern-sibling",
23441            "sibling",
23442            None,
23443        );
23444
23445        let (cancelled, sibling) = runtime.block_on(async {
23446            let observer_cx = Cx::current().expect("block_on installs an observer context");
23447            let cancelled_cx = cancel_control
23448                .recv(&observer_cx)
23449                .await
23450                .expect("the request owner exposes its cancellation context");
23451            // Bounded so a dispatch rejection fails the test loudly instead of
23452            // spinning this observer loop forever.
23453            let admission_deadline = std::time::Instant::now() + Duration::from_secs(10);
23454            while started.load(Ordering::SeqCst) < 2 {
23455                assert!(
23456                    std::time::Instant::now() < admission_deadline,
23457                    "both owned modern requests must start before the admission deadline"
23458                );
23459                yield_once().await;
23460            }
23461            cancelled_cx.cancel_with(CancelKind::User, Some("test single-request cancellation"));
23462            let cancelled = cancelled
23463                .recv(&observer_cx)
23464                .await
23465                .expect("the cancelled owner reports a terminal result");
23466            let sibling = sibling
23467                .recv(&observer_cx)
23468                .await
23469                .expect("the sibling owner reports a terminal result");
23470            (cancelled, sibling)
23471        });
23472
23473        let cancelled = cancelled.expect_err("only the selected request is cancelled");
23474        assert_eq!(cancelled.code, McpErrorCode::RequestCancelled);
23475        let sibling = sibling.expect("the sibling completes despite peer cancellation");
23476        assert_eq!(
23477            sibling.get("resultType"),
23478            Some(&serde_json::json!("complete"))
23479        );
23480        assert_eq!(
23481            completed
23482                .lock()
23483                .expect("completion probe lock is not poisoned")
23484                .as_slice(),
23485            ["sibling"],
23486            "cancelling one request cannot add, remove, or alter sibling completion"
23487        );
23488    }
23489
23490    #[test]
23491    fn mount_result_with_warning_and_no_error_is_successful() {
23492        let result = MountResult {
23493            tools: 0,
23494            resources: 0,
23495            resource_templates: 0,
23496            prompts: 0,
23497            warnings: vec!["something".to_string()],
23498            errors: vec![],
23499        };
23500        assert!(result.is_success());
23501        assert!(!result.has_components());
23502    }
23503
23504    struct AppsLinkedTool;
23505
23506    impl ToolHandler for AppsLinkedTool {
23507        fn definition(&self) -> Tool {
23508            Tool {
23509                name: "apps-linked-tool".to_owned(),
23510                description: None,
23511                input_schema: serde_json::json!({"type": "object"}),
23512                output_schema: None,
23513                icon: None,
23514                version: None,
23515                tags: Vec::new(),
23516                annotations: None,
23517            }
23518        }
23519
23520        fn final_definition(&self) -> Option<FinalTool> {
23521            let metadata = fastmcp_protocol::McpAppsToolMetadata::try_new(
23522                Some(
23523                    AbsoluteUri::parse("ui://weather/dashboard").expect("fixed Apps URI is valid"),
23524                ),
23525                Some(vec![fastmcp_protocol::McpAppsToolVisibility::App]),
23526            )
23527            .expect("fixed Apps metadata is valid")
23528            .to_open_metadata()
23529            .expect("fixed Apps metadata serializes");
23530            Some(FinalTool {
23531                name: "apps-linked-tool".to_owned(),
23532                title: None,
23533                description: None,
23534                input_schema: serde_json::json!({"type": "object"}),
23535                output_schema: None,
23536                annotations: None,
23537                icons: None,
23538                meta: Some(metadata),
23539            })
23540        }
23541
23542        fn call(&self, _ctx: &McpContext, _args: serde_json::Value) -> McpResult<Vec<Content>> {
23543            Ok(Vec::new())
23544        }
23545    }
23546
23547    struct AppsBoundResource {
23548        mime_type: &'static str,
23549    }
23550
23551    impl ResourceHandler for AppsBoundResource {
23552        fn definition(&self) -> Resource {
23553            Resource {
23554                uri: "ui://weather/dashboard".to_owned(),
23555                name: "weather-dashboard".to_owned(),
23556                description: None,
23557                mime_type: Some(self.mime_type.to_owned()),
23558                icon: None,
23559                version: None,
23560                tags: Vec::new(),
23561            }
23562        }
23563
23564        fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
23565            Ok(Vec::new())
23566        }
23567    }
23568
23569    #[test]
23570    fn apps_tool_registration_requires_a_registered_html_ui_resource() {
23571        let mut router = Router::new();
23572        router
23573            .add_mcp_apps_ui_resource_with_behavior(
23574                AppsBoundResource {
23575                    mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23576                },
23577                crate::DuplicateBehavior::Error,
23578            )
23579            .expect("matching Apps HTML resource registers first");
23580        router
23581            .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23582            .expect("tool binding to the registered Apps HTML resource is admitted");
23583        assert!(router.tools().is_empty(), "Apps tools are final-only");
23584        assert!(
23585            router
23586                .resolve_tool_for_era("apps-linked-tool", Some(ProtocolEra::Legacy2024),)
23587                .is_none()
23588        );
23589        assert!(
23590            router
23591                .resolve_tool_for_era("apps-linked-tool", Some(ProtocolEra::Modern2026),)
23592                .is_some()
23593        );
23594    }
23595
23596    #[test]
23597    fn apps_tool_registration_rejects_only_a_non_html_ui_resource_without_mutation() {
23598        let mut router = Router::new();
23599        router
23600            .add_final_resource_with_behavior(
23601                AppsBoundResource {
23602                    mime_type: "text/plain",
23603                },
23604                crate::DuplicateBehavior::Error,
23605            )
23606            .expect("the one-field MIME variant remains a valid final resource");
23607
23608        let error = router
23609            .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23610            .expect_err("only replacing the Apps HTML MIME type rejects the linked tool");
23611        assert_eq!(error.code, McpErrorCode::InvalidRequest);
23612        assert!(
23613            router.tools().is_empty(),
23614            "rejected Apps linkage cannot add the tool to the legacy or final catalog"
23615        );
23616    }
23617
23618    #[test]
23619    fn generic_registration_rejects_apps_ui_resources_and_tools_without_mutation() {
23620        let mut router = Router::new();
23621        let resource_error = router
23622            .add_resource_with_behavior(
23623                AppsBoundResource {
23624                    mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23625                },
23626                crate::DuplicateBehavior::Error,
23627            )
23628            .expect_err("generic registration must not expose an Apps View to exact 2024");
23629        assert_eq!(resource_error.code, McpErrorCode::InvalidRequest);
23630        assert!(router.resources().is_empty());
23631
23632        router
23633            .add_mcp_apps_ui_resource_with_behavior(
23634                AppsBoundResource {
23635                    mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23636                },
23637                crate::DuplicateBehavior::Error,
23638            )
23639            .expect("the negotiated final-only Apps resource registers");
23640        let tool_error = router
23641            .add_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23642            .expect_err("generic registration must not publish Apps metadata without opt-in");
23643        assert_eq!(tool_error.code, McpErrorCode::InvalidRequest);
23644        assert!(router.tools().is_empty());
23645    }
23646
23647    #[test]
23648    fn apps_binding_mounts_reject_dangling_tools_replacements_and_prefixes_atomically() {
23649        let mut source = Router::new();
23650        source
23651            .add_mcp_apps_ui_resource_with_behavior(
23652                AppsBoundResource {
23653                    mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23654                },
23655                crate::DuplicateBehavior::Error,
23656            )
23657            .expect("source Apps resource registers");
23658        source
23659            .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23660            .expect("source Apps tool registers");
23661
23662        let mut tools_only_destination = Router::new();
23663        let tools_only = tools_only_destination.mount_tools(source, None);
23664        assert!(!tools_only.is_success());
23665        assert!(tools_only_destination.tools().is_empty());
23666
23667        let mut destination = Router::new();
23668        destination
23669            .add_mcp_apps_ui_resource_with_behavior(
23670                AppsBoundResource {
23671                    mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23672                },
23673                crate::DuplicateBehavior::Error,
23674            )
23675            .expect("destination Apps resource registers");
23676        destination
23677            .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23678            .expect("destination Apps tool registers");
23679
23680        let mut incompatible_resource = Router::new();
23681        incompatible_resource
23682            .add_final_resource_with_behavior(
23683                AppsBoundResource {
23684                    mime_type: "text/plain",
23685                },
23686                crate::DuplicateBehavior::Error,
23687            )
23688            .expect("non-Apps final resource is independently admissible");
23689        let replacement = destination.mount_resources_with_behavior(
23690            incompatible_resource,
23691            None,
23692            crate::DuplicateBehavior::Replace,
23693        );
23694        assert!(!replacement.is_success());
23695        assert_eq!(
23696            destination
23697                .final_resources
23698                .get("ui://weather/dashboard")
23699                .expect("rejected replacement retains the HTML resource")
23700                .definition
23701                .mime_type
23702                .as_deref(),
23703            Some(fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE)
23704        );
23705
23706        let mut prefixed_source = Router::new();
23707        prefixed_source
23708            .add_mcp_apps_ui_resource_with_behavior(
23709                AppsBoundResource {
23710                    mime_type: fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE,
23711                },
23712                crate::DuplicateBehavior::Error,
23713            )
23714            .expect("prefixed source Apps resource registers");
23715        prefixed_source
23716            .add_mcp_apps_tool_with_behavior(AppsLinkedTool, crate::DuplicateBehavior::Error)
23717            .expect("prefixed source Apps tool registers");
23718        let mut prefixed_destination = Router::new();
23719        let prefixed = prefixed_destination.mount(prefixed_source, Some("peer"));
23720        assert!(!prefixed.is_success());
23721        assert!(prefixed_destination.tools().is_empty());
23722        assert!(prefixed_destination.resources().is_empty());
23723    }
23724}