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