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