Skip to main content

protocol_droid/domain/
feature.rs

1//! `Feature` — the protocol behaviours a session may or may not have, and
2//! [`resolve`] — the pure function that decides which of them it has.
3//!
4//! # The table is the model
5//!
6//! [`FEATURES`] is a const table, and [`resolve`] is a fold over it. That is
7//! the whole design: there is no second place where a feature's availability is
8//! written down, so a row cannot disagree with the code that consults it, and
9//! adding a revision is a data change rather than a code change.
10//!
11//! Each row answers four questions, all of them from the vendored changelogs:
12//!
13//! - **Who must support it** — [`Side`]. A server feature with no client to
14//!   call it is off.
15//! - **What capability it needs** — at most one client and one server flag.
16//!   This is the prerequisite rule, and it is a requirement rather than a hint:
17//!   a peer that did not advertise `elicitation` cannot be sent an elicitation
18//!   request, whatever the revision allows.
19//! - **Which transport it needs** — `None` for transport-independent features.
20//!   Legacy SSE exists only on `2024-11-05`, so a feature that is only
21//!   expressible there cannot be on for a `2025-03-26` peer.
22//! - **Which revisions define it** — [`first`](FeatureSpec::first) and
23//!   [`last`](FeatureSpec::last). A feature that was later *removed* has a
24//!   `last`, and the removal is as much a part of the matrix as the addition.
25//!
26//! # Every row cites its changelog
27//!
28//! The doc comment on each variant names the revision and changelog entry that
29//! introduced or removed it. A row without a citation is a failing task, and
30//! `every_row_is_cited_and_ordered` enforces the ordering half of that
31//! mechanically: a removal must be attributed to a revision strictly after the
32//! one that added it.
33//!
34//! # Removals are features of the matrix
35//!
36//! `2026-07-28` removes `ping`, `logging/setLevel` and
37//! `notifications/roots/list_changed`, and moves `tasks` out of the core
38//! protocol into an extension. Modelling those as rows with a `last` — rather
39//! than as their absence — is what lets a bridge report *why* it cannot lower a
40//! message, which is the difference between a diagnosable failure and a silent
41//! drop.
42
43use crate::domain::ir::FeatureSet;
44use crate::domain::{
45    Capability, CapabilitySet, ProtocolVersion, Side, TransportKind, transport_allowed,
46};
47
48/// `str` equality by hand, because `==` on slices is not what this needs and
49/// a name makes the two call sites in `bit` and `bit_of` read the same.
50fn str_eq(left: &str, right: &str) -> bool {
51    let (left, right) = (left.as_bytes(), right.as_bytes());
52    if left.len() != right.len() {
53        return false;
54    }
55    let mut i = 0;
56    while i < left.len() {
57        match (left.get(i), right.get(i)) {
58            (Some(l), Some(r)) if l == r => {}
59            _ => return false,
60        }
61        i += 1;
62    }
63    true
64}
65
66/// A protocol behaviour a session may have.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub enum Feature {
69    // ── Server: tools ──────────────────────────────────────────────────────
70    /// `tools/list` and `tools/call`. Every revision, every transport.
71    Tools,
72    /// `tools.list_changed`, gated on the server advertising it.
73    ToolsListChanged,
74    /// Tool annotations — read-only, destructive, and so on. `2025-03-26`.
75    ToolAnnotations,
76    /// `structuredContent` on a tool result. `2025-06-18`.
77    ToolStructuredOutput,
78    /// Resource links in a tool call result. `2025-06-18`.
79    ToolResourceLinks,
80    /// `annotations.lastModified` on a content block. `2025-06-18`.
81    ///
82    /// Separate from `ToolAnnotations` because the two gate different
83    /// revisions: annotations as a field exist in `2024-11-05`, untyped, while
84    /// `lastModified` first appears in the typed form at `2025-06-18`. A value
85    /// carrying only `audience` or `priority` needs the older feature alone.
86    ToolAnnotationsLastModified,
87    /// `icons` on a resource link. `2025-11-25`, SEP-973.
88    ///
89    /// `https://github.com/modelcontextprotocol/modelcontextprotocol/issues/973`
90    ToolResourceLinkIcons,
91    /// Audio content blocks. `2025-03-26`.
92    ToolAudioContent,
93    /// `resultType` on tool results. `2026-07-28`, required rather than
94    /// optional there.
95    ToolResultType,
96
97    // ── Server: resources and prompts ──────────────────────────────────────
98    /// `resources/list`, `resources/read`, `resources/templates/list`. Every
99    /// revision.
100    Resources,
101    /// `resources.list_changed`.
102    ResourcesListChanged,
103    /// Server-to-client resource change notifications. The capability survives
104    /// `2026-07-28` even though the methods behind it are replaced.
105    ResourcesSubscribe,
106    /// `prompts/list` and `prompts/get`. Every revision.
107    Prompts,
108    /// `prompts.list_changed`.
109    PromptsListChanged,
110
111    // ── Server: completions ────────────────────────────────────────────────
112    /// `completion/complete`. `2025-03-26`.
113    Completions,
114
115    // ── Client features the server may use ─────────────────────────────────
116    /// `roots/list`. Deprecated `2026-07-28` but still defined.
117    Roots,
118    /// `notifications/roots/list_changed`. **Removed** in `2026-07-28`.
119    RootsListChanged,
120    /// `sampling/createMessage`. Deprecated `2026-07-28` but still defined.
121    Sampling,
122    /// Sampling with `includeContext`. `2025-11-25`.
123    SamplingContext,
124    /// Sampling with `tools` and `toolChoice`. `2025-11-25`, SEP-1577.
125    SamplingTools,
126    /// `elicitation/create`. `2025-06-18`.
127    Elicitation,
128    /// Form-mode elicitation with the SEP-1330 schema shapes. `2025-11-25`.
129    ElicitationForm,
130    /// URL-mode elicitation. `2025-11-25`, SEP-1036. The completion
131    /// notification behind it is removed in `2026-07-28`; the capability is
132    /// not.
133    ElicitationUrl,
134    /// `logging/setLevel` and `notifications/message`. Deprecated `2026-07-28`,
135    /// which also removes the method and moves log level into `_meta`.
136    Logging,
137
138    // ── Protocol level ─────────────────────────────────────────────────────
139    /// JSON-RPC batching. Added `2025-03-26`, **removed** `2025-06-18`.
140    JsonRpcBatching,
141    /// The Streamable HTTP transport. `2025-03-26`.
142    StreamableHttp,
143    /// The legacy HTTP+SSE transport. `2024-11-05` only; replaced in
144    /// `2025-03-26`, reclassified as Deprecated in `2026-07-28`.
145    LegacySse,
146    /// The `MCP-Protocol-Version` header on subsequent HTTP requests.
147    /// `2025-06-18`.
148    McpProtocolVersionHeader,
149    /// `_meta` on interface types. `2025-06-18`.
150    MetaExtensions,
151    /// The stateless lifecycle: no `initialize` handshake, version and
152    /// capabilities in `_meta` on every request. `2026-07-28`, SEP-2575.
153    StatelessLifecycle,
154    /// `server/discover`. `2026-07-28`, SEP-2575.
155    ServerDiscover,
156    /// `subscriptions/listen`, replacing the HTTP GET endpoint and
157    /// `resources/subscribe`. `2026-07-28`, SEP-2575.
158    SubscriptionsListen,
159    /// `Mcp-Method` and `Mcp-Name` routing headers. `2026-07-28`, SEP-2243.
160    StandardRoutingHeaders,
161    /// `ttlMs` and `cacheScope` on cacheable results. `2026-07-28`, SEP-2549.
162    ResponseCaching,
163    /// The multi-round-trip request pattern, which is what the removal of
164    /// `notifications/elicitation/complete` is predicated on. `2026-07-28`.
165    MultiRoundTripRequests,
166    /// `ping`. **Removed** in `2026-07-28`, SEP-2575.
167    Ping,
168
169    // ── Extensions ─────────────────────────────────────────────────────────
170    /// The `tasks` capability, core-protocol only. Added `2025-11-25` (SEP-1686),
171    /// **removed** from the core in `2026-07-28` when it moves to the
172    /// `io.modelcontextprotocol/tasks` extension (SEP-2663).
173    Tasks,
174    /// The `extensions` capability bag. `2026-07-28`, SEP-2575.
175    Extensions,
176    /// The `experimental` capability bag. Every revision.
177    Experimental,
178}
179
180impl Feature {
181    /// Every feature, in declaration order.
182    pub const ALL: [Self; 39] = [
183        Self::Tools,
184        Self::ToolsListChanged,
185        Self::ToolAnnotations,
186        Self::ToolStructuredOutput,
187        Self::ToolResourceLinks,
188        Self::ToolAnnotationsLastModified,
189        Self::ToolResourceLinkIcons,
190        Self::ToolAudioContent,
191        Self::ToolResultType,
192        Self::Resources,
193        Self::ResourcesListChanged,
194        Self::ResourcesSubscribe,
195        Self::Prompts,
196        Self::PromptsListChanged,
197        Self::Completions,
198        Self::Roots,
199        Self::RootsListChanged,
200        Self::Sampling,
201        Self::SamplingContext,
202        Self::SamplingTools,
203        Self::Elicitation,
204        Self::ElicitationForm,
205        Self::ElicitationUrl,
206        Self::Logging,
207        Self::JsonRpcBatching,
208        Self::StreamableHttp,
209        Self::LegacySse,
210        Self::McpProtocolVersionHeader,
211        Self::MetaExtensions,
212        Self::StatelessLifecycle,
213        Self::ServerDiscover,
214        Self::SubscriptionsListen,
215        Self::StandardRoutingHeaders,
216        Self::ResponseCaching,
217        Self::MultiRoundTripRequests,
218        Self::Ping,
219        Self::Tasks,
220        Self::Extensions,
221        Self::Experimental,
222    ];
223
224    /// The feature's name, for logs, errors and golden files.
225    #[must_use]
226    pub const fn as_str(self) -> &'static str {
227        match self {
228            Self::Tools => "tools",
229            Self::ToolsListChanged => "tools.listChanged",
230            Self::ToolAnnotations => "tool.annotations",
231            Self::ToolStructuredOutput => "tool.structuredOutput",
232            Self::ToolResourceLinks => "tool.resourceLinks",
233            Self::ToolAnnotationsLastModified => "tool.annotations.lastModified",
234            Self::ToolResourceLinkIcons => "tool.resourceLinks.icons",
235            Self::ToolAudioContent => "tool.audioContent",
236            Self::ToolResultType => "tool.resultType",
237            Self::Resources => "resources",
238            Self::ResourcesListChanged => "resources.listChanged",
239            Self::ResourcesSubscribe => "resources.subscribe",
240            Self::Prompts => "prompts",
241            Self::PromptsListChanged => "prompts.listChanged",
242            Self::Completions => "completions",
243            Self::Roots => "roots",
244            Self::RootsListChanged => "roots.listChanged",
245            Self::Sampling => "sampling",
246            Self::SamplingContext => "sampling.context",
247            Self::SamplingTools => "sampling.tools",
248            Self::Elicitation => "elicitation",
249            Self::ElicitationForm => "elicitation.form",
250            Self::ElicitationUrl => "elicitation.url",
251            Self::Logging => "logging",
252            Self::JsonRpcBatching => "jsonRpcBatching",
253            Self::StreamableHttp => "streamableHttp",
254            Self::LegacySse => "legacySse",
255            Self::McpProtocolVersionHeader => "mcpProtocolVersionHeader",
256            Self::MetaExtensions => "metaExtensions",
257            Self::StatelessLifecycle => "statelessLifecycle",
258            Self::ServerDiscover => "server.discover",
259            Self::SubscriptionsListen => "subscriptions.listen",
260            Self::StandardRoutingHeaders => "standardRoutingHeaders",
261            Self::ResponseCaching => "responseCaching",
262            Self::MultiRoundTripRequests => "multiRoundTripRequests",
263            Self::Ping => "ping",
264            Self::Tasks => "tasks",
265            Self::Extensions => "extensions",
266            Self::Experimental => "experimental",
267        }
268    }
269}
270
271impl core::fmt::Display for Feature {
272    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
273        formatter.write_str(self.as_str())
274    }
275}
276
277/// One row of the feature matrix.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub struct FeatureSpec {
280    /// The feature this row describes.
281    pub feature: Feature,
282    /// Which side has to support it.
283    pub side: Side,
284    /// Client capability required, if any.
285    pub client_cap: Option<Capability>,
286    /// Server capability required, if any.
287    pub server_cap: Option<Capability>,
288    /// Transport required, if any.
289    pub transport: Option<TransportKind>,
290    /// The oldest revision that defines it.
291    pub first: ProtocolVersion,
292    /// The revision that removed it, if it was removed.
293    pub last: Option<ProtocolVersion>,
294}
295
296impl FeatureSpec {
297    /// Whether `version` defines this feature, ignoring capabilities and
298    /// transport.
299    ///
300    /// An unknown revision defines nothing, matching
301    /// [`transport_allowed`]: there are no
302    /// wire types to lower to.
303    #[must_use]
304    pub fn defined_in(&self, version: ProtocolVersion) -> bool {
305        if !version.is_known() || version < self.first {
306            return false;
307        }
308        self.last.is_none_or(|last| version < last)
309    }
310}
311
312/// Every capability and revision constant, aliased so the table below reads as
313/// a matrix rather than as a wall of paths.
314///
315/// # Why `tools`, `resources` and `prompts` are gated
316///
317/// These are capabilities in the vendored schemas, not merely collections the
318/// server always has: a `ServerCapabilities` with no `tools` key is a server
319/// with no tools. So a feature row for them carries `server_cap`, and a peer
320/// that never sent `tools` never gets [`Feature::Tools`] — which is what stops
321/// the resolver handing a client a `tools/list` it should not make.
322///
323/// The five tool sub-behaviours all require [`CapTools`] and nothing more,
324/// because the schemas have no separate flag for "this server annotates its
325/// tools". A sub-behaviour is therefore only on when the parent is.
326use Capability::{
327    Completions as CapCompletions, Elicitation as CapElicitation,
328    ElicitationForm as CapElicitationForm, ElicitationUrl as CapElicitationUrl,
329    Experimental as CapExperimental, Extensions as CapExtensions, Logging as CapLogging,
330    Prompts as CapPrompts, PromptsListChanged as CapPromptsListChanged, Resources as CapResources,
331    ResourcesListChanged as CapResourcesListChanged, ResourcesSubscribe as CapResourcesSubscribe,
332    Roots as CapRoots, RootsListChanged as CapRootsListChanged, Sampling as CapSampling,
333    SamplingContext as CapSamplingContext, SamplingTools as CapSamplingTools, Tasks as CapTasks,
334    Tools as CapTools, ToolsListChanged as CapToolsListChanged,
335};
336use ProtocolVersion::{V2024_11_05, V2025_03_26, V2025_06_18, V2025_11_25, V2026_07_28};
337
338/// The feature matrix.
339///
340/// Every row is cited in the doc comment on its [`Feature`] variant. The
341/// ordering within a row is the same as the four questions in the module docs,
342/// so the table can be read as a column of answers.
343pub const FEATURES: &[FeatureSpec] = &[
344    // ── Server: tools ──────────────────────────────────────────────────────
345    FeatureSpec {
346        feature: Feature::Tools,
347        side: Side::Server,
348        client_cap: None,
349        server_cap: Some(CapTools),
350        transport: None,
351        first: V2024_11_05,
352        last: None,
353    },
354    FeatureSpec {
355        feature: Feature::ToolsListChanged,
356        side: Side::Server,
357        client_cap: None,
358        server_cap: Some(CapToolsListChanged),
359        transport: None,
360        first: V2024_11_05,
361        last: None,
362    },
363    // PR #185, `2025-03-26`: "Added comprehensive tool annotations".
364    FeatureSpec {
365        feature: Feature::ToolAnnotations,
366        side: Side::Server,
367        client_cap: None,
368        server_cap: Some(CapTools),
369        transport: None,
370        first: V2025_03_26,
371        last: None,
372    },
373    // `2025-06-18`: `lastModified` is the first annotation field that the
374    // typed `Annotations` object carries and `2025-03-26` does not. The
375    // annotation field itself is older than that and untyped at `2024-11-05`,
376    // so it is `ToolAnnotations` that gates the field rather than this.
377    FeatureSpec {
378        feature: Feature::ToolAnnotationsLastModified,
379        side: Side::Server,
380        client_cap: None,
381        server_cap: Some(CapTools),
382        transport: None,
383        first: V2025_06_18,
384        last: None,
385    },
386    // SEP-973, `2025-11-25` major change 2: "Allow servers to expose icons as
387    // additional metadata for tools, resources, resource templates, and
388    // prompts". `https://github.com/modelcontextprotocol/modelcontextprotocol/issues/973`
389    FeatureSpec {
390        feature: Feature::ToolResourceLinkIcons,
391        side: Side::Server,
392        client_cap: None,
393        server_cap: Some(CapTools),
394        transport: None,
395        first: V2025_11_25,
396        last: None,
397    },
398    // PR #371, `2025-06-18` major change 2.
399    FeatureSpec {
400        feature: Feature::ToolStructuredOutput,
401        side: Side::Server,
402        client_cap: None,
403        server_cap: Some(CapTools),
404        transport: None,
405        first: V2025_06_18,
406        last: None,
407    },
408    // PR #603, `2025-06-18` major change 7.
409    FeatureSpec {
410        feature: Feature::ToolResourceLinks,
411        side: Side::Server,
412        client_cap: None,
413        server_cap: Some(CapTools),
414        transport: None,
415        first: V2025_06_18,
416        last: None,
417    },
418    // `2025-03-26`: "Added support for audio data".
419    FeatureSpec {
420        feature: Feature::ToolAudioContent,
421        side: Side::Server,
422        client_cap: None,
423        server_cap: Some(CapTools),
424        transport: None,
425        first: V2025_03_26,
426        last: None,
427    },
428    // `2026-07-28` major change 10 loosens the schemas; `resultType` is
429    // required there.
430    FeatureSpec {
431        feature: Feature::ToolResultType,
432        side: Side::Server,
433        client_cap: None,
434        server_cap: Some(CapTools),
435        transport: None,
436        first: V2026_07_28,
437        last: None,
438    },
439    // ── Server: resources and prompts ──────────────────────────────────────
440    FeatureSpec {
441        feature: Feature::Resources,
442        side: Side::Server,
443        client_cap: None,
444        server_cap: Some(CapResources),
445        transport: None,
446        first: V2024_11_05,
447        last: None,
448    },
449    FeatureSpec {
450        feature: Feature::ResourcesListChanged,
451        side: Side::Server,
452        client_cap: None,
453        server_cap: Some(CapResourcesListChanged),
454        transport: None,
455        first: V2024_11_05,
456        last: None,
457    },
458    // The capability outlives the methods: `2026-07-28` replaces
459    // `resources/subscribe` with `subscriptions/listen` but keeps the flag.
460    FeatureSpec {
461        feature: Feature::ResourcesSubscribe,
462        side: Side::Server,
463        client_cap: None,
464        server_cap: Some(CapResourcesSubscribe),
465        transport: None,
466        first: V2024_11_05,
467        last: None,
468    },
469    FeatureSpec {
470        feature: Feature::Prompts,
471        side: Side::Server,
472        client_cap: None,
473        server_cap: Some(CapPrompts),
474        transport: None,
475        first: V2024_11_05,
476        last: None,
477    },
478    FeatureSpec {
479        feature: Feature::PromptsListChanged,
480        side: Side::Server,
481        client_cap: None,
482        server_cap: Some(CapPromptsListChanged),
483        transport: None,
484        first: V2024_11_05,
485        last: None,
486    },
487    // `2025-03-26`: "Added `completions` capability". The capability is the
488    // only way a server signals support, so the row requires it.
489    FeatureSpec {
490        feature: Feature::Completions,
491        side: Side::Server,
492        client_cap: None,
493        server_cap: Some(CapCompletions),
494        transport: None,
495        first: V2025_03_26,
496        last: None,
497    },
498    // ── Client features the server may use ─────────────────────────────────
499    // Roots: deprecated by SEP-2577 in `2026-07-28`, still defined.
500    FeatureSpec {
501        feature: Feature::Roots,
502        side: Side::Client,
503        client_cap: Some(CapRoots),
504        server_cap: None,
505        transport: None,
506        first: V2024_11_05,
507        last: None,
508    },
509    // Removed with `notifications/roots/list_changed`, SEP-2575.
510    FeatureSpec {
511        feature: Feature::RootsListChanged,
512        side: Side::Client,
513        client_cap: Some(CapRootsListChanged),
514        server_cap: None,
515        transport: None,
516        first: V2024_11_05,
517        last: Some(V2026_07_28),
518    },
519    // Sampling: deprecated by SEP-2577 in `2026-07-28`, still defined.
520    FeatureSpec {
521        feature: Feature::Sampling,
522        side: Side::Client,
523        client_cap: Some(CapSampling),
524        server_cap: None,
525        transport: None,
526        first: V2024_11_05,
527        last: None,
528    },
529    FeatureSpec {
530        feature: Feature::SamplingContext,
531        side: Side::Client,
532        client_cap: Some(CapSamplingContext),
533        server_cap: None,
534        transport: None,
535        first: V2025_11_25,
536        last: None,
537    },
538    // SEP-1577, `2025-11-25`.
539    FeatureSpec {
540        feature: Feature::SamplingTools,
541        side: Side::Client,
542        client_cap: Some(CapSamplingTools),
543        server_cap: None,
544        transport: None,
545        first: V2025_11_25,
546        last: None,
547    },
548    // PR #382, `2025-06-18` major change 6.
549    FeatureSpec {
550        feature: Feature::Elicitation,
551        side: Side::Client,
552        client_cap: Some(CapElicitation),
553        server_cap: None,
554        transport: None,
555        first: V2025_06_18,
556        last: None,
557    },
558    // SEP-1330, `2025-11-25` minor change 5.
559    FeatureSpec {
560        feature: Feature::ElicitationForm,
561        side: Side::Client,
562        client_cap: Some(CapElicitationForm),
563        server_cap: None,
564        transport: None,
565        first: V2025_11_25,
566        last: None,
567    },
568    // SEP-1036, `2025-11-25` major change 6.
569    FeatureSpec {
570        feature: Feature::ElicitationUrl,
571        side: Side::Client,
572        client_cap: Some(CapElicitationUrl),
573        server_cap: None,
574        transport: None,
575        first: V2025_11_25,
576        last: None,
577    },
578    // SEP-2577, `2026-07-28`, deprecates it and removes `logging/setLevel`.
579    FeatureSpec {
580        feature: Feature::Logging,
581        side: Side::Server,
582        client_cap: None,
583        server_cap: Some(CapLogging),
584        transport: None,
585        first: V2024_11_05,
586        last: None,
587    },
588    // ── Protocol level ─────────────────────────────────────────────────────
589    // PR #228 adds it in `2025-03-26`; PR #416 removes it in `2025-06-18`.
590    FeatureSpec {
591        feature: Feature::JsonRpcBatching,
592        side: Side::Server,
593        client_cap: None,
594        server_cap: None,
595        transport: None,
596        first: V2025_03_26,
597        last: Some(V2025_06_18),
598    },
599    // PR #206, `2025-03-26`.
600    FeatureSpec {
601        feature: Feature::StreamableHttp,
602        side: Side::Server,
603        client_cap: None,
604        server_cap: None,
605        transport: Some(TransportKind::StreamableHttp),
606        first: V2025_03_26,
607        last: None,
608    },
609    // `2024-11-05` only. Reclassified Deprecated by SEP-2596 in `2026-07-28`,
610    // but that is a recommendation, not a removal — it was already gone in
611    // `2025-03-26` as far as the versioned matrix is concerned.
612    FeatureSpec {
613        feature: Feature::LegacySse,
614        side: Side::Server,
615        client_cap: None,
616        server_cap: None,
617        transport: Some(TransportKind::LegacySse),
618        first: V2024_11_05,
619        last: Some(V2025_03_26),
620    },
621    // PR #548, `2025-06-18` major change 8.
622    FeatureSpec {
623        feature: Feature::McpProtocolVersionHeader,
624        side: Side::Server,
625        client_cap: None,
626        server_cap: None,
627        transport: None,
628        first: V2025_06_18,
629        last: None,
630    },
631    // PR #710, `2025-06-18`: `_meta` on additional interface types.
632    FeatureSpec {
633        feature: Feature::MetaExtensions,
634        side: Side::Server,
635        client_cap: None,
636        server_cap: None,
637        transport: None,
638        first: V2025_06_18,
639        last: None,
640    },
641    // SEP-2575, `2026-07-28` major change 2.
642    FeatureSpec {
643        feature: Feature::StatelessLifecycle,
644        side: Side::Server,
645        client_cap: None,
646        server_cap: None,
647        transport: None,
648        first: V2026_07_28,
649        last: None,
650    },
651    // SEP-2575, `2026-07-28` major change 3.
652    FeatureSpec {
653        feature: Feature::ServerDiscover,
654        side: Side::Server,
655        client_cap: None,
656        server_cap: None,
657        transport: None,
658        first: V2026_07_28,
659        last: None,
660    },
661    // SEP-2575, `2026-07-28` major change 4.
662    FeatureSpec {
663        feature: Feature::SubscriptionsListen,
664        side: Side::Server,
665        client_cap: None,
666        server_cap: None,
667        transport: None,
668        first: V2026_07_28,
669        last: None,
670    },
671    // SEP-2243, `2026-07-28` major change 4.
672    FeatureSpec {
673        feature: Feature::StandardRoutingHeaders,
674        side: Side::Server,
675        client_cap: None,
676        server_cap: None,
677        transport: None,
678        first: V2026_07_28,
679        last: None,
680    },
681    // SEP-2549, `2026-07-28` major change 5.
682    FeatureSpec {
683        feature: Feature::ResponseCaching,
684        side: Side::Server,
685        client_cap: None,
686        server_cap: None,
687        transport: None,
688        first: V2026_07_28,
689        last: None,
690    },
691    // `2026-07-28` major change 11, the pattern that replaces the elicitation
692    // completion notification.
693    FeatureSpec {
694        feature: Feature::MultiRoundTripRequests,
695        side: Side::Server,
696        client_cap: None,
697        server_cap: None,
698        transport: None,
699        first: V2026_07_28,
700        last: None,
701    },
702    // SEP-2575, `2026-07-28` major change 5: `ping` is removed outright.
703    FeatureSpec {
704        feature: Feature::Ping,
705        side: Side::Server,
706        client_cap: None,
707        server_cap: None,
708        transport: None,
709        first: V2024_11_05,
710        last: Some(V2026_07_28),
711    },
712    // ── Extensions ─────────────────────────────────────────────────────────
713    // SEP-1686 adds it in `2025-11-25`; SEP-2663 moves it to
714    // `io.modelcontextprotocol/tasks` in `2026-07-28`.
715    FeatureSpec {
716        feature: Feature::Tasks,
717        side: Side::Both,
718        client_cap: Some(CapTasks),
719        server_cap: Some(CapTasks),
720        transport: None,
721        first: V2025_11_25,
722        last: Some(V2026_07_28),
723    },
724    // SEP-2575, `2026-07-28`.
725    FeatureSpec {
726        feature: Feature::Extensions,
727        side: Side::Both,
728        client_cap: Some(CapExtensions),
729        server_cap: Some(CapExtensions),
730        transport: None,
731        first: V2026_07_28,
732        last: None,
733    },
734    FeatureSpec {
735        feature: Feature::Experimental,
736        side: Side::Both,
737        client_cap: Some(CapExperimental),
738        server_cap: Some(CapExperimental),
739        transport: None,
740        first: V2024_11_05,
741        last: None,
742    },
743];
744
745/// The set of features a negotiated session has.
746///
747/// A `u128` bitset, so [`allows`](Self::allows) is a mask and a test — the
748/// check every feature-specific code path makes before it runs, and one that
749/// must not cost a linear search on the message path.
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
751pub struct FeatureProfile {
752    bits: u128,
753}
754
755impl FeatureProfile {
756    /// No features at all. What an unnegotiated session has.
757    #[must_use]
758    pub const fn empty() -> Self {
759        Self { bits: 0 }
760    }
761
762    /// Every feature, ignoring revision, capabilities and transport.
763    ///
764    /// Only for tests that need a profile with a known shape. Production code
765    /// goes through [`resolve`].
766    #[must_use]
767    pub fn all() -> Self {
768        let mut profile = Self::empty();
769        for feature in Feature::ALL {
770            profile.insert(feature);
771        }
772        profile
773    }
774
775    /// Adds a feature.
776    #[must_use]
777    pub fn with(self, feature: Feature) -> Self {
778        Self {
779            bits: self.bits | Self::bit_of(feature),
780        }
781    }
782
783    /// Adds a feature in place.
784    pub fn insert(&mut self, feature: Feature) {
785        self.bits |= Self::bit_of(feature);
786    }
787
788    /// Removes a feature.
789    pub fn remove(&mut self, feature: Feature) {
790        self.bits &= !Self::bit_of(feature);
791    }
792
793    /// Whether the session has the feature.
794    #[must_use]
795    pub fn allows(self, feature: Feature) -> bool {
796        self.bits & Self::bit_of(feature) != 0
797    }
798
799    /// How many features the session has.
800    #[must_use]
801    pub const fn len(self) -> u32 {
802        self.bits.count_ones()
803    }
804
805    /// Whether the session has nothing.
806    #[must_use]
807    pub const fn is_empty(self) -> bool {
808        self.bits == 0
809    }
810
811    /// The features in the profile, in declaration order.
812    pub fn iter(self) -> impl Iterator<Item = Feature> {
813        Feature::ALL.into_iter().filter(move |f| self.allows(*f))
814    }
815
816    /// The features that are *not* in the profile, in declaration order.
817    pub fn missing(self) -> impl Iterator<Item = Feature> {
818        Feature::ALL.into_iter().filter(move |f| !self.allows(*f))
819    }
820
821    /// The features this session has that `other` does not.
822    #[must_use]
823    pub const fn difference(self, other: Self) -> Self {
824        Self {
825            bits: self.bits & !other.bits,
826        }
827    }
828
829    /// The features both sessions have.
830    #[must_use]
831    pub const fn intersection(self, other: Self) -> Self {
832        Self {
833            bits: self.bits & other.bits,
834        }
835    }
836
837    /// This profile viewed as a [`FeatureSet`].
838    ///
839    /// The two types share one bit encoding precisely so that this is a copy of
840    /// a `u128` and every comparison between them is a mask test. The
841    /// alternative — `FeatureSet::union_of(self.iter())` — walks all
842    /// [`Feature::ALL`] and calls [`allows`](Self::allows) for each, and
843    /// `allows` resolves its bit by scanning `ALL` for a matching name. That is
844    /// quadratic in the feature count, which is invisible when a decision is
845    /// made once per message and not when it is made once per primitive on a
846    /// `tools/list` of a thousand tools.
847    #[must_use]
848    pub const fn as_set(self) -> FeatureSet {
849        FeatureSet::from_bits(self.bits)
850    }
851
852    /// The server-side capabilities this session is on, as a set.
853    ///
854    /// This is the answer to "what does this server advertise", read back out
855    /// of the same profile every feature gate consults. It is a fold over
856    /// [`FEATURES`] — the same table [`resolve`] is a
857    /// fold over — taking the `server_cap` of every server-side row the profile
858    /// allows.
859    ///
860    /// # Why a server's advertisement is derived and not declared
861    ///
862    /// A server's `InitializeResult` has to state its capabilities, and the
863    /// obvious implementation is a second list beside the feature matrix. That
864    /// list would be a second thing to update, and the two would disagree the
865    /// first time a feature was added to [`FEATURES`] and not to the list: the
866    /// gate would switch on a capability the handshake never claimed, and the
867    /// peer would have been told at connect time not to use it.
868    ///
869    /// Deriving it inverts that. The advertisement is a *function of the gate*,
870    /// so it cannot be edited independently of it, and the only way to widen
871    /// what a server advertises is to widen what it actually does.
872    ///
873    /// Two properties fall out of reading the profile rather than the
874    /// declaration:
875    ///
876    /// - **Client capabilities are excluded by construction.** Their rows are
877    ///   [`Side::Client`] with no `server_cap`, so there is no path by which
878    ///   `roots`, `sampling` or `elicitation` reaches a `ServerCapabilities`
879    ///   object, which has no field for them.
880    /// - **The revision narrows it.** A capability survives only if the feature
881    ///   behind it is defined in the negotiated version, so a server that
882    ///   declared everything still advertises only what the agreed revision can
883    ///   name.
884    ///
885    /// # Examples
886    ///
887    /// ```
888    /// use protocol_droid::domain::{
889    ///     Capability, CapabilitySet, Feature, Negotiation, ProtocolVersion,
890    ///     TransportKind, resolve,
891    /// };
892    ///
893    /// // A server that declared nothing advertises nothing.
894    /// let version = ProtocolVersion::V2025_06_18;
895    /// let silent = resolve(Negotiation {
896    ///     version,
897    ///     client_caps: CapabilitySet::all_for(version),
898    ///     server_caps: CapabilitySet::empty(),
899    ///     transport: TransportKind::Stdio,
900    /// });
901    /// assert!(silent.advertised_by_server().is_empty());
902    ///
903    /// // A server that declared tools advertises tools, and nothing a
904    /// // client-side-only gate would have switched on.
905    /// let loud = resolve(Negotiation {
906    ///     version,
907    ///     client_caps: CapabilitySet::all_for(version),
908    ///     server_caps: CapabilitySet::empty().with(Capability::Tools),
909    ///     transport: TransportKind::Stdio,
910    /// });
911    /// let advertised = loud.advertised_by_server();
912    /// assert!(advertised.contains(Capability::Tools));
913    /// assert!(!advertised.contains(Capability::ToolsListChanged));
914    /// assert!(!advertised.contains(Capability::Sampling), "client-side");
915    /// ```
916    #[must_use]
917    pub fn advertised_by_server(self) -> CapabilitySet {
918        let mut advertised = CapabilitySet::empty();
919        for spec in FEATURES {
920            if !spec.side.includes_server() || !self.allows(spec.feature) {
921                continue;
922            }
923            if let Some(capability) = spec.server_cap {
924                advertised.insert(capability);
925            }
926        }
927        advertised
928    }
929
930    /// The bit a feature occupies, found by name so a new variant cannot leave
931    /// a stale hand-assigned bit behind.
932    fn bit_of(feature: Feature) -> u128 {
933        let name = feature.as_str();
934        let mut i = 0;
935        while let Some(candidate) = Feature::ALL.get(i) {
936            if str_eq(candidate.as_str(), name) {
937                return 1u128 << i;
938            }
939            i += 1;
940        }
941        0
942    }
943}
944
945/// Everything a session's peers and transport tell us, as one argument.
946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
947pub struct Negotiation {
948    /// The revision both sides agreed on.
949    pub version: ProtocolVersion,
950    /// What the client advertised.
951    pub client_caps: CapabilitySet,
952    /// What the server advertised.
953    pub server_caps: CapabilitySet,
954    /// The transport the session runs over.
955    pub transport: TransportKind,
956}
957
958impl Negotiation {
959    /// The capabilities for a version, on one side, with nothing advertised.
960    #[must_use]
961    pub const fn bare(version: ProtocolVersion, transport: TransportKind) -> Self {
962        Self {
963            version,
964            client_caps: CapabilitySet::empty(),
965            server_caps: CapabilitySet::empty(),
966            transport,
967        }
968    }
969}
970
971/// Resolves the features a negotiated session has.
972///
973/// Pure: the same inputs always give the same profile, and nothing here reads a
974/// clock, a socket, or global state. That is what makes the golden snapshot
975/// worth having — a profile is a function of four values, so it can be
976/// reviewed as a table rather than discovered at runtime.
977///
978/// A feature is on when *all four* of its row's conditions hold:
979///
980/// 1. `version` is within the row's revision window.
981/// 2. The side that must support it actually declared the required capability,
982///    for the side that owns it.
983/// 3. The transport, if the row names one, is defined for the version and
984///    matches.
985/// 4. The capability the row needs is one the version's schema defines, which
986///    stops a newer peer's flag from switching on a feature an older revision
987///    cannot express.
988///
989/// # Examples
990///
991/// ```
992/// use protocol_droid::domain::{
993///     Capability, CapabilitySet, Feature, Negotiation, ProtocolVersion,
994///     TransportKind, resolve,
995/// };
996///
997/// // 2024-11-05 over stdio, with everything that revision defines advertised.
998/// let version = ProtocolVersion::V2024_11_05;
999/// let all = CapabilitySet::all_for(version);
1000/// let profile = resolve(Negotiation {
1001///     version,
1002///     client_caps: all,
1003///     server_caps: all,
1004///     transport: TransportKind::Stdio,
1005/// });
1006/// assert!(profile.allows(Feature::Tools));
1007/// assert!(!profile.allows(Feature::Elicitation), "added in 2025-06-18");
1008/// ```
1009#[must_use]
1010pub fn resolve(negotiation: Negotiation) -> FeatureProfile {
1011    let Negotiation {
1012        version,
1013        client_caps,
1014        server_caps,
1015        transport,
1016    } = negotiation;
1017
1018    // An unknown revision cannot express anything, so the whole profile is
1019    // empty before the table is even consulted. Same fail-secure posture as
1020    // `transport_allowed`.
1021    if !version.is_known() {
1022        return FeatureProfile::empty();
1023    }
1024
1025    let mut profile = FeatureProfile::empty();
1026    for spec in FEATURES {
1027        if !spec.defined_in(version) {
1028            continue;
1029        }
1030        if let Some(required) = spec.transport
1031            && required != transport
1032        {
1033            continue;
1034        }
1035        // A transport-gated row also needs the transport to exist for the
1036        // revision, which `transport_allowed` is the authority on.
1037        if spec.transport.is_some() && !transport_allowed(version, transport) {
1038            continue;
1039        }
1040        if !supported_by(spec.side, spec.client_cap, client_caps, version) {
1041            continue;
1042        }
1043        if !supported_by(spec.side, spec.server_cap, server_caps, version) {
1044            continue;
1045        }
1046        profile.insert(spec.feature);
1047    }
1048    profile
1049}
1050
1051/// Whether the owner of a capability has advertised it on a version that
1052/// defines it.
1053fn supported_by(
1054    side: Side,
1055    required: Option<Capability>,
1056    caps: CapabilitySet,
1057    version: ProtocolVersion,
1058) -> bool {
1059    let Some(required) = required else {
1060        // A row with no capability for this side is on unless the side does
1061        // not exist, which cannot happen: both sides always do.
1062        return true;
1063    };
1064    // The capability has to be one the side can declare in the first place,
1065    // and one the version's schema has a field for.
1066    let owner_declares = match side {
1067        Side::Client => required.side().includes_client(),
1068        Side::Server => required.side().includes_server(),
1069        Side::Both => true,
1070    };
1071    owner_declares && required.defined_in(version) && caps.contains(required)
1072}
1073
1074/// The downgrades a caller will accept when a feature is unavailable.
1075///
1076/// Separate from [`FeatureProfile`] on purpose: a profile is a fact, and a
1077/// policy is a decision. Keeping them apart means "what can this session do"
1078/// and "what will this caller settle for" are separately testable, and a
1079/// session cannot quietly relax its own requirements.
1080#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
1081pub struct DegradePolicy {
1082    bits: u128,
1083}
1084
1085impl DegradePolicy {
1086    /// Nothing may be downgraded. The default, and the fail-secure one.
1087    #[must_use]
1088    pub const fn strict() -> Self {
1089        Self { bits: 0 }
1090    }
1091
1092    /// Every feature may be downgraded. For a prober that wants to know what
1093    /// a peer will tolerate, never for a server serving a client.
1094    #[must_use]
1095    pub fn permissive() -> Self {
1096        Self::all()
1097    }
1098
1099    /// Every feature.
1100    #[must_use]
1101    pub fn all() -> Self {
1102        let mut policy = Self::strict();
1103        for feature in Feature::ALL {
1104            policy.insert(feature);
1105        }
1106        policy
1107    }
1108
1109    /// Allows one feature to be downgraded.
1110    #[must_use]
1111    pub fn with(self, feature: Feature) -> Self {
1112        Self {
1113            bits: self.bits | FeatureProfile::bit_of(feature),
1114        }
1115    }
1116
1117    /// Adds a feature in place.
1118    pub fn insert(&mut self, feature: Feature) {
1119        self.bits |= FeatureProfile::bit_of(feature);
1120    }
1121
1122    /// Whether this feature may be downgraded.
1123    #[must_use]
1124    pub fn allows(self, feature: Feature) -> bool {
1125        self.bits & FeatureProfile::bit_of(feature) != 0
1126    }
1127
1128    /// Whether the session's profile is acceptable under this policy.
1129    ///
1130    /// `True` when every feature the session *lacks* is one the policy allows
1131    /// to be missing. A strict policy therefore accepts only a profile with
1132    /// everything, which is the point: the caller has to opt in to each
1133    /// individual loss.
1134    #[must_use]
1135    pub fn accepts(self, profile: FeatureProfile) -> bool {
1136        profile.missing().all(|feature| self.allows(feature))
1137    }
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142    // Test code unwraps deliberately: the strict profile exists to keep
1143    // production paths fallible, and a test that cannot unwrap cannot assert.
1144    // The messages name the failing precondition, which is the useful part.
1145    #![expect(
1146        clippy::panic,
1147        clippy::unwrap_used,
1148        reason = "tests assert on fallible calls and panic with the failing precondition; propagating errors would replace a clear failure message with noise"
1149    )]
1150
1151    use super::{DegradePolicy, FEATURES, Feature, FeatureProfile, Negotiation, resolve};
1152    use crate::domain::{Capability, CapabilitySet, ProtocolVersion, TransportKind};
1153    use std::collections::BTreeSet;
1154    fn everything_on(version: ProtocolVersion, transport: TransportKind) -> FeatureProfile {
1155        let caps = CapabilitySet::all_for(version);
1156        resolve(Negotiation {
1157            version,
1158            client_caps: caps,
1159            server_caps: caps,
1160            transport,
1161        })
1162    }
1163
1164    fn nothing_on(version: ProtocolVersion, transport: TransportKind) -> FeatureProfile {
1165        resolve(Negotiation::bare(version, transport))
1166    }
1167
1168    #[test]
1169    fn the_table_covers_every_feature_exactly_once() {
1170        for feature in Feature::ALL {
1171            assert_eq!(
1172                FEATURES.iter().filter(|s| s.feature == feature).count(),
1173                1,
1174                "{feature} has a duplicate row"
1175            );
1176        }
1177        assert_eq!(FEATURES.len(), Feature::ALL.len());
1178    }
1179
1180    /// A removal must be attributed to a revision strictly after the one that
1181    /// added it, and must name known revisions. This is the half of "every row
1182    /// cites the spec" that can be checked mechanically.
1183    #[test]
1184    fn every_row_is_cited_and_ordered() {
1185        for spec in FEATURES {
1186            assert!(
1187                spec.first.is_known(),
1188                "{} cites {}",
1189                spec.feature,
1190                spec.first
1191            );
1192            if let Some(last) = spec.last {
1193                assert!(last.is_known(), "{} cites {}", spec.feature, last);
1194                assert!(
1195                    spec.first < last,
1196                    "{} is removed by {last}, which does not follow {}",
1197                    spec.feature,
1198                    spec.first
1199                );
1200            }
1201        }
1202    }
1203
1204    /// Every capability a row requires has to be one the row's *own* revision
1205    /// window defines. A row that requires a flag its first revision cannot
1206    /// express could never turn on, which is a dead row rather than a wrong
1207    /// one — but it is always a mistake.
1208    #[test]
1209    fn no_row_requires_a_capability_its_revisions_lack() {
1210        for spec in FEATURES {
1211            for capability in [spec.client_cap, spec.server_cap].into_iter().flatten() {
1212                for version in ProtocolVersion::KNOWN {
1213                    if spec.defined_in(version) {
1214                        assert!(
1215                            capability.defined_in(version),
1216                            "{capability} is required by {} but is not defined in {version}",
1217                            spec.feature
1218                        );
1219                    }
1220                }
1221            }
1222        }
1223    }
1224
1225    #[test]
1226    fn every_feature_fits_in_the_bitset_with_a_distinct_bit() {
1227        let mut seen = BTreeSet::new();
1228        let profile = FeatureProfile::all();
1229        assert_eq!(profile.len() as usize, Feature::ALL.len());
1230        for feature in Feature::ALL {
1231            assert!(profile.allows(feature), "{feature} has no bit");
1232            let alone = FeatureProfile::empty().with(feature);
1233            assert_eq!(alone.len(), 1, "{feature} is not alone in its bit");
1234            assert!(seen.insert(alone.iter().next()), "{feature} shares a bit");
1235        }
1236    }
1237
1238    #[test]
1239    fn the_golden_profile_grows_across_revisions() {
1240        let counts: Vec<(ProtocolVersion, u32)> = ProtocolVersion::KNOWN
1241            .into_iter()
1242            .map(|version| (version, everything_on(version, TransportKind::Stdio).len()))
1243            .collect();
1244        // Nothing before `2025-03-26`, and the removals in `2026-07-28` offset
1245        // some of that growth, so only the broad shape is asserted here. The
1246        // exact numbers are the golden file's job.
1247        let (Some(first), Some(last)) = (counts.first(), counts.last()) else {
1248            panic!("no revisions were counted");
1249        };
1250        assert!(first.1 < last.1, "{counts:?}");
1251        for pair in counts.windows(2) {
1252            let (Some(earlier), Some(later)) = (pair.first(), pair.last()) else {
1253                continue;
1254            };
1255            assert!(
1256                later.1 >= earlier.1 - 2,
1257                "{earlier:?} to {later:?} lost more than the removals explain"
1258            );
1259        }
1260    }
1261
1262    /// A peer that advertises nothing gets the capability-free protocol-level
1263    /// features and nothing else. `ping` and `_meta` are unconditional, so the
1264    /// profile is *not* empty — what matters is that every gated feature is off.
1265    #[test]
1266    fn a_peer_that_advertises_nothing_gets_no_gated_features() {
1267        for version in ProtocolVersion::KNOWN {
1268            for transport in TransportKind::ALL {
1269                let profile = nothing_on(version, transport);
1270                for spec in FEATURES {
1271                    let gated = spec.client_cap.is_some() || spec.server_cap.is_some();
1272                    if gated {
1273                        assert!(
1274                            !profile.allows(spec.feature),
1275                            "{version} over {transport}: {} is gated but is on",
1276                            spec.feature
1277                        );
1278                    }
1279                }
1280            }
1281        }
1282        // And the ungated ones that are in range really are on, so the loop
1283        // above is not passing because `resolve` returns nothing at all.
1284        let old = nothing_on(ProtocolVersion::V2024_11_05, TransportKind::Stdio);
1285        assert!(old.allows(Feature::Ping));
1286        assert!(
1287            !old.allows(Feature::MetaExtensions),
1288            "`_meta` starts in 2025-06-18"
1289        );
1290        let mid = nothing_on(ProtocolVersion::V2025_06_18, TransportKind::Stdio);
1291        assert!(mid.allows(Feature::MetaExtensions));
1292        assert!(mid.allows(Feature::McpProtocolVersionHeader));
1293    }
1294
1295    /// The fail-secure property, at the top of the stack this time.
1296    #[test]
1297    fn an_unknown_revision_yields_nothing() {
1298        let future = ProtocolVersion::Unknown("2027-01-01".parse().unwrap());
1299        assert!(everything_on(future, TransportKind::Stdio).is_empty());
1300    }
1301
1302    #[test]
1303    fn batching_exists_for_exactly_one_revision() {
1304        for version in ProtocolVersion::KNOWN {
1305            let on = everything_on(version, TransportKind::Stdio).allows(Feature::JsonRpcBatching);
1306            assert_eq!(
1307                on,
1308                version == ProtocolVersion::V2025_03_26,
1309                "batching in {version}"
1310            );
1311        }
1312    }
1313
1314    #[test]
1315    fn the_2026_removals_are_actually_removed() {
1316        for version in ProtocolVersion::KNOWN {
1317            let profile = everything_on(version, TransportKind::Stdio);
1318            let newest = version == ProtocolVersion::V2026_07_28;
1319            assert_eq!(profile.allows(Feature::Ping), !newest, "ping in {version}");
1320            assert_eq!(
1321                profile.allows(Feature::RootsListChanged),
1322                !newest,
1323                "roots.listChanged in {version}"
1324            );
1325            // Tasks came in 2025-11-25 and left in 2026-07-28.
1326            let expected = version == ProtocolVersion::V2025_11_25;
1327            assert_eq!(
1328                profile.allows(Feature::Tasks),
1329                expected,
1330                "tasks in {version}"
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn a_capability_nobody_advertised_keeps_its_feature_off() {
1337        let version = ProtocolVersion::V2026_07_28;
1338        let mut server = CapabilitySet::empty();
1339        server.insert(Capability::Tools);
1340        let profile = resolve(Negotiation {
1341            version,
1342            client_caps: CapabilitySet::all_for(version),
1343            server_caps: server,
1344            transport: TransportKind::Stdio,
1345        });
1346        // Core tools work, because the server said it has them.
1347        assert!(profile.allows(Feature::Tools));
1348        // `completions` needs the server to advertise it.
1349        assert!(!profile.allows(Feature::Completions));
1350        assert!(!profile.allows(Feature::ToolsListChanged));
1351        // The server said nothing about resources, so it has none.
1352        assert!(!profile.allows(Feature::Resources));
1353        assert!(!profile.allows(Feature::Prompts));
1354    }
1355
1356    #[test]
1357    fn a_dependent_capability_does_not_imply_its_parent() {
1358        // A peer that sends `elicitation.url` without `elicitation` is
1359        // malformed, and the resolver must not quietly promote it: the row
1360        // names `elicitation.url` and `elicitation.url` is what it checks.
1361        let version = ProtocolVersion::V2025_11_25;
1362        let client = CapabilitySet::empty().with(Capability::ElicitationUrl);
1363        let caps = client;
1364        let profile = resolve(Negotiation {
1365            version,
1366            client_caps: caps,
1367            server_caps: CapabilitySet::all_for(version),
1368            transport: TransportKind::Stdio,
1369        });
1370        assert!(profile.allows(Feature::ElicitationUrl));
1371        assert!(!profile.allows(Feature::Elicitation));
1372        assert!(!profile.allows(Feature::ElicitationForm));
1373    }
1374
1375    #[test]
1376    fn transport_gated_features_follow_the_transport() {
1377        // Streamable HTTP is not defined before 2025-03-26 and not on stdio.
1378        let old = everything_on(ProtocolVersion::V2024_11_05, TransportKind::Stdio);
1379        assert!(!old.allows(Feature::StreamableHttp));
1380
1381        let new = everything_on(ProtocolVersion::V2026_07_28, TransportKind::StreamableHttp);
1382        assert!(new.allows(Feature::StreamableHttp));
1383
1384        let on_stdio = everything_on(ProtocolVersion::V2026_07_28, TransportKind::Stdio);
1385        assert!(!on_stdio.allows(Feature::StreamableHttp));
1386    }
1387
1388    #[test]
1389    fn legacy_sse_survives_only_on_2024_11_05() {
1390        for version in ProtocolVersion::KNOWN {
1391            let profile = everything_on(version, TransportKind::LegacySse);
1392            let expected = version == ProtocolVersion::V2024_11_05;
1393            assert_eq!(
1394                profile.allows(Feature::LegacySse),
1395                expected,
1396                "legacy sse in {version}"
1397            );
1398        }
1399    }
1400
1401    #[test]
1402    fn profile_set_operations_behave() {
1403        let full = FeatureProfile::all();
1404        let empty = FeatureProfile::empty();
1405        assert!(empty.difference(full).is_empty());
1406        assert_eq!(full.difference(empty), full);
1407        assert_eq!(full.intersection(full), full);
1408        assert!(full.intersection(empty).is_empty());
1409
1410        let one = FeatureProfile::empty().with(Feature::Tools);
1411        assert_eq!(one.difference(full).len(), 0);
1412        assert_eq!(full.difference(one).len() as usize, Feature::ALL.len() - 1);
1413    }
1414
1415    #[test]
1416    fn insert_and_remove_work_in_place() {
1417        let mut profile = FeatureProfile::empty();
1418        assert!(!profile.allows(Feature::Tools));
1419        profile.insert(Feature::Tools);
1420        assert!(profile.allows(Feature::Tools));
1421        profile.remove(Feature::Tools);
1422        assert!(!profile.allows(Feature::Tools));
1423        assert!(profile.is_empty());
1424    }
1425
1426    #[test]
1427    fn a_strict_policy_accepts_only_a_complete_profile() {
1428        let strict = DegradePolicy::strict();
1429        let full = everything_on(ProtocolVersion::V2026_07_28, TransportKind::Stdio);
1430        // The full profile is missing the features that revision removed, so
1431        // even it is not accepted by a strict policy — which is correct: a
1432        // strict policy means "no losses at all".
1433        assert!(!strict.accepts(full));
1434        assert!(strict.accepts(FeatureProfile::all()));
1435
1436        // One tolerated loss is enough to make the full profile acceptable.
1437        let mut lenient = DegradePolicy::strict();
1438        for feature in full.missing() {
1439            lenient.insert(feature);
1440        }
1441        assert!(lenient.accepts(full));
1442    }
1443
1444    #[test]
1445    fn a_permissive_policy_accepts_anything() {
1446        let permissive = DegradePolicy::permissive();
1447        assert!(permissive.accepts(FeatureProfile::empty()));
1448        assert!(permissive.accepts(FeatureProfile::all()));
1449        assert!(permissive.allows(Feature::Ping));
1450    }
1451
1452    #[test]
1453    fn a_policy_tolerating_one_feature_still_rejects_another() {
1454        let policy = DegradePolicy::strict().with(Feature::Ping);
1455        assert!(policy.allows(Feature::Ping));
1456        assert!(!policy.allows(Feature::Tools));
1457        // Loses both features, so one tolerance is not enough.
1458        let lossy = FeatureProfile::empty()
1459            .with(Feature::Tools)
1460            .with(Feature::Resources);
1461        assert!(!policy.accepts(lossy));
1462    }
1463}