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