Skip to main content

turbomcp_protocol/versioning/
adapter.rs

1//! Version adapter layer for multi-version MCP protocol support.
2//!
3//! The adapter sits between the transport and handler layers, transforming
4//! outgoing messages and filtering capabilities based on the negotiated
5//! protocol version. This allows the codebase to always work with the latest
6//! types internally while producing spec-compliant wire output for older clients.
7//!
8//! # Architecture
9//!
10//! ```text
11//! Transport → (incoming) → Router → Handler
12//! Handler → Router → VersionAdapter::filter → Transport (outgoing)
13//! ```
14//!
15//! # Usage
16//!
17//! ```rust
18//! use turbomcp_protocol::versioning::adapter::{VersionAdapter, adapter_for_version};
19//! use turbomcp_types::ProtocolVersion;
20//!
21//! let adapter = adapter_for_version(&ProtocolVersion::V2025_06_18);
22//! assert_eq!(adapter.version(), &ProtocolVersion::V2025_06_18);
23//! ```
24
25use serde_json::Value;
26use std::collections::HashSet;
27use turbomcp_types::ProtocolVersion;
28
29use crate::types::capabilities::ServerCapabilities;
30
31/// Trait for adapting protocol messages to a specific MCP spec version.
32///
33/// Implementations strip fields, reject methods, and filter capabilities
34/// that don't exist in the target version.
35pub trait VersionAdapter: Send + Sync + std::fmt::Debug {
36    /// The protocol version this adapter targets.
37    fn version(&self) -> &ProtocolVersion;
38
39    /// Filter server capabilities for the target version.
40    ///
41    /// Removes capabilities that don't exist in the target spec version.
42    fn filter_capabilities(&self, caps: ServerCapabilities) -> ServerCapabilities;
43
44    /// Filter an outgoing JSON-RPC result value for the target version.
45    ///
46    /// Strips fields from the JSON that don't exist in the target spec.
47    /// The `method` parameter indicates which RPC method produced this result.
48    fn filter_result(&self, method: &str, result: Value) -> Value;
49
50    /// Validate that an incoming method is supported in the target version.
51    ///
52    /// Returns `Ok(())` if the method exists in the target spec,
53    /// or `Err(reason)` if it should be rejected.
54    fn validate_method(&self, method: &str) -> Result<(), String>;
55
56    /// Methods that are valid in the target version.
57    fn supported_methods(&self) -> &HashSet<&'static str>;
58}
59
60// =============================================================================
61// MCP 2025-11-25 Adapter (pass-through — current version)
62// =============================================================================
63
64/// Adapter for MCP 2025-11-25 (current stable spec).
65///
66/// Strips draft-only fields such as capability `extensions`.
67#[derive(Debug)]
68pub struct V2025_11_25Adapter;
69
70impl VersionAdapter for V2025_11_25Adapter {
71    fn version(&self) -> &ProtocolVersion {
72        &ProtocolVersion::V2025_11_25
73    }
74
75    fn filter_capabilities(&self, caps: ServerCapabilities) -> ServerCapabilities {
76        let mut caps = caps;
77        caps.extensions = None;
78        caps
79    }
80
81    fn filter_result(&self, method: &str, mut result: Value) -> Value {
82        if method == "initialize"
83            && let Some(caps) = result.get_mut("capabilities")
84        {
85            strip_keys(caps, &["extensions"]);
86        }
87        result
88    }
89
90    fn validate_method(&self, _method: &str) -> Result<(), String> {
91        Ok(()) // all methods valid
92    }
93
94    fn supported_methods(&self) -> &HashSet<&'static str> {
95        &METHODS_2025_11_25
96    }
97}
98
99// =============================================================================
100// MCP 2025-06-18 Adapter (strips 2025-11-25 additions)
101// =============================================================================
102
103/// Adapter for MCP 2025-06-18 (previous stable spec).
104///
105/// Strips fields and capabilities that were added in 2025-11-25:
106/// - `icons` on Tool, Prompt, Resource, Implementation
107/// - `execution` (taskSupport) on Tool
108/// - `description`, `websiteUrl` on Implementation/ServerInfo
109/// - `tasks` capability
110/// - URL mode elicitation (capability and methods)
111/// - `outputSchema` on Tool
112#[derive(Debug)]
113pub struct V2025_06_18Adapter;
114
115impl VersionAdapter for V2025_06_18Adapter {
116    fn version(&self) -> &ProtocolVersion {
117        &ProtocolVersion::V2025_06_18
118    }
119
120    fn filter_capabilities(&self, caps: ServerCapabilities) -> ServerCapabilities {
121        let mut caps = caps;
122        caps.extensions = None;
123        // Tasks didn't exist in 2025-06-18 - always strip regardless of feature flag,
124        // since the field is always present on `ServerCapabilities`.
125        caps.tasks = None;
126        caps
127    }
128
129    fn filter_result(&self, method: &str, mut result: Value) -> Value {
130        match method {
131            "initialize" => {
132                // Strip new serverInfo fields (added in 2025-11-25)
133                if let Some(info) = result.get_mut("serverInfo") {
134                    strip_keys(info, &["description", "icons", "websiteUrl"]);
135                }
136                if let Some(caps) = result.get_mut("capabilities") {
137                    // Strip tasks capability (new in 2025-11-25)
138                    strip_keys(caps, &["tasks", "extensions"]);
139                    // Strip url sub-capability from elicitation (new in 2025-11-25)
140                    if let Some(elicitation) = caps.get_mut("elicitation") {
141                        strip_keys(elicitation, &["url"]);
142                    }
143                    // Strip tools sub-capability from sampling (new in 2025-11-25)
144                    if let Some(sampling) = caps.get_mut("sampling") {
145                        strip_keys(sampling, &["tools"]);
146                    }
147                }
148                result
149            }
150            "tools/list" => {
151                strip_from_array(
152                    &mut result,
153                    "tools",
154                    &["icons", "execution", "outputSchema"],
155                );
156                result
157            }
158            "prompts/list" => {
159                strip_from_array(&mut result, "prompts", &["icons"]);
160                result
161            }
162            "resources/list" => {
163                strip_from_array(&mut result, "resources", &["icons"]);
164                result
165            }
166            "resources/templates/list" => {
167                strip_from_array(&mut result, "resourceTemplates", &["icons"]);
168                result
169            }
170            _ => result,
171        }
172    }
173
174    fn validate_method(&self, method: &str) -> Result<(), String> {
175        if METHODS_2025_11_25_ONLY.contains(method) {
176            Err(format!(
177                "Method '{method}' is not available in MCP 2025-06-18"
178            ))
179        } else {
180            Ok(())
181        }
182    }
183
184    fn supported_methods(&self) -> &HashSet<&'static str> {
185        &METHODS_2025_06_18
186    }
187}
188
189// =============================================================================
190// Draft Adapter (adds extensions support)
191// =============================================================================
192
193/// Adapter for the draft MCP specification (DRAFT-2026-v1).
194///
195/// Passes through everything from 2025-11-25 plus supports the new
196/// `extensions` field on capabilities.
197#[derive(Debug)]
198pub struct DraftAdapter;
199
200impl VersionAdapter for DraftAdapter {
201    fn version(&self) -> &ProtocolVersion {
202        &ProtocolVersion::Draft
203    }
204
205    fn filter_capabilities(&self, caps: ServerCapabilities) -> ServerCapabilities {
206        caps // pass-through — draft is superset of 2025-11-25
207    }
208
209    fn filter_result(&self, _method: &str, result: Value) -> Value {
210        result // pass-through
211    }
212
213    fn validate_method(&self, _method: &str) -> Result<(), String> {
214        Ok(()) // all methods valid
215    }
216
217    fn supported_methods(&self) -> &HashSet<&'static str> {
218        &METHODS_2025_11_25 // draft uses same methods as 2025-11-25
219    }
220}
221
222// =============================================================================
223// Adapter Registry
224// =============================================================================
225
226// Static adapter instances — zero-sized types with no state, so these are
227// trivially const-constructible and eliminate per-request heap allocation.
228static ADAPTER_V2025_06_18: V2025_06_18Adapter = V2025_06_18Adapter;
229static ADAPTER_V2025_11_25: V2025_11_25Adapter = V2025_11_25Adapter;
230static ADAPTER_DRAFT: DraftAdapter = DraftAdapter;
231
232/// Get the appropriate version adapter for a protocol version.
233///
234/// Returns a static reference to the adapter for the given version.
235/// Unknown versions fall back to the latest stable adapter.
236pub fn adapter_for_version(version: &ProtocolVersion) -> &'static dyn VersionAdapter {
237    match version {
238        ProtocolVersion::V2025_06_18 => &ADAPTER_V2025_06_18,
239        ProtocolVersion::V2025_11_25 => &ADAPTER_V2025_11_25,
240        ProtocolVersion::Draft => &ADAPTER_DRAFT,
241        ProtocolVersion::Unknown(_) => &ADAPTER_V2025_11_25, // fallback
242    }
243}
244
245// =============================================================================
246// Helpers
247// =============================================================================
248
249/// Strip keys from a JSON object.
250fn strip_keys(value: &mut Value, keys: &[&str]) {
251    if let Value::Object(map) = value {
252        for key in keys {
253            map.remove(*key);
254        }
255    }
256}
257
258/// Strip keys from each element in a JSON array within a result object.
259fn strip_from_array(result: &mut Value, array_key: &str, keys: &[&str]) {
260    if let Some(Value::Array(items)) = result.get_mut(array_key) {
261        for item in items.iter_mut() {
262            strip_keys(item, keys);
263        }
264    }
265}
266
267// =============================================================================
268// Method Sets
269// =============================================================================
270
271use std::sync::LazyLock;
272
273/// Methods available in MCP 2025-06-18.
274static METHODS_2025_06_18: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
275    HashSet::from([
276        "initialize",
277        "ping",
278        "tools/list",
279        "tools/call",
280        "resources/list",
281        "resources/templates/list",
282        "resources/read",
283        "resources/subscribe",
284        "resources/unsubscribe",
285        "prompts/list",
286        "prompts/get",
287        "completion/complete",
288        "logging/setLevel",
289        "notifications/initialized",
290        "notifications/cancelled",
291        "notifications/progress",
292        "notifications/message",
293        "notifications/resources/updated",
294        "notifications/resources/list_changed",
295        "notifications/tools/list_changed",
296        "notifications/prompts/list_changed",
297        "notifications/roots/list_changed",
298        "roots/list",
299        "sampling/createMessage",
300        "elicitation/create",
301    ])
302});
303
304/// Methods available in MCP 2025-11-25 (superset of 2025-06-18).
305static METHODS_2025_11_25: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
306    let mut methods = METHODS_2025_06_18.clone();
307    methods.extend([
308        "tasks/get",
309        "tasks/result",
310        "tasks/list",
311        "tasks/cancel",
312        "notifications/tasks/status",
313        "notifications/elicitation/complete",
314    ]);
315    methods
316});
317
318/// Methods that exist only in 2025-11-25 (not in 2025-06-18).
319static METHODS_2025_11_25_ONLY: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
320    METHODS_2025_11_25
321        .difference(&METHODS_2025_06_18)
322        .copied()
323        .collect()
324});
325
326// =============================================================================
327// Tests
328// =============================================================================
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use serde_json::json;
334
335    #[test]
336    fn test_adapter_for_known_versions() {
337        let adapter = adapter_for_version(&ProtocolVersion::V2025_06_18);
338        assert_eq!(adapter.version(), &ProtocolVersion::V2025_06_18);
339
340        let adapter = adapter_for_version(&ProtocolVersion::V2025_11_25);
341        assert_eq!(adapter.version(), &ProtocolVersion::V2025_11_25);
342
343        let adapter = adapter_for_version(&ProtocolVersion::Draft);
344        assert_eq!(adapter.version(), &ProtocolVersion::Draft);
345    }
346
347    #[test]
348    fn test_unknown_version_falls_back() {
349        let adapter = adapter_for_version(&ProtocolVersion::Unknown("9999-01-01".into()));
350        assert_eq!(adapter.version(), &ProtocolVersion::V2025_11_25);
351    }
352
353    #[test]
354    fn test_v2025_11_25_passthrough() {
355        let adapter = V2025_11_25Adapter;
356        let caps = ServerCapabilities::default();
357        let filtered = adapter.filter_capabilities(caps.clone());
358        assert_eq!(
359            serde_json::to_string(&filtered).unwrap(),
360            serde_json::to_string(&caps).unwrap()
361        );
362    }
363
364    #[test]
365    fn test_v2025_11_25_strips_draft_extensions() {
366        use std::collections::HashMap;
367
368        let adapter = V2025_11_25Adapter;
369
370        let mut extensions = HashMap::new();
371        extensions.insert(
372            "io.modelcontextprotocol/trace".to_string(),
373            serde_json::json!({"version": "1"}),
374        );
375        let caps = ServerCapabilities {
376            extensions: Some(extensions),
377            ..Default::default()
378        };
379        let filtered = adapter.filter_capabilities(caps);
380        assert!(
381            filtered.extensions.is_none(),
382            "extensions field should be stripped for stable 2025-11-25"
383        );
384
385        let result = json!({
386            "capabilities": {
387                "tools": { "listChanged": true },
388                "extensions": { "io.modelcontextprotocol/trace": { "version": "1" } }
389            }
390        });
391        let filtered = adapter.filter_result("initialize", result);
392        assert!(filtered["capabilities"]["tools"].is_object());
393        assert!(
394            filtered["capabilities"].get("extensions").is_none(),
395            "extensions key should be stripped from initialize result"
396        );
397    }
398
399    #[test]
400    fn test_draft_preserves_extensions() {
401        use std::collections::HashMap;
402
403        let adapter = DraftAdapter;
404
405        let mut extensions = HashMap::new();
406        extensions.insert(
407            "io.modelcontextprotocol/trace".to_string(),
408            serde_json::json!({"version": "1"}),
409        );
410        let caps = ServerCapabilities {
411            extensions: Some(extensions),
412            ..Default::default()
413        };
414        let filtered = adapter.filter_capabilities(caps);
415        assert!(
416            filtered
417                .extensions
418                .as_ref()
419                .is_some_and(|m| m.contains_key("io.modelcontextprotocol/trace")),
420            "draft adapter must preserve extensions"
421        );
422    }
423
424    #[test]
425    fn test_v2025_06_18_strips_tools_icons() {
426        let adapter = V2025_06_18Adapter;
427        let result = json!({
428            "tools": [
429                {
430                    "name": "my-tool",
431                    "description": "A tool",
432                    "inputSchema": { "type": "object" },
433                    "icons": [{ "src": "https://example.com/icon.png" }],
434                    "execution": { "taskSupport": "optional" },
435                    "outputSchema": { "type": "object" }
436                }
437            ]
438        });
439
440        let filtered = adapter.filter_result("tools/list", result);
441        let tool = &filtered["tools"][0];
442        assert!(tool.get("name").is_some());
443        assert!(tool.get("description").is_some());
444        assert!(tool.get("icons").is_none(), "icons should be stripped");
445        assert!(
446            tool.get("execution").is_none(),
447            "execution should be stripped"
448        );
449        assert!(
450            tool.get("outputSchema").is_none(),
451            "outputSchema should be stripped"
452        );
453    }
454
455    #[test]
456    fn test_v2025_06_18_strips_server_info() {
457        let adapter = V2025_06_18Adapter;
458        let result = json!({
459            "protocolVersion": "2025-06-18",
460            "serverInfo": {
461                "name": "my-server",
462                "version": "1.0.0",
463                "description": "A server",
464                "icons": [{ "src": "https://example.com/icon.png" }],
465                "websiteUrl": "https://example.com"
466            },
467            "capabilities": {
468                "tools": { "listChanged": true },
469                "tasks": { "list": {} }
470            }
471        });
472
473        let filtered = adapter.filter_result("initialize", result);
474        let info = &filtered["serverInfo"];
475        assert!(info.get("name").is_some());
476        assert!(
477            info.get("description").is_none(),
478            "description should be stripped"
479        );
480        assert!(info.get("icons").is_none(), "icons should be stripped");
481        assert!(
482            info.get("websiteUrl").is_none(),
483            "websiteUrl should be stripped"
484        );
485
486        let caps = &filtered["capabilities"];
487        assert!(caps.get("tools").is_some());
488        assert!(
489            caps.get("tasks").is_none(),
490            "tasks capability should be stripped"
491        );
492    }
493
494    #[test]
495    fn test_v2025_06_18_rejects_task_methods() {
496        let adapter = V2025_06_18Adapter;
497        assert!(adapter.validate_method("tools/list").is_ok());
498        assert!(adapter.validate_method("tools/call").is_ok());
499        assert!(adapter.validate_method("tasks/get").is_err());
500        assert!(adapter.validate_method("tasks/list").is_err());
501        assert!(
502            adapter
503                .validate_method("notifications/tasks/status")
504                .is_err()
505        );
506    }
507
508    #[test]
509    fn test_v2025_06_18_strips_prompts_icons() {
510        let adapter = V2025_06_18Adapter;
511        let result = json!({
512            "prompts": [
513                {
514                    "name": "my-prompt",
515                    "description": "A prompt",
516                    "icons": [{ "src": "https://example.com/icon.png" }]
517                }
518            ]
519        });
520
521        let filtered = adapter.filter_result("prompts/list", result);
522        let prompt = &filtered["prompts"][0];
523        assert!(prompt.get("name").is_some());
524        assert!(prompt.get("icons").is_none(), "icons should be stripped");
525    }
526
527    #[test]
528    fn test_v2025_06_18_strips_resources_icons() {
529        let adapter = V2025_06_18Adapter;
530        let result = json!({
531            "resources": [
532                {
533                    "uri": "file:///test.txt",
534                    "name": "test",
535                    "icons": [{ "src": "https://example.com/icon.png" }]
536                }
537            ]
538        });
539
540        let filtered = adapter.filter_result("resources/list", result);
541        let resource = &filtered["resources"][0];
542        assert!(resource.get("uri").is_some());
543        assert!(resource.get("icons").is_none(), "icons should be stripped");
544    }
545
546    #[test]
547    fn test_v2025_06_18_supports_and_strips_resource_templates() {
548        let adapter = V2025_06_18Adapter;
549        assert!(adapter.validate_method("resources/templates/list").is_ok());
550
551        let result = json!({
552            "resourceTemplates": [
553                {
554                    "uriTemplate": "file://{path}",
555                    "name": "file",
556                    "icons": [{ "src": "https://example.com/icon.png" }]
557                }
558            ]
559        });
560
561        let filtered = adapter.filter_result("resources/templates/list", result);
562        let template = &filtered["resourceTemplates"][0];
563        assert_eq!(template["uriTemplate"], "file://{path}");
564        assert!(template.get("icons").is_none(), "icons should be stripped");
565    }
566
567    #[test]
568    fn test_draft_passthrough() {
569        let adapter = DraftAdapter;
570        assert!(adapter.validate_method("tools/list").is_ok());
571        assert!(adapter.validate_method("tasks/get").is_ok());
572    }
573
574    #[test]
575    fn test_method_sets_are_consistent() {
576        // 2025-11-25 should be a superset of 2025-06-18
577        for method in METHODS_2025_06_18.iter() {
578            assert!(
579                METHODS_2025_11_25.contains(method),
580                "2025-11-25 should contain all 2025-06-18 methods, missing: {method}"
581            );
582        }
583
584        // 2025-11-25 only should have no overlap with 2025-06-18
585        for method in METHODS_2025_11_25_ONLY.iter() {
586            assert!(
587                !METHODS_2025_06_18.contains(method),
588                "2025-11-25-only method {method} should not be in 2025-06-18"
589            );
590        }
591    }
592
593    #[test]
594    fn test_elicitation_capabilities_backward_compat() {
595        use crate::types::capabilities::ElicitationCapabilities;
596
597        // Empty object should support form mode (backward compat)
598        let empty = ElicitationCapabilities::default();
599        assert!(
600            empty.supports_form(),
601            "empty caps should default to form support"
602        );
603        assert!(
604            !empty.supports_url(),
605            "empty caps should not support URL mode"
606        );
607
608        // Explicit form+url
609        let full = ElicitationCapabilities::full();
610        assert!(full.supports_form());
611        assert!(full.supports_url());
612
613        // Form only
614        let form = ElicitationCapabilities::form_only();
615        assert!(form.supports_form());
616        assert!(!form.supports_url());
617    }
618}