1use 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 ];
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#[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
219pub 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 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 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}