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