turbomcp_protocol/versioning/
adapter.rs1use serde_json::Value;
26use std::collections::HashSet;
27use turbomcp_types::ProtocolVersion;
28
29use crate::types::capabilities::ServerCapabilities;
30
31pub trait VersionAdapter: Send + Sync + std::fmt::Debug {
36 fn version(&self) -> &ProtocolVersion;
38
39 fn filter_capabilities(&self, caps: ServerCapabilities) -> ServerCapabilities;
43
44 fn filter_result(&self, method: &str, result: Value) -> Value;
49
50 fn validate_method(&self, method: &str) -> Result<(), String>;
55
56 fn supported_methods(&self) -> &HashSet<&'static str>;
58}
59
60#[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(()) }
93
94 fn supported_methods(&self) -> &HashSet<&'static str> {
95 &METHODS_2025_11_25
96 }
97}
98
99#[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 caps.tasks = None;
126 caps
127 }
128
129 fn filter_result(&self, method: &str, mut result: Value) -> Value {
130 match method {
131 "initialize" => {
132 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_keys(caps, &["tasks", "extensions"]);
139 if let Some(elicitation) = caps.get_mut("elicitation") {
141 strip_keys(elicitation, &["url"]);
142 }
143 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#[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 }
208
209 fn filter_result(&self, _method: &str, result: Value) -> Value {
210 result }
212
213 fn validate_method(&self, _method: &str) -> Result<(), String> {
214 Ok(()) }
216
217 fn supported_methods(&self) -> &HashSet<&'static str> {
218 &METHODS_2025_11_25 }
220}
221
222static 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
232pub 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, }
243}
244
245fn 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
258fn 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
267use std::sync::LazyLock;
272
273static 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
304static 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
318static 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#[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 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 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 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 let full = ElicitationCapabilities::full();
610 assert!(full.supports_form());
611 assert!(full.supports_url());
612
613 let form = ElicitationCapabilities::form_only();
615 assert!(form.supports_form());
616 assert!(!form.supports_url());
617 }
618}