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/// Prior stable MCP protocol version with the same initialize/session shape
8/// Harn uses for [`PROTOCOL_VERSION`]. Kept for clients such as Codex that
9/// still negotiate this released version.
10pub const LEGACY_2025_06_18_PROTOCOL_VERSION: &str = "2025-06-18";
11/// RC profile published alongside the stable version. Both clients and
12/// servers opt into this profile per request; the runtime never assumes
13/// a connection is RC-only unless the client signals it via metadata or
14/// HTTP headers.
15pub 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
32/// RC request metadata keys carried inside `params._meta` so the server
33/// can identify the client's protocol target without a sticky session.
34pub 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
38/// RC HTTP headers expected on streamable POSTs.
39pub 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";
42/// Legacy HTTP session header that pre-RC clients and servers carry. The
43/// RC modern profile is stateless and never emits this header.
44pub const MCP_SESSION_HEADER_LEGACY: &str = "mcp-session-id";
45
46/// `resultType` discriminants exposed to RC clients on every response
47/// result body. Legacy clients never see this field, which preserves the
48/// pre-RC wire format byte-for-byte.
49pub const RESULT_TYPE_COMPLETE: &str = "complete";
50pub const RESULT_TYPE_INPUT_REQUIRED: &str = "input_required";
51
52/// JSON-RPC error code reserved by the RC for unsupported protocol
53/// version negotiation. Returned with `data.supported` listing the
54/// versions the peer accepts.
55pub 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
61/// Conservative cache hints emitted with list/read results when an RC
62/// client asked for them. Both surfaces fall back to these defaults so
63/// implementations can opt out per handler if a tighter or looser bound
64/// is appropriate.
65pub 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/// Identifies which MCP profile a single request targets. The mode is
122/// negotiated per request from `_meta` or HTTP headers, never sticky.
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124pub enum McpProtocolMode {
125    /// `2025-11-25` initialize/notify flow with session-id stickiness.
126    #[default]
127    Legacy,
128    /// RC profile: per-request metadata, optional cache hints, explicit
129    /// `resultType` discriminants, no session stickiness.
130    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
146/// Returns the protocol versions this Harn build understands when
147/// peers ask via `server/discover` or fail with `-32004`.
148pub 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/// Parsed per-request RC metadata. All fields are optional; legacy
161/// requests yield an empty struct.
162#[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
178/// Extract RC metadata from a request's `params._meta` block. Unknown
179/// keys are ignored so this stays forward-compatible with future RC
180/// drafts.
181pub 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
198/// Validate that a request's metadata targets a supported version.
199/// Returns an `Err(error_response)` payload ready to ship back to the
200/// client when the version is recognized but unsupported, leaving the
201/// caller to send the response. `Ok(None)` means legacy; `Ok(Some(meta))`
202/// means RC.
203pub 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
216/// Build the `-32004 Unsupported protocol version` JSON-RPC error the
217/// RC expects so a client can retry with a mutually-supported version.
218pub 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/// HTTP-header validation outcome. Errors carry a JSON-RPC body so the
234/// HTTP layer can ship either an HTTP 400 with the body or a 200 with
235/// the JSON-RPC error — both paths exist in the RC spec.
236#[derive(Clone, Debug)]
237pub struct RcHttpHeaderOutcome {
238    pub mode: McpProtocolMode,
239    pub protocol_version: Option<String>,
240}
241
242/// Inspect the streamable HTTP request headers for the RC signals the
243/// server has to validate. The function is pure: it returns the
244/// negotiated mode and the version pinned by the client, or a JSON-RPC
245/// error body when the headers contradict the request body or name a
246/// version the server does not support.
247///
248/// `body_method` and `body_name` are the values pulled from the JSON-RPC
249/// body so the helper can detect a header/body mismatch.
250pub 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
313/// Pulls the standard `Mcp-Name` header value for a request body. RC
314/// servers cross-check this against the header sent on the wire; RC
315/// clients use the same helper when authoring outbound requests.
316pub 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
330/// Modify a JSON-RPC result body in place to include the RC's
331/// per-result discriminants when the response targets a Modern client.
332/// Legacy clients see no change.
333pub 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/// Conservative cache hint surfaced on RC results. Servers can override
358/// the defaults per handler when they have a better answer; clients fall
359/// back to the constants in [`DEFAULT_LIST_CACHE_TTL_MS`].
360#[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    /// Extract a cache hint from an RC result body. Returns `None` when
389    /// neither `ttlMs` nor a recognized `cacheScope` is present; unknown
390    /// scopes are silently dropped so we stay forward-compatible.
391    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
423/// Build a JSON object mapping method names to their recorded RC cache
424/// hints. Empty input yields an empty object.
425pub 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
436/// Build the canonical `server/discover` result both server surfaces
437/// share. Callers supply their advertised capabilities and serverInfo;
438/// the helper handles `resultType`, `supportedVersions`, instructions,
439/// and any RC-required envelope fields.
440pub 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/// Severity levels defined by the MCP logging utility (RFC 5424 ordering).
542///
543/// Variants are ordered from most verbose (`Debug`) to most severe
544/// (`Emergency`); `Ord` follows that ordering so that
545/// `level >= subscribed_level` filters notifications by severity.
546#[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
591/// Encode a `notifications/message` envelope per
592/// <https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging>.
593pub 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(&params);
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}