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
5pub const PROTOCOL_VERSION: &str = "2025-11-25";
6pub const METHOD_TASKS_GET: &str = "tasks/get";
7pub const METHOD_TASKS_RESULT: &str = "tasks/result";
8pub const METHOD_TASKS_LIST: &str = "tasks/list";
9pub const METHOD_TASKS_CANCEL: &str = "tasks/cancel";
10pub const METHOD_COMPLETION_COMPLETE: &str = "completion/complete";
11pub const METHOD_SAMPLING_CREATE_MESSAGE: &str = "sampling/createMessage";
12pub const METHOD_ELICITATION_CREATE: &str = "elicitation/create";
13pub const METHOD_TASK_STATUS_NOTIFICATION: &str = "notifications/tasks/status";
14pub const METHOD_ROOTS_LIST: &str = "roots/list";
15pub const METHOD_ROOTS_LIST_CHANGED_NOTIFICATION: &str = "notifications/roots/list_changed";
16pub const METHOD_LOGGING_SET_LEVEL: &str = "logging/setLevel";
17pub const METHOD_LOGGING_MESSAGE_NOTIFICATION: &str = "notifications/message";
18pub const RELATED_TASK_META_KEY: &str = "io.modelcontextprotocol/related-task";
19pub const DEFAULT_TASK_POLL_INTERVAL_MS: u64 = 250;
20pub const DEFAULT_MCP_LIST_PAGE_SIZE: usize = 100;
21pub const MCP_LIST_PAGE_SIZE_ENV: &str = "HARN_MCP_LIST_PAGE_SIZE";
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct McpListPage {
25    pub start: usize,
26    pub end: usize,
27    pub next_cursor: Option<String>,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct UnsupportedMcpMethod {
32    pub method: &'static str,
33    pub feature: &'static str,
34    pub role: &'static str,
35    pub reason: &'static str,
36}
37
38pub const UNSUPPORTED_LATEST_SPEC_METHODS: &[UnsupportedMcpMethod] = &[
39    // `sampling/createMessage` (client) is supported — handled in
40    // `mcp::handle_inbound_client_request` via
41    // `mcp_sampling::dispatch_inbound_sampling`, which routes the
42    // request through the host bridge's `("mcp", "sample")` operation
43    // and on to Harn's `llm_call`. Intentionally omitted from this
44    // gap list.
45    //
46    // `elicitation/create` is supported on both roles — handled in
47    // `mcp::stdio_call`/`mcp::http_call` (client) and via `mcp_elicit(...)`
48    // (server). It is intentionally omitted from this gap list.
49    //
50    // `roots/list` is supported when Harn acts as an MCP client and answers
51    // inbound server-to-client root discovery requests. It is intentionally
52    // omitted from this gap list.
53];
54
55pub const MCP_COMPLETION_MAX_VALUES: usize = 100;
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum McpTaskStatus {
59    Working,
60    InputRequired,
61    Completed,
62    Failed,
63    Cancelled,
64}
65
66impl McpTaskStatus {
67    pub fn as_str(self) -> &'static str {
68        match self {
69            Self::Working => "working",
70            Self::InputRequired => "input_required",
71            Self::Completed => "completed",
72            Self::Failed => "failed",
73            Self::Cancelled => "cancelled",
74        }
75    }
76
77    pub fn is_terminal(self) -> bool {
78        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
79    }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum McpToolTaskSupport {
84    Required,
85    Optional,
86    Forbidden,
87}
88
89impl McpToolTaskSupport {
90    pub fn as_str(self) -> &'static str {
91        match self {
92            Self::Required => "required",
93            Self::Optional => "optional",
94            Self::Forbidden => "forbidden",
95        }
96    }
97}
98
99pub fn unsupported_latest_spec_method(method: &str) -> Option<&'static UnsupportedMcpMethod> {
100    UNSUPPORTED_LATEST_SPEC_METHODS
101        .iter()
102        .find(|entry| entry.method == method)
103}
104
105pub fn unsupported_latest_spec_method_response(
106    id: impl Into<JsonValue>,
107    method: &str,
108) -> Option<JsonValue> {
109    unsupported_latest_spec_method(method).map(|entry| {
110        crate::jsonrpc::error_response_with_data(
111            id,
112            -32601,
113            &format!("Unsupported MCP method: {method}"),
114            unsupported_method_data(entry),
115        )
116    })
117}
118
119pub fn unsupported_client_bound_method_response(
120    id: impl Into<JsonValue>,
121    method: &str,
122) -> Option<JsonValue> {
123    let (feature, reason) = match method {
124        METHOD_SAMPLING_CREATE_MESSAGE => (
125            "sampling",
126            "MCP sampling requests are server-to-client requests. Harn does not accept client-initiated sampling on MCP server endpoints.",
127        ),
128        METHOD_ELICITATION_CREATE => (
129            "elicitation",
130            "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.",
131        ),
132        _ => return None,
133    };
134    Some(crate::jsonrpc::error_response_with_data(
135        id,
136        -32601,
137        &format!("Unsupported MCP client-bound method: {method}"),
138        json!({
139            "type": "mcp.unsupportedFeature",
140            "protocolVersion": PROTOCOL_VERSION,
141            "method": method,
142            "feature": feature,
143            "role": "client",
144            "status": "unsupported",
145            "reason": reason,
146        }),
147    ))
148}
149
150pub fn unsupported_task_augmentation_response(id: impl Into<JsonValue>, method: &str) -> JsonValue {
151    task_augmentation_error_response(
152        id,
153        method,
154        -32602,
155        "MCP task-augmented execution is not supported",
156        "Harn MCP tools execute inline and do not advertise taskSupport.",
157    )
158}
159
160pub fn task_augmentation_error_response(
161    id: impl Into<JsonValue>,
162    method: &str,
163    code: i64,
164    message: &str,
165    reason: &str,
166) -> JsonValue {
167    crate::jsonrpc::error_response_with_data(
168        id,
169        code,
170        message,
171        json!({
172            "type": "mcp.unsupportedFeature",
173            "protocolVersion": PROTOCOL_VERSION,
174            "method": method,
175            "feature": "tasks",
176            "status": "unsupported",
177            "reason": reason,
178        }),
179    )
180}
181
182pub fn requests_task_augmentation(params: &JsonValue) -> bool {
183    params.get("task").is_some()
184}
185
186pub fn tasks_capability() -> JsonValue {
187    json!({
188        "list": {},
189        "cancel": {},
190        "requests": {
191            "tools": {
192                "call": {}
193            }
194        }
195    })
196}
197
198pub fn completions_capability() -> JsonValue {
199    json!({})
200}
201
202/// Severity levels defined by the MCP logging utility (RFC 5424 ordering).
203///
204/// Variants are ordered from most verbose (`Debug`) to most severe
205/// (`Emergency`); `Ord` follows that ordering so that
206/// `level >= subscribed_level` filters notifications by severity.
207#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
208pub enum McpLogLevel {
209    Debug,
210    Info,
211    Notice,
212    Warning,
213    Error,
214    Critical,
215    Alert,
216    Emergency,
217}
218
219impl McpLogLevel {
220    pub fn as_str(self) -> &'static str {
221        match self {
222            Self::Debug => "debug",
223            Self::Info => "info",
224            Self::Notice => "notice",
225            Self::Warning => "warning",
226            Self::Error => "error",
227            Self::Critical => "critical",
228            Self::Alert => "alert",
229            Self::Emergency => "emergency",
230        }
231    }
232
233    pub fn from_str_ci(value: &str) -> Option<Self> {
234        match value.trim().to_ascii_lowercase().as_str() {
235            "debug" => Some(Self::Debug),
236            "info" => Some(Self::Info),
237            "notice" => Some(Self::Notice),
238            "warning" | "warn" => Some(Self::Warning),
239            "error" | "err" => Some(Self::Error),
240            "critical" | "crit" => Some(Self::Critical),
241            "alert" => Some(Self::Alert),
242            "emergency" | "emerg" => Some(Self::Emergency),
243            _ => None,
244        }
245    }
246}
247
248pub fn logging_capability() -> JsonValue {
249    json!({})
250}
251
252/// Encode a `notifications/message` envelope per
253/// <https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging>.
254pub fn logging_message_notification(
255    level: McpLogLevel,
256    logger: Option<&str>,
257    data: JsonValue,
258) -> JsonValue {
259    let mut params = serde_json::Map::new();
260    params.insert(
261        "level".to_string(),
262        JsonValue::String(level.as_str().into()),
263    );
264    if let Some(logger) = logger {
265        params.insert("logger".to_string(), JsonValue::String(logger.to_string()));
266    }
267    params.insert("data".to_string(), data);
268    json!({
269        "jsonrpc": "2.0",
270        "method": METHOD_LOGGING_MESSAGE_NOTIFICATION,
271        "params": JsonValue::Object(params),
272    })
273}
274
275pub fn completion_result(
276    id: impl Into<JsonValue>,
277    candidates: Vec<String>,
278    value: &str,
279) -> JsonValue {
280    crate::jsonrpc::response(
281        id,
282        json!({ "completion": completion_payload(candidates, value) }),
283    )
284}
285
286pub fn completion_payload(candidates: Vec<String>, value: &str) -> JsonValue {
287    let needle = value.to_ascii_lowercase();
288    let mut seen = std::collections::BTreeSet::new();
289    let mut ranked = candidates
290        .into_iter()
291        .filter_map(|candidate| {
292            let candidate = candidate.trim().to_string();
293            if candidate.is_empty() || !seen.insert(candidate.clone()) {
294                return None;
295            }
296            let haystack = candidate.to_ascii_lowercase();
297            if !needle.is_empty() && !haystack.contains(&needle) {
298                return None;
299            }
300            let rank = if needle.is_empty() || haystack.starts_with(&needle) {
301                0
302            } else {
303                1
304            };
305            Some((rank, haystack, candidate))
306        })
307        .collect::<Vec<_>>();
308    ranked.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
309
310    let total = ranked.len();
311    let values = ranked
312        .into_iter()
313        .take(MCP_COMPLETION_MAX_VALUES)
314        .map(|(_, _, candidate)| candidate)
315        .collect::<Vec<_>>();
316    json!({
317        "values": values,
318        "total": total,
319        "hasMore": total > MCP_COMPLETION_MAX_VALUES,
320    })
321}
322
323pub fn tool_execution(task_support: McpToolTaskSupport) -> JsonValue {
324    json!({
325        "taskSupport": task_support.as_str(),
326    })
327}
328
329pub fn related_task_meta(task_id: &str) -> JsonValue {
330    json!({
331        RELATED_TASK_META_KEY: {
332            "taskId": task_id,
333        }
334    })
335}
336
337pub fn mcp_list_page_size() -> usize {
338    mcp_list_page_size_from_env(std::env::var(MCP_LIST_PAGE_SIZE_ENV).ok().as_deref())
339}
340
341fn mcp_list_page_size_from_env(raw: Option<&str>) -> usize {
342    raw.and_then(|value| value.parse::<usize>().ok())
343        .filter(|size| *size > 0)
344        .unwrap_or(DEFAULT_MCP_LIST_PAGE_SIZE)
345}
346
347pub fn encode_mcp_list_cursor(offset: usize) -> String {
348    use base64::Engine;
349    base64::engine::general_purpose::STANDARD.encode(offset.to_string().as_bytes())
350}
351
352pub fn mcp_list_page(
353    params: &JsonValue,
354    total_len: usize,
355    method: &str,
356) -> Result<McpListPage, String> {
357    let offset = parse_mcp_list_cursor(params, method)?;
358    let page_size = mcp_list_page_size();
359    let start = offset.min(total_len);
360    let end = start.saturating_add(page_size).min(total_len);
361    let next_cursor = (end < total_len).then(|| encode_mcp_list_cursor(end));
362    Ok(McpListPage {
363        start,
364        end,
365        next_cursor,
366    })
367}
368
369fn unsupported_method_data(entry: &UnsupportedMcpMethod) -> JsonValue {
370    json!({
371        "type": "mcp.unsupportedFeature",
372        "protocolVersion": PROTOCOL_VERSION,
373        "method": entry.method,
374        "feature": entry.feature,
375        "role": entry.role,
376        "status": "unsupported",
377        "reason": entry.reason,
378    })
379}
380
381fn parse_mcp_list_cursor(params: &JsonValue, method: &str) -> Result<usize, String> {
382    let Some(cursor) = params.get("cursor") else {
383        return Ok(0);
384    };
385    let Some(cursor) = cursor.as_str() else {
386        return Err(format!("invalid {method} cursor"));
387    };
388    use base64::Engine;
389    let bytes = base64::engine::general_purpose::STANDARD
390        .decode(cursor)
391        .map_err(|_| format!("invalid {method} cursor"))?;
392    let decoded = String::from_utf8(bytes).map_err(|_| format!("invalid {method} cursor"))?;
393    decoded
394        .parse::<usize>()
395        .map_err(|_| format!("invalid {method} cursor"))
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn completion_complete_is_no_longer_in_the_unsupported_gap_list() {
404        assert!(unsupported_latest_spec_method(METHOD_COMPLETION_COMPLETE).is_none());
405        let response = completion_result(
406            json!(1),
407            vec![
408                "typescript".to_string(),
409                "rust".to_string(),
410                "ruby".to_string(),
411                "rust".to_string(),
412            ],
413            "ru",
414        );
415        assert_eq!(
416            response["result"]["completion"]["values"],
417            json!(["ruby", "rust"])
418        );
419        assert_eq!(response["result"]["completion"]["total"], json!(2));
420        assert_eq!(response["result"]["completion"]["hasMore"], json!(false));
421    }
422
423    #[test]
424    fn elicitation_create_is_no_longer_in_the_unsupported_gap_list() {
425        // Removed from `UNSUPPORTED_LATEST_SPEC_METHODS` once we
426        // implemented bidirectional elicitation (issue #875). Lookup
427        // therefore returns `None` and callers route the method
428        // through the elicitation bus (server) or the host bridge
429        // (client) instead of the auto-rejection path.
430        assert!(unsupported_latest_spec_method("elicitation/create").is_none());
431    }
432
433    #[test]
434    fn roots_list_is_no_longer_in_the_unsupported_gap_list() {
435        assert!(unsupported_latest_spec_method(METHOD_ROOTS_LIST).is_none());
436    }
437
438    #[test]
439    fn resource_subscriptions_are_no_longer_in_the_unsupported_gap_list() {
440        assert!(unsupported_latest_spec_method("resources/subscribe").is_none());
441        assert!(unsupported_latest_spec_method("resources/unsubscribe").is_none());
442    }
443
444    #[test]
445    fn sampling_create_message_is_no_longer_in_the_unsupported_gap_list() {
446        // Removed from `UNSUPPORTED_LATEST_SPEC_METHODS` once we
447        // implemented inbound server-to-client sampling (issue #874).
448        // Lookup therefore returns `None` and callers route the method
449        // through `mcp_sampling::dispatch_inbound_sampling` on the
450        // client side instead of the auto-rejection path.
451        assert!(unsupported_latest_spec_method("sampling/createMessage").is_none());
452    }
453
454    #[test]
455    fn task_augmentation_error_is_json_rpc_shaped() {
456        let response = unsupported_task_augmentation_response(json!("call-1"), "tools/call");
457        assert_eq!(response["jsonrpc"], json!("2.0"));
458        assert_eq!(response["id"], json!("call-1"));
459        assert_eq!(response["error"]["code"], json!(-32602));
460        assert_eq!(response["error"]["data"]["feature"], json!("tasks"));
461    }
462
463    #[test]
464    fn task_protocol_shapes_match_latest_spec_names() {
465        assert_eq!(McpTaskStatus::Working.as_str(), "working");
466        assert_eq!(McpTaskStatus::InputRequired.as_str(), "input_required");
467        assert!(McpTaskStatus::Completed.is_terminal());
468        assert_eq!(tasks_capability()["requests"]["tools"]["call"], json!({}));
469        assert_eq!(
470            tool_execution(McpToolTaskSupport::Optional)["taskSupport"],
471            json!("optional")
472        );
473        assert_eq!(
474            related_task_meta("task-1")[RELATED_TASK_META_KEY]["taskId"],
475            json!("task-1")
476        );
477    }
478
479    #[test]
480    fn mcp_list_page_uses_default_size_and_next_cursor() {
481        let page = mcp_list_page(&json!({}), 105, "tools/list").unwrap();
482        assert_eq!(page.start, 0);
483        assert_eq!(page.end, DEFAULT_MCP_LIST_PAGE_SIZE);
484        assert_eq!(
485            page.next_cursor,
486            Some(encode_mcp_list_cursor(DEFAULT_MCP_LIST_PAGE_SIZE))
487        );
488
489        let next = mcp_list_page(
490            &json!({"cursor": page.next_cursor.unwrap()}),
491            105,
492            "tools/list",
493        )
494        .unwrap();
495        assert_eq!(next.start, DEFAULT_MCP_LIST_PAGE_SIZE);
496        assert_eq!(next.end, 105);
497        assert_eq!(next.next_cursor, None);
498    }
499
500    #[test]
501    fn log_levels_round_trip_through_string_form() {
502        for level in [
503            McpLogLevel::Debug,
504            McpLogLevel::Info,
505            McpLogLevel::Notice,
506            McpLogLevel::Warning,
507            McpLogLevel::Error,
508            McpLogLevel::Critical,
509            McpLogLevel::Alert,
510            McpLogLevel::Emergency,
511        ] {
512            assert_eq!(McpLogLevel::from_str_ci(level.as_str()), Some(level));
513        }
514        assert_eq!(McpLogLevel::from_str_ci("WARN"), Some(McpLogLevel::Warning));
515        assert_eq!(
516            McpLogLevel::from_str_ci("Crit"),
517            Some(McpLogLevel::Critical)
518        );
519        assert_eq!(McpLogLevel::from_str_ci(""), None);
520        assert_eq!(McpLogLevel::from_str_ci("trace"), None);
521    }
522
523    #[test]
524    fn log_levels_order_from_debug_to_emergency() {
525        assert!(McpLogLevel::Debug < McpLogLevel::Info);
526        assert!(McpLogLevel::Warning < McpLogLevel::Error);
527        assert!(McpLogLevel::Error < McpLogLevel::Emergency);
528    }
529
530    #[test]
531    fn logging_message_notification_matches_spec_envelope() {
532        let notification = logging_message_notification(
533            McpLogLevel::Warning,
534            Some("audit.signature_verify"),
535            json!({"event_id": 1, "kind": "verify_failed"}),
536        );
537        assert_eq!(notification["jsonrpc"], json!("2.0"));
538        assert_eq!(
539            notification["method"],
540            json!(METHOD_LOGGING_MESSAGE_NOTIFICATION)
541        );
542        assert_eq!(notification["params"]["level"], json!("warning"));
543        assert_eq!(
544            notification["params"]["logger"],
545            json!("audit.signature_verify")
546        );
547        assert_eq!(
548            notification["params"]["data"]["kind"],
549            json!("verify_failed")
550        );
551
552        let no_logger =
553            logging_message_notification(McpLogLevel::Info, None, json!({"hello": "world"}));
554        assert!(no_logger["params"].get("logger").is_none());
555    }
556
557    #[test]
558    fn mcp_list_page_size_parses_positive_env_override() {
559        assert_eq!(mcp_list_page_size_from_env(Some("2")), 2);
560        assert_eq!(
561            mcp_list_page_size_from_env(Some("0")),
562            DEFAULT_MCP_LIST_PAGE_SIZE
563        );
564        assert_eq!(
565            mcp_list_page_size_from_env(Some("nope")),
566            DEFAULT_MCP_LIST_PAGE_SIZE
567        );
568        assert_eq!(
569            mcp_list_page_size_from_env(None),
570            DEFAULT_MCP_LIST_PAGE_SIZE
571        );
572    }
573
574    #[test]
575    fn mcp_list_page_rejects_malformed_cursor() {
576        let err = mcp_list_page(&json!({"cursor": "not-base64"}), 5, "resources/list")
577            .expect_err("malformed cursor should fail");
578        assert_eq!(err, "invalid resources/list cursor");
579    }
580}