1use serde_json::{json, Value as JsonValue};
4
5pub const PROTOCOL_VERSION: &str = "2025-11-25";
7pub const LEGACY_2025_06_18_PROTOCOL_VERSION: &str = "2025-06-18";
11pub const LEGACY_2024_11_05_PROTOCOL_VERSION: &str = "2024-11-05";
15pub const DRAFT_PROTOCOL_VERSION: &str = "DRAFT-2026-v1";
20
21pub const METHOD_SERVER_DISCOVER: &str = "server/discover";
22pub const METHOD_TASKS_GET: &str = "tasks/get";
23pub const METHOD_TASKS_RESULT: &str = "tasks/result";
24pub const METHOD_TASKS_LIST: &str = "tasks/list";
25pub const METHOD_TASKS_CANCEL: &str = "tasks/cancel";
26pub const METHOD_COMPLETION_COMPLETE: &str = "completion/complete";
27pub const METHOD_SAMPLING_CREATE_MESSAGE: &str = "sampling/createMessage";
28pub const METHOD_ELICITATION_CREATE: &str = "elicitation/create";
29pub const METHOD_TASK_STATUS_NOTIFICATION: &str = "notifications/tasks/status";
30pub const METHOD_ROOTS_LIST: &str = "roots/list";
31pub const METHOD_ROOTS_LIST_CHANGED_NOTIFICATION: &str = "notifications/roots/list_changed";
32pub const METHOD_LOGGING_SET_LEVEL: &str = "logging/setLevel";
33pub const METHOD_LOGGING_MESSAGE_NOTIFICATION: &str = "notifications/message";
34pub const RELATED_TASK_META_KEY: &str = "io.modelcontextprotocol/related-task";
35
36pub const RC_META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
39pub const RC_META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo";
40pub const RC_META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
41
42pub const RC_HEADER_PROTOCOL_VERSION: &str = "mcp-protocol-version";
44pub const RC_HEADER_METHOD: &str = "mcp-method";
45pub const RC_HEADER_NAME: &str = "mcp-name";
46pub const MCP_SESSION_HEADER_LEGACY: &str = "mcp-session-id";
49
50pub const RESULT_TYPE_COMPLETE: &str = "complete";
54pub const RESULT_TYPE_INPUT_REQUIRED: &str = "input_required";
55
56pub const UNSUPPORTED_PROTOCOL_VERSION_CODE: i64 = -32004;
60
61pub const DEFAULT_TASK_POLL_INTERVAL_MS: u64 = 250;
62pub const DEFAULT_MCP_LIST_PAGE_SIZE: usize = 100;
63pub const MCP_LIST_PAGE_SIZE_ENV: &str = "HARN_MCP_LIST_PAGE_SIZE";
64
65pub const DEFAULT_LIST_CACHE_TTL_MS: u64 = 5_000;
70pub const DEFAULT_LIST_CACHE_SCOPE: &str = "private";
71pub const DEFAULT_READ_CACHE_TTL_MS: u64 = 1_000;
72pub const DEFAULT_READ_CACHE_SCOPE: &str = "private";
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct McpListPage {
76 pub start: usize,
77 pub end: usize,
78 pub next_cursor: Option<String>,
79}
80
81pub const MCP_COMPLETION_MAX_VALUES: usize = 100;
82
83#[derive(Clone, Copy, Debug, PartialEq, Eq)]
84pub enum McpTaskStatus {
85 Working,
86 InputRequired,
87 Completed,
88 Failed,
89 Cancelled,
90}
91
92impl McpTaskStatus {
93 pub fn as_str(self) -> &'static str {
94 match self {
95 Self::Working => "working",
96 Self::InputRequired => "input_required",
97 Self::Completed => "completed",
98 Self::Failed => "failed",
99 Self::Cancelled => "cancelled",
100 }
101 }
102
103 pub fn is_terminal(self) -> bool {
104 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
105 }
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum McpToolTaskSupport {
110 Required,
111 Optional,
112 Forbidden,
113}
114
115impl McpToolTaskSupport {
116 pub fn as_str(self) -> &'static str {
117 match self {
118 Self::Required => "required",
119 Self::Optional => "optional",
120 Self::Forbidden => "forbidden",
121 }
122 }
123}
124
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
128pub enum McpProtocolMode {
129 #[default]
131 Legacy,
132 Modern,
135}
136
137impl McpProtocolMode {
138 pub fn is_modern(self) -> bool {
139 matches!(self, Self::Modern)
140 }
141
142 pub fn default_protocol_version(self) -> &'static str {
143 match self {
144 Self::Legacy => PROTOCOL_VERSION,
145 Self::Modern => DRAFT_PROTOCOL_VERSION,
146 }
147 }
148}
149
150pub fn supported_protocol_versions() -> &'static [&'static str] {
153 &[
154 DRAFT_PROTOCOL_VERSION,
155 PROTOCOL_VERSION,
156 LEGACY_2025_06_18_PROTOCOL_VERSION,
157 LEGACY_2024_11_05_PROTOCOL_VERSION,
158 ]
159}
160
161pub fn is_supported_protocol_version(version: &str) -> bool {
162 supported_protocol_versions().contains(&version)
163}
164
165#[derive(Clone, Debug, Default, PartialEq, Eq)]
168pub struct McpRequestMetadata {
169 pub protocol_version: Option<String>,
170 pub client_info: Option<JsonValue>,
171 pub client_capabilities: Option<JsonValue>,
172}
173
174impl McpRequestMetadata {
175 pub fn mode(&self) -> McpProtocolMode {
176 match self.protocol_version.as_deref() {
177 Some(DRAFT_PROTOCOL_VERSION) => McpProtocolMode::Modern,
178 _ => McpProtocolMode::Legacy,
179 }
180 }
181}
182
183pub fn parse_request_metadata(params: &JsonValue) -> McpRequestMetadata {
187 let Some(meta) = params.get("_meta").and_then(JsonValue::as_object) else {
188 return McpRequestMetadata::default();
189 };
190 let protocol_version = meta
191 .get(RC_META_KEY_PROTOCOL_VERSION)
192 .and_then(JsonValue::as_str)
193 .map(str::to_string);
194 let client_info = meta.get(RC_META_KEY_CLIENT_INFO).cloned();
195 let client_capabilities = meta.get(RC_META_KEY_CLIENT_CAPABILITIES).cloned();
196 McpRequestMetadata {
197 protocol_version,
198 client_info,
199 client_capabilities,
200 }
201}
202
203pub fn enforce_request_protocol_version(
209 id: &JsonValue,
210 metadata: &McpRequestMetadata,
211) -> Result<Option<McpProtocolMode>, JsonValue> {
212 let Some(version) = metadata.protocol_version.as_deref() else {
213 return Ok(None);
214 };
215 if !is_supported_protocol_version(version) {
216 return Err(unsupported_protocol_version_response(id.clone(), version));
217 }
218 Ok(Some(metadata.mode()))
219}
220
221pub fn unsupported_protocol_version_response(
224 id: impl Into<JsonValue>,
225 requested: &str,
226) -> JsonValue {
227 crate::jsonrpc::error_response_with_data(
228 id,
229 UNSUPPORTED_PROTOCOL_VERSION_CODE,
230 "Unsupported protocol version",
231 json!({
232 "supported": supported_protocol_versions(),
233 "requested": requested,
234 }),
235 )
236}
237
238#[derive(Clone, Debug)]
242pub struct RcHttpHeaderOutcome {
243 pub mode: McpProtocolMode,
244 pub protocol_version: Option<String>,
245}
246
247pub fn negotiate_rc_http_request<'a, F>(
256 headers: F,
257 body_method: Option<&str>,
258 body_name: Option<&str>,
259 request_id: &JsonValue,
260) -> Result<RcHttpHeaderOutcome, JsonValue>
261where
262 F: Fn(&str) -> Option<&'a str>,
263{
264 let mut outcome = RcHttpHeaderOutcome {
265 mode: McpProtocolMode::Legacy,
266 protocol_version: None,
267 };
268
269 if let Some(value) = headers(RC_HEADER_PROTOCOL_VERSION) {
270 if !is_supported_protocol_version(value) {
271 return Err(unsupported_protocol_version_response(
272 request_id.clone(),
273 value,
274 ));
275 }
276 outcome.protocol_version = Some(value.to_string());
277 if value == DRAFT_PROTOCOL_VERSION {
278 outcome.mode = McpProtocolMode::Modern;
279 }
280 }
281
282 if let Some(method_header) = headers(RC_HEADER_METHOD) {
283 outcome.mode = McpProtocolMode::Modern;
284 if let Some(body_method) = body_method {
285 if method_header != body_method {
286 return Err(crate::jsonrpc::error_response_with_data(
287 request_id.clone(),
288 -32600,
289 "Mcp-Method header does not match request body",
290 json!({
291 "headerValue": method_header,
292 "bodyMethod": body_method,
293 }),
294 ));
295 }
296 }
297 }
298
299 if let Some(name_header) = headers(RC_HEADER_NAME) {
300 outcome.mode = McpProtocolMode::Modern;
301 let expected = body_name.unwrap_or_default();
302 if !expected.is_empty() && name_header != expected {
303 return Err(crate::jsonrpc::error_response_with_data(
304 request_id.clone(),
305 -32600,
306 "Mcp-Name header does not match request body",
307 json!({
308 "headerValue": name_header,
309 "bodyName": expected,
310 }),
311 ));
312 }
313 }
314
315 Ok(outcome)
316}
317
318pub fn rc_name_header_value(method: &str, params: &JsonValue) -> Option<String> {
322 match method {
323 "tools/call" | "prompts/get" => params
324 .get("name")
325 .and_then(JsonValue::as_str)
326 .map(str::to_string),
327 "resources/read" => params
328 .get("uri")
329 .and_then(JsonValue::as_str)
330 .map(str::to_string),
331 _ => None,
332 }
333}
334
335pub fn apply_rc_result_envelope(
339 result: &mut JsonValue,
340 mode: McpProtocolMode,
341 cache: Option<&McpCacheHint>,
342) {
343 if !mode.is_modern() {
344 return;
345 }
346 let Some(object) = result.as_object_mut() else {
347 return;
348 };
349 object
350 .entry("resultType")
351 .or_insert_with(|| JsonValue::String(RESULT_TYPE_COMPLETE.to_string()));
352 if let Some(hint) = cache {
353 if let Some(ttl) = hint.ttl_ms {
354 object.insert("ttlMs".to_string(), json!(ttl));
355 }
356 if let Some(scope) = hint.scope {
357 object.insert("cacheScope".to_string(), JsonValue::String(scope.into()));
358 }
359 }
360}
361
362#[derive(Clone, Copy, Debug, PartialEq, Eq)]
366pub struct McpCacheHint {
367 pub ttl_ms: Option<u64>,
368 pub scope: Option<&'static str>,
369}
370
371impl McpCacheHint {
372 pub const fn list_default() -> Self {
373 Self {
374 ttl_ms: Some(DEFAULT_LIST_CACHE_TTL_MS),
375 scope: Some(DEFAULT_LIST_CACHE_SCOPE),
376 }
377 }
378
379 pub const fn read_default() -> Self {
380 Self {
381 ttl_ms: Some(DEFAULT_READ_CACHE_TTL_MS),
382 scope: Some(DEFAULT_READ_CACHE_SCOPE),
383 }
384 }
385
386 pub const fn none() -> Self {
387 Self {
388 ttl_ms: None,
389 scope: None,
390 }
391 }
392
393 pub fn from_result(result: &JsonValue) -> Option<Self> {
397 let ttl_ms = result.get("ttlMs").and_then(JsonValue::as_u64);
398 let scope = result
399 .get("cacheScope")
400 .and_then(JsonValue::as_str)
401 .and_then(Self::canonical_scope);
402 if ttl_ms.is_none() && scope.is_none() {
403 return None;
404 }
405 Some(Self { ttl_ms, scope })
406 }
407
408 fn canonical_scope(value: &str) -> Option<&'static str> {
409 match value {
410 "public" => Some("public"),
411 "private" => Some("private"),
412 _ => None,
413 }
414 }
415
416 pub fn to_json_object(&self) -> serde_json::Map<String, JsonValue> {
417 let mut entry = serde_json::Map::new();
418 if let Some(ttl_ms) = self.ttl_ms {
419 entry.insert("ttlMs".to_string(), json!(ttl_ms));
420 }
421 if let Some(scope) = self.scope {
422 entry.insert("cacheScope".to_string(), JsonValue::String(scope.into()));
423 }
424 entry
425 }
426}
427
428pub fn cache_hints_to_json<'a, I>(hints: I) -> JsonValue
431where
432 I: IntoIterator<Item = (&'a String, &'a McpCacheHint)>,
433{
434 let mut object = serde_json::Map::new();
435 for (method, hint) in hints {
436 object.insert(method.clone(), JsonValue::Object(hint.to_json_object()));
437 }
438 JsonValue::Object(object)
439}
440
441pub fn server_discover_result(
446 capabilities: JsonValue,
447 server_info: JsonValue,
448 instructions: Option<&str>,
449) -> JsonValue {
450 let mut result = json!({
451 "resultType": RESULT_TYPE_COMPLETE,
452 "protocolVersion": DRAFT_PROTOCOL_VERSION,
453 "supportedVersions": supported_protocol_versions(),
454 "capabilities": capabilities,
455 "serverInfo": server_info,
456 });
457 if let Some(instructions) = instructions {
458 result["instructions"] = JsonValue::String(instructions.to_string());
459 }
460 result
461}
462
463pub fn unsupported_client_bound_method_response(
464 id: impl Into<JsonValue>,
465 method: &str,
466) -> Option<JsonValue> {
467 let (feature, reason) = match method {
468 METHOD_SAMPLING_CREATE_MESSAGE => (
469 "sampling",
470 "MCP sampling requests are server-to-client requests. Harn does not accept client-initiated sampling on MCP server endpoints.",
471 ),
472 METHOD_ELICITATION_CREATE => (
473 "elicitation",
474 "MCP elicitation requests are server-to-client requests. Harn MCP servers initiate elicitation from tool, resource, or prompt handlers instead of accepting it from clients.",
475 ),
476 _ => return None,
477 };
478 Some(crate::jsonrpc::error_response_with_data(
479 id,
480 -32601,
481 &format!("Unsupported MCP client-bound method: {method}"),
482 json!({
483 "type": "mcp.unsupportedFeature",
484 "protocolVersion": PROTOCOL_VERSION,
485 "method": method,
486 "feature": feature,
487 "role": "client",
488 "status": "unsupported",
489 "reason": reason,
490 }),
491 ))
492}
493
494pub fn unsupported_task_augmentation_response(id: impl Into<JsonValue>, method: &str) -> JsonValue {
495 task_augmentation_error_response(
496 id,
497 method,
498 -32602,
499 "MCP task-augmented execution is not supported",
500 "Harn MCP tools execute inline and do not advertise taskSupport.",
501 )
502}
503
504pub fn task_augmentation_error_response(
505 id: impl Into<JsonValue>,
506 method: &str,
507 code: i64,
508 message: &str,
509 reason: &str,
510) -> JsonValue {
511 crate::jsonrpc::error_response_with_data(
512 id,
513 code,
514 message,
515 json!({
516 "type": "mcp.unsupportedFeature",
517 "protocolVersion": PROTOCOL_VERSION,
518 "method": method,
519 "feature": "tasks",
520 "status": "unsupported",
521 "reason": reason,
522 }),
523 )
524}
525
526pub fn requests_task_augmentation(params: &JsonValue) -> bool {
527 params.get("task").is_some()
528}
529
530pub fn tasks_capability() -> JsonValue {
531 json!({
532 "list": {},
533 "cancel": {},
534 "requests": {
535 "tools": {
536 "call": {}
537 }
538 }
539 })
540}
541
542pub fn completions_capability() -> JsonValue {
543 json!({})
544}
545
546#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
552pub enum McpLogLevel {
553 Debug,
554 Info,
555 Notice,
556 Warning,
557 Error,
558 Critical,
559 Alert,
560 Emergency,
561}
562
563impl McpLogLevel {
564 pub fn as_str(self) -> &'static str {
565 match self {
566 Self::Debug => "debug",
567 Self::Info => "info",
568 Self::Notice => "notice",
569 Self::Warning => "warning",
570 Self::Error => "error",
571 Self::Critical => "critical",
572 Self::Alert => "alert",
573 Self::Emergency => "emergency",
574 }
575 }
576
577 pub fn from_str_ci(value: &str) -> Option<Self> {
578 match value.trim().to_ascii_lowercase().as_str() {
579 "debug" => Some(Self::Debug),
580 "info" => Some(Self::Info),
581 "notice" => Some(Self::Notice),
582 "warning" | "warn" => Some(Self::Warning),
583 "error" | "err" => Some(Self::Error),
584 "critical" | "crit" => Some(Self::Critical),
585 "alert" => Some(Self::Alert),
586 "emergency" | "emerg" => Some(Self::Emergency),
587 _ => None,
588 }
589 }
590}
591
592pub fn logging_capability() -> JsonValue {
593 json!({})
594}
595
596pub fn logging_message_notification(
599 level: McpLogLevel,
600 logger: Option<&str>,
601 data: JsonValue,
602) -> JsonValue {
603 let mut params = serde_json::Map::new();
604 params.insert(
605 "level".to_string(),
606 JsonValue::String(level.as_str().into()),
607 );
608 if let Some(logger) = logger {
609 params.insert("logger".to_string(), JsonValue::String(logger.to_string()));
610 }
611 params.insert("data".to_string(), data);
612 json!({
613 "jsonrpc": "2.0",
614 "method": METHOD_LOGGING_MESSAGE_NOTIFICATION,
615 "params": JsonValue::Object(params),
616 })
617}
618
619pub fn completion_result(
620 id: impl Into<JsonValue>,
621 candidates: Vec<String>,
622 value: &str,
623) -> JsonValue {
624 crate::jsonrpc::response(
625 id,
626 json!({ "completion": completion_payload(candidates, value) }),
627 )
628}
629
630pub fn completion_payload(candidates: Vec<String>, value: &str) -> JsonValue {
631 let needle = value.to_ascii_lowercase();
632 let mut seen = std::collections::BTreeSet::new();
633 let mut ranked = candidates
634 .into_iter()
635 .filter_map(|candidate| {
636 let candidate = candidate.trim().to_string();
637 if candidate.is_empty() || !seen.insert(candidate.clone()) {
638 return None;
639 }
640 let haystack = candidate.to_ascii_lowercase();
641 if !needle.is_empty() && !haystack.contains(&needle) {
642 return None;
643 }
644 let rank = i32::from(!(needle.is_empty() || haystack.starts_with(&needle)));
645 Some((rank, haystack, candidate))
646 })
647 .collect::<Vec<_>>();
648 ranked.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
649
650 let total = ranked.len();
651 let values = ranked
652 .into_iter()
653 .take(MCP_COMPLETION_MAX_VALUES)
654 .map(|(_, _, candidate)| candidate)
655 .collect::<Vec<_>>();
656 json!({
657 "values": values,
658 "total": total,
659 "hasMore": total > MCP_COMPLETION_MAX_VALUES,
660 })
661}
662
663pub fn tool_execution(task_support: McpToolTaskSupport) -> JsonValue {
664 json!({
665 "taskSupport": task_support.as_str(),
666 })
667}
668
669pub fn related_task_meta(task_id: &str) -> JsonValue {
670 json!({
671 RELATED_TASK_META_KEY: {
672 "taskId": task_id,
673 }
674 })
675}
676
677pub fn mcp_list_page_size() -> usize {
678 mcp_list_page_size_from_env(std::env::var(MCP_LIST_PAGE_SIZE_ENV).ok().as_deref())
679}
680
681fn mcp_list_page_size_from_env(raw: Option<&str>) -> usize {
682 raw.and_then(|value| value.parse::<usize>().ok())
683 .filter(|size| *size > 0)
684 .unwrap_or(DEFAULT_MCP_LIST_PAGE_SIZE)
685}
686
687pub fn encode_mcp_list_cursor(offset: usize) -> String {
688 use base64::Engine;
689 base64::engine::general_purpose::STANDARD.encode(offset.to_string().as_bytes())
690}
691
692pub fn mcp_list_page(
693 params: &JsonValue,
694 total_len: usize,
695 method: &str,
696) -> Result<McpListPage, String> {
697 let offset = parse_mcp_list_cursor(params, method)?;
698 let page_size = mcp_list_page_size();
699 let start = offset.min(total_len);
700 let end = start.saturating_add(page_size).min(total_len);
701 let next_cursor = (end < total_len).then(|| encode_mcp_list_cursor(end));
702 Ok(McpListPage {
703 start,
704 end,
705 next_cursor,
706 })
707}
708
709fn parse_mcp_list_cursor(params: &JsonValue, method: &str) -> Result<usize, String> {
710 let Some(cursor) = params.get("cursor") else {
711 return Ok(0);
712 };
713 let Some(cursor) = cursor.as_str() else {
714 return Err(format!("invalid {method} cursor"));
715 };
716 use base64::Engine;
717 let bytes = base64::engine::general_purpose::STANDARD
718 .decode(cursor)
719 .map_err(|_| format!("invalid {method} cursor"))?;
720 let decoded = String::from_utf8(bytes).map_err(|_| format!("invalid {method} cursor"))?;
721 decoded
722 .parse::<usize>()
723 .map_err(|_| format!("invalid {method} cursor"))
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
731 fn completion_payload_dedupes_and_ranks_prefix_matches() {
732 let response = completion_result(
733 json!(1),
734 vec![
735 "typescript".to_string(),
736 "rust".to_string(),
737 "ruby".to_string(),
738 "rust".to_string(),
739 ],
740 "ru",
741 );
742 assert_eq!(
743 response["result"]["completion"]["values"],
744 json!(["ruby", "rust"])
745 );
746 assert_eq!(response["result"]["completion"]["total"], json!(2));
747 assert_eq!(response["result"]["completion"]["hasMore"], json!(false));
748 }
749
750 #[test]
751 fn task_augmentation_error_is_json_rpc_shaped() {
752 let response = unsupported_task_augmentation_response(json!("call-1"), "tools/call");
753 assert_eq!(response["jsonrpc"], json!("2.0"));
754 assert_eq!(response["id"], json!("call-1"));
755 assert_eq!(response["error"]["code"], json!(-32602));
756 assert_eq!(response["error"]["data"]["feature"], json!("tasks"));
757 }
758
759 #[test]
760 fn task_protocol_shapes_match_latest_spec_names() {
761 assert_eq!(McpTaskStatus::Working.as_str(), "working");
762 assert_eq!(McpTaskStatus::InputRequired.as_str(), "input_required");
763 assert!(McpTaskStatus::Completed.is_terminal());
764 assert_eq!(tasks_capability()["requests"]["tools"]["call"], json!({}));
765 assert_eq!(
766 tool_execution(McpToolTaskSupport::Optional)["taskSupport"],
767 json!("optional")
768 );
769 assert_eq!(
770 related_task_meta("task-1")[RELATED_TASK_META_KEY]["taskId"],
771 json!("task-1")
772 );
773 }
774
775 #[test]
776 fn mcp_list_page_uses_default_size_and_next_cursor() {
777 let page = mcp_list_page(&json!({}), 105, "tools/list").unwrap();
778 assert_eq!(page.start, 0);
779 assert_eq!(page.end, DEFAULT_MCP_LIST_PAGE_SIZE);
780 assert_eq!(
781 page.next_cursor,
782 Some(encode_mcp_list_cursor(DEFAULT_MCP_LIST_PAGE_SIZE))
783 );
784
785 let next = mcp_list_page(
786 &json!({"cursor": page.next_cursor.unwrap()}),
787 105,
788 "tools/list",
789 )
790 .unwrap();
791 assert_eq!(next.start, DEFAULT_MCP_LIST_PAGE_SIZE);
792 assert_eq!(next.end, 105);
793 assert_eq!(next.next_cursor, None);
794 }
795
796 #[test]
797 fn log_levels_round_trip_through_string_form() {
798 for level in [
799 McpLogLevel::Debug,
800 McpLogLevel::Info,
801 McpLogLevel::Notice,
802 McpLogLevel::Warning,
803 McpLogLevel::Error,
804 McpLogLevel::Critical,
805 McpLogLevel::Alert,
806 McpLogLevel::Emergency,
807 ] {
808 assert_eq!(McpLogLevel::from_str_ci(level.as_str()), Some(level));
809 }
810 assert_eq!(McpLogLevel::from_str_ci("WARN"), Some(McpLogLevel::Warning));
811 assert_eq!(
812 McpLogLevel::from_str_ci("Crit"),
813 Some(McpLogLevel::Critical)
814 );
815 assert_eq!(McpLogLevel::from_str_ci(""), None);
816 assert_eq!(McpLogLevel::from_str_ci("trace"), None);
817 }
818
819 #[test]
820 fn log_levels_order_from_debug_to_emergency() {
821 assert!(McpLogLevel::Debug < McpLogLevel::Info);
822 assert!(McpLogLevel::Warning < McpLogLevel::Error);
823 assert!(McpLogLevel::Error < McpLogLevel::Emergency);
824 }
825
826 #[test]
827 fn logging_message_notification_matches_spec_envelope() {
828 let notification = logging_message_notification(
829 McpLogLevel::Warning,
830 Some("audit.signature_verify"),
831 json!({"event_id": 1, "kind": "verify_failed"}),
832 );
833 assert_eq!(notification["jsonrpc"], json!("2.0"));
834 assert_eq!(
835 notification["method"],
836 json!(METHOD_LOGGING_MESSAGE_NOTIFICATION)
837 );
838 assert_eq!(notification["params"]["level"], json!("warning"));
839 assert_eq!(
840 notification["params"]["logger"],
841 json!("audit.signature_verify")
842 );
843 assert_eq!(
844 notification["params"]["data"]["kind"],
845 json!("verify_failed")
846 );
847
848 let no_logger =
849 logging_message_notification(McpLogLevel::Info, None, json!({"hello": "world"}));
850 assert!(no_logger["params"].get("logger").is_none());
851 }
852
853 #[test]
854 fn mcp_list_page_size_parses_positive_env_override() {
855 assert_eq!(mcp_list_page_size_from_env(Some("2")), 2);
856 assert_eq!(
857 mcp_list_page_size_from_env(Some("0")),
858 DEFAULT_MCP_LIST_PAGE_SIZE
859 );
860 assert_eq!(
861 mcp_list_page_size_from_env(Some("nope")),
862 DEFAULT_MCP_LIST_PAGE_SIZE
863 );
864 assert_eq!(
865 mcp_list_page_size_from_env(None),
866 DEFAULT_MCP_LIST_PAGE_SIZE
867 );
868 }
869
870 #[test]
871 fn mcp_list_page_rejects_malformed_cursor() {
872 let err = mcp_list_page(&json!({"cursor": "not-base64"}), 5, "resources/list")
873 .expect_err("malformed cursor should fail");
874 assert_eq!(err, "invalid resources/list cursor");
875 }
876
877 #[test]
878 fn rc_metadata_round_trips_through_meta_block() {
879 let params = json!({
880 "_meta": {
881 RC_META_KEY_PROTOCOL_VERSION: DRAFT_PROTOCOL_VERSION,
882 RC_META_KEY_CLIENT_INFO: {"name": "harn", "version": "x"},
883 RC_META_KEY_CLIENT_CAPABILITIES: {"roots": {}},
884 }
885 });
886 let meta = parse_request_metadata(¶ms);
887 assert_eq!(
888 meta.protocol_version.as_deref(),
889 Some(DRAFT_PROTOCOL_VERSION)
890 );
891 assert_eq!(
892 meta.client_info,
893 Some(json!({"name": "harn", "version": "x"}))
894 );
895 assert_eq!(meta.client_capabilities, Some(json!({"roots": {}})));
896 assert_eq!(meta.mode(), McpProtocolMode::Modern);
897 }
898
899 #[test]
900 fn rc_metadata_defaults_to_legacy_when_absent() {
901 let meta = parse_request_metadata(&json!({}));
902 assert_eq!(meta, McpRequestMetadata::default());
903 assert_eq!(meta.mode(), McpProtocolMode::Legacy);
904 }
905
906 #[test]
907 fn enforce_request_protocol_version_rejects_unknown_version() {
908 let meta = McpRequestMetadata {
909 protocol_version: Some("2099-01-01".to_string()),
910 ..Default::default()
911 };
912 let id = json!(7);
913 let err =
914 enforce_request_protocol_version(&id, &meta).expect_err("unknown version should error");
915 assert_eq!(err["id"], id);
916 assert_eq!(
917 err["error"]["code"],
918 json!(UNSUPPORTED_PROTOCOL_VERSION_CODE)
919 );
920 assert_eq!(err["error"]["data"]["requested"], json!("2099-01-01"));
921 let supported = err["error"]["data"]["supported"].as_array().unwrap();
922 assert!(supported.iter().any(|v| v == DRAFT_PROTOCOL_VERSION));
923 assert!(supported.iter().any(|v| v == PROTOCOL_VERSION));
924 assert!(supported
925 .iter()
926 .any(|v| v == LEGACY_2025_06_18_PROTOCOL_VERSION));
927 }
928
929 #[test]
930 fn enforce_request_protocol_version_returns_modern_mode_for_draft() {
931 let meta = McpRequestMetadata {
932 protocol_version: Some(DRAFT_PROTOCOL_VERSION.to_string()),
933 ..Default::default()
934 };
935 let mode = enforce_request_protocol_version(&json!(1), &meta).unwrap();
936 assert_eq!(mode, Some(McpProtocolMode::Modern));
937 }
938
939 #[test]
940 fn enforce_request_protocol_version_accepts_2025_06_18_as_legacy() {
941 let meta = McpRequestMetadata {
942 protocol_version: Some(LEGACY_2025_06_18_PROTOCOL_VERSION.to_string()),
943 ..Default::default()
944 };
945 let mode = enforce_request_protocol_version(&json!(1), &meta).unwrap();
946 assert_eq!(mode, Some(McpProtocolMode::Legacy));
947 }
948
949 #[test]
950 fn negotiate_rc_http_headers_detects_draft_protocol_header() {
951 let headers = std::collections::HashMap::from([(
952 RC_HEADER_PROTOCOL_VERSION.to_string(),
953 DRAFT_PROTOCOL_VERSION.to_string(),
954 )]);
955 let outcome = negotiate_rc_http_request(
956 |key| headers.get(key).map(String::as_str),
957 Some("tools/list"),
958 None,
959 &json!(1),
960 )
961 .unwrap();
962 assert_eq!(outcome.mode, McpProtocolMode::Modern);
963 assert_eq!(
964 outcome.protocol_version.as_deref(),
965 Some(DRAFT_PROTOCOL_VERSION)
966 );
967 }
968
969 #[test]
970 fn negotiate_rc_http_headers_rejects_method_body_mismatch() {
971 let headers = std::collections::HashMap::from([(
972 RC_HEADER_METHOD.to_string(),
973 "tools/list".to_string(),
974 )]);
975 let err = negotiate_rc_http_request(
976 |key| headers.get(key).map(String::as_str),
977 Some("tools/call"),
978 None,
979 &json!(2),
980 )
981 .expect_err("header/body mismatch must error");
982 assert_eq!(err["error"]["code"], json!(-32600));
983 assert_eq!(err["error"]["data"]["headerValue"], json!("tools/list"));
984 assert_eq!(err["error"]["data"]["bodyMethod"], json!("tools/call"));
985 }
986
987 #[test]
988 fn negotiate_rc_http_headers_rejects_name_body_mismatch() {
989 let headers = std::collections::HashMap::from([
990 (RC_HEADER_METHOD.to_string(), "tools/call".to_string()),
991 (RC_HEADER_NAME.to_string(), "wrong".to_string()),
992 ]);
993 let err = negotiate_rc_http_request(
994 |key| headers.get(key).map(String::as_str),
995 Some("tools/call"),
996 Some("right"),
997 &json!(3),
998 )
999 .expect_err("name mismatch must error");
1000 assert_eq!(err["error"]["code"], json!(-32600));
1001 assert_eq!(err["error"]["data"]["bodyName"], json!("right"));
1002 }
1003
1004 #[test]
1005 fn rc_name_header_value_extracts_method_subject() {
1006 assert_eq!(
1007 rc_name_header_value("tools/call", &json!({"name": "demo"})),
1008 Some("demo".to_string())
1009 );
1010 assert_eq!(
1011 rc_name_header_value("prompts/get", &json!({"name": "p"})),
1012 Some("p".to_string())
1013 );
1014 assert_eq!(
1015 rc_name_header_value("resources/read", &json!({"uri": "harn://x"})),
1016 Some("harn://x".to_string())
1017 );
1018 assert_eq!(rc_name_header_value("tools/list", &json!({})), None);
1019 }
1020
1021 #[test]
1022 fn apply_rc_result_envelope_adds_result_type_and_cache_only_for_modern() {
1023 let mut modern = json!({"tools": []});
1024 apply_rc_result_envelope(
1025 &mut modern,
1026 McpProtocolMode::Modern,
1027 Some(&McpCacheHint::list_default()),
1028 );
1029 assert_eq!(modern["resultType"], json!(RESULT_TYPE_COMPLETE));
1030 assert_eq!(modern["ttlMs"], json!(DEFAULT_LIST_CACHE_TTL_MS));
1031 assert_eq!(modern["cacheScope"], json!(DEFAULT_LIST_CACHE_SCOPE));
1032
1033 let mut legacy = json!({"tools": []});
1034 apply_rc_result_envelope(
1035 &mut legacy,
1036 McpProtocolMode::Legacy,
1037 Some(&McpCacheHint::list_default()),
1038 );
1039 assert!(legacy.get("resultType").is_none());
1040 assert!(legacy.get("ttlMs").is_none());
1041 assert!(legacy.get("cacheScope").is_none());
1042 }
1043
1044 #[test]
1045 fn apply_rc_result_envelope_preserves_caller_provided_result_type() {
1046 let mut result = json!({"resultType": RESULT_TYPE_INPUT_REQUIRED});
1047 apply_rc_result_envelope(&mut result, McpProtocolMode::Modern, None);
1048 assert_eq!(result["resultType"], json!(RESULT_TYPE_INPUT_REQUIRED));
1049 }
1050
1051 #[test]
1052 fn server_discover_result_advertises_both_versions() {
1053 let discover = server_discover_result(
1054 json!({"tools": {}}),
1055 json!({"name": "harn", "version": "x"}),
1056 Some("hello"),
1057 );
1058 assert_eq!(discover["resultType"], json!(RESULT_TYPE_COMPLETE));
1059 assert_eq!(discover["protocolVersion"], json!(DRAFT_PROTOCOL_VERSION));
1060 let supported = discover["supportedVersions"].as_array().unwrap();
1061 assert!(supported.iter().any(|v| v == DRAFT_PROTOCOL_VERSION));
1062 assert!(supported.iter().any(|v| v == PROTOCOL_VERSION));
1063 assert!(supported
1064 .iter()
1065 .any(|v| v == LEGACY_2025_06_18_PROTOCOL_VERSION));
1066 assert_eq!(discover["instructions"], json!("hello"));
1067 }
1068}