Skip to main content

harn_vm/
mcp_protocol.rs

1//! Shared MCP protocol-version and feature-gap helpers.
2
3use serde_json::{json, Value as JsonValue};
4
5/// Stable, production MCP protocol version that Harn defaults to.
6pub const PROTOCOL_VERSION: &str = "2025-11-25";
7/// RC profile published alongside the stable version. Both clients and
8/// servers opt into this profile per request; the runtime never assumes
9/// a connection is RC-only unless the client signals it via metadata or
10/// HTTP headers.
11pub 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
28/// RC request metadata keys carried inside `params._meta` so the server
29/// can identify the client's protocol target without a sticky session.
30pub 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
34/// RC HTTP headers expected on streamable POSTs.
35pub 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";
38/// Legacy HTTP session header that pre-RC clients and servers carry. The
39/// RC modern profile is stateless and never emits this header.
40pub const MCP_SESSION_HEADER_LEGACY: &str = "mcp-session-id";
41
42/// `resultType` discriminants exposed to RC clients on every response
43/// result body. Legacy clients never see this field, which preserves the
44/// pre-RC wire format byte-for-byte.
45pub const RESULT_TYPE_COMPLETE: &str = "complete";
46pub const RESULT_TYPE_INPUT_REQUIRED: &str = "input_required";
47
48/// JSON-RPC error code reserved by the RC for unsupported protocol
49/// version negotiation. Returned with `data.supported` listing the
50/// versions the peer accepts.
51pub 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
57/// Conservative cache hints emitted with list/read results when an RC
58/// client asked for them. Both surfaces fall back to these defaults so
59/// implementations can opt out per handler if a tighter or looser bound
60/// is appropriate.
61pub 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/// Identifies which MCP profile a single request targets. The mode is
118/// negotiated per request from `_meta` or HTTP headers, never sticky.
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
120pub enum McpProtocolMode {
121    /// `2025-11-25` initialize/notify flow with session-id stickiness.
122    #[default]
123    Legacy,
124    /// RC profile: per-request metadata, optional cache hints, explicit
125    /// `resultType` discriminants, no session stickiness.
126    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
142/// Returns the protocol versions this Harn build understands when
143/// peers ask via `server/discover` or fail with `-32004`.
144pub 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/// Parsed per-request RC metadata. All fields are optional; legacy
153/// requests yield an empty struct.
154#[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
170/// Extract RC metadata from a request's `params._meta` block. Unknown
171/// keys are ignored so this stays forward-compatible with future RC
172/// drafts.
173pub 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
190/// Validate that a request's metadata targets a supported version.
191/// Returns an `Err(error_response)` payload ready to ship back to the
192/// client when the version is recognized but unsupported, leaving the
193/// caller to send the response. `Ok(None)` means legacy; `Ok(Some(meta))`
194/// means RC.
195pub 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
208/// Build the `-32004 Unsupported protocol version` JSON-RPC error the
209/// RC expects so a client can retry with a mutually-supported version.
210pub 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/// HTTP-header validation outcome. Errors carry a JSON-RPC body so the
226/// HTTP layer can ship either an HTTP 400 with the body or a 200 with
227/// the JSON-RPC error — both paths exist in the RC spec.
228#[derive(Clone, Debug)]
229pub struct RcHttpHeaderOutcome {
230    pub mode: McpProtocolMode,
231    pub protocol_version: Option<String>,
232}
233
234/// Inspect the streamable HTTP request headers for the RC signals the
235/// server has to validate. The function is pure: it returns the
236/// negotiated mode and the version pinned by the client, or a JSON-RPC
237/// error body when the headers contradict the request body or name a
238/// version the server does not support.
239///
240/// `body_method` and `body_name` are the values pulled from the JSON-RPC
241/// body so the helper can detect a header/body mismatch.
242pub 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
305/// Pulls the standard `Mcp-Name` header value for a request body. RC
306/// servers cross-check this against the header sent on the wire; RC
307/// clients use the same helper when authoring outbound requests.
308pub 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
322/// Modify a JSON-RPC result body in place to include the RC's
323/// per-result discriminants when the response targets a Modern client.
324/// Legacy clients see no change.
325pub 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/// Conservative cache hint surfaced on RC results. Servers can override
350/// the defaults per handler when they have a better answer; clients fall
351/// back to the constants in [`DEFAULT_LIST_CACHE_TTL_MS`].
352#[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    /// Extract a cache hint from an RC result body. Returns `None` when
381    /// neither `ttlMs` nor a recognized `cacheScope` is present; unknown
382    /// scopes are silently dropped so we stay forward-compatible.
383    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
415/// Build a JSON object mapping method names to their recorded RC cache
416/// hints. Empty input yields an empty object.
417pub 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
428/// Build the canonical `server/discover` result both server surfaces
429/// share. Callers supply their advertised capabilities and serverInfo;
430/// the helper handles `resultType`, `supportedVersions`, instructions,
431/// and any RC-required envelope fields.
432pub 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/// Severity levels defined by the MCP logging utility (RFC 5424 ordering).
534///
535/// Variants are ordered from most verbose (`Debug`) to most severe
536/// (`Emergency`); `Ord` follows that ordering so that
537/// `level >= subscribed_level` filters notifications by severity.
538#[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
583/// Encode a `notifications/message` envelope per
584/// <https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging>.
585pub 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(&params);
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}