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_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 ];
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#[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
252pub 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 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 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}