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}