use serde_json::{json, Value as JsonValue};
pub const PROTOCOL_VERSION: &str = "2025-11-25";
pub const METHOD_TASKS_GET: &str = "tasks/get";
pub const METHOD_TASKS_RESULT: &str = "tasks/result";
pub const METHOD_TASKS_LIST: &str = "tasks/list";
pub const METHOD_TASKS_CANCEL: &str = "tasks/cancel";
pub const METHOD_COMPLETION_COMPLETE: &str = "completion/complete";
pub const METHOD_SAMPLING_CREATE_MESSAGE: &str = "sampling/createMessage";
pub const METHOD_ELICITATION_CREATE: &str = "elicitation/create";
pub const METHOD_TASK_STATUS_NOTIFICATION: &str = "notifications/tasks/status";
pub const METHOD_ROOTS_LIST: &str = "roots/list";
pub const METHOD_ROOTS_LIST_CHANGED_NOTIFICATION: &str = "notifications/roots/list_changed";
pub const METHOD_LOGGING_SET_LEVEL: &str = "logging/setLevel";
pub const METHOD_LOGGING_MESSAGE_NOTIFICATION: &str = "notifications/message";
pub const RELATED_TASK_META_KEY: &str = "io.modelcontextprotocol/related-task";
pub const DEFAULT_TASK_POLL_INTERVAL_MS: u64 = 250;
pub const DEFAULT_MCP_LIST_PAGE_SIZE: usize = 100;
pub const MCP_LIST_PAGE_SIZE_ENV: &str = "HARN_MCP_LIST_PAGE_SIZE";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct McpListPage {
pub start: usize,
pub end: usize,
pub next_cursor: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct UnsupportedMcpMethod {
pub method: &'static str,
pub feature: &'static str,
pub role: &'static str,
pub reason: &'static str,
}
pub const UNSUPPORTED_LATEST_SPEC_METHODS: &[UnsupportedMcpMethod] = &[
];
pub const MCP_COMPLETION_MAX_VALUES: usize = 100;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum McpTaskStatus {
Working,
InputRequired,
Completed,
Failed,
Cancelled,
}
impl McpTaskStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Working => "working",
Self::InputRequired => "input_required",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum McpToolTaskSupport {
Required,
Optional,
Forbidden,
}
impl McpToolTaskSupport {
pub fn as_str(self) -> &'static str {
match self {
Self::Required => "required",
Self::Optional => "optional",
Self::Forbidden => "forbidden",
}
}
}
pub fn unsupported_latest_spec_method(method: &str) -> Option<&'static UnsupportedMcpMethod> {
UNSUPPORTED_LATEST_SPEC_METHODS
.iter()
.find(|entry| entry.method == method)
}
pub fn unsupported_latest_spec_method_response(
id: impl Into<JsonValue>,
method: &str,
) -> Option<JsonValue> {
unsupported_latest_spec_method(method).map(|entry| {
crate::jsonrpc::error_response_with_data(
id,
-32601,
&format!("Unsupported MCP method: {method}"),
unsupported_method_data(entry),
)
})
}
pub fn unsupported_client_bound_method_response(
id: impl Into<JsonValue>,
method: &str,
) -> Option<JsonValue> {
let (feature, reason) = match method {
METHOD_SAMPLING_CREATE_MESSAGE => (
"sampling",
"MCP sampling requests are server-to-client requests. Harn does not accept client-initiated sampling on MCP server endpoints.",
),
METHOD_ELICITATION_CREATE => (
"elicitation",
"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.",
),
_ => return None,
};
Some(crate::jsonrpc::error_response_with_data(
id,
-32601,
&format!("Unsupported MCP client-bound method: {method}"),
json!({
"type": "mcp.unsupportedFeature",
"protocolVersion": PROTOCOL_VERSION,
"method": method,
"feature": feature,
"role": "client",
"status": "unsupported",
"reason": reason,
}),
))
}
pub fn unsupported_task_augmentation_response(id: impl Into<JsonValue>, method: &str) -> JsonValue {
task_augmentation_error_response(
id,
method,
-32602,
"MCP task-augmented execution is not supported",
"Harn MCP tools execute inline and do not advertise taskSupport.",
)
}
pub fn task_augmentation_error_response(
id: impl Into<JsonValue>,
method: &str,
code: i64,
message: &str,
reason: &str,
) -> JsonValue {
crate::jsonrpc::error_response_with_data(
id,
code,
message,
json!({
"type": "mcp.unsupportedFeature",
"protocolVersion": PROTOCOL_VERSION,
"method": method,
"feature": "tasks",
"status": "unsupported",
"reason": reason,
}),
)
}
pub fn requests_task_augmentation(params: &JsonValue) -> bool {
params.get("task").is_some()
}
pub fn tasks_capability() -> JsonValue {
json!({
"list": {},
"cancel": {},
"requests": {
"tools": {
"call": {}
}
}
})
}
pub fn completions_capability() -> JsonValue {
json!({})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum McpLogLevel {
Debug,
Info,
Notice,
Warning,
Error,
Critical,
Alert,
Emergency,
}
impl McpLogLevel {
pub fn as_str(self) -> &'static str {
match self {
Self::Debug => "debug",
Self::Info => "info",
Self::Notice => "notice",
Self::Warning => "warning",
Self::Error => "error",
Self::Critical => "critical",
Self::Alert => "alert",
Self::Emergency => "emergency",
}
}
pub fn from_str_ci(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"debug" => Some(Self::Debug),
"info" => Some(Self::Info),
"notice" => Some(Self::Notice),
"warning" | "warn" => Some(Self::Warning),
"error" | "err" => Some(Self::Error),
"critical" | "crit" => Some(Self::Critical),
"alert" => Some(Self::Alert),
"emergency" | "emerg" => Some(Self::Emergency),
_ => None,
}
}
}
pub fn logging_capability() -> JsonValue {
json!({})
}
pub fn logging_message_notification(
level: McpLogLevel,
logger: Option<&str>,
data: JsonValue,
) -> JsonValue {
let mut params = serde_json::Map::new();
params.insert(
"level".to_string(),
JsonValue::String(level.as_str().into()),
);
if let Some(logger) = logger {
params.insert("logger".to_string(), JsonValue::String(logger.to_string()));
}
params.insert("data".to_string(), data);
json!({
"jsonrpc": "2.0",
"method": METHOD_LOGGING_MESSAGE_NOTIFICATION,
"params": JsonValue::Object(params),
})
}
pub fn completion_result(
id: impl Into<JsonValue>,
candidates: Vec<String>,
value: &str,
) -> JsonValue {
crate::jsonrpc::response(
id,
json!({ "completion": completion_payload(candidates, value) }),
)
}
pub fn completion_payload(candidates: Vec<String>, value: &str) -> JsonValue {
let needle = value.to_ascii_lowercase();
let mut seen = std::collections::BTreeSet::new();
let mut ranked = candidates
.into_iter()
.filter_map(|candidate| {
let candidate = candidate.trim().to_string();
if candidate.is_empty() || !seen.insert(candidate.clone()) {
return None;
}
let haystack = candidate.to_ascii_lowercase();
if !needle.is_empty() && !haystack.contains(&needle) {
return None;
}
let rank = if needle.is_empty() || haystack.starts_with(&needle) {
0
} else {
1
};
Some((rank, haystack, candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
let total = ranked.len();
let values = ranked
.into_iter()
.take(MCP_COMPLETION_MAX_VALUES)
.map(|(_, _, candidate)| candidate)
.collect::<Vec<_>>();
json!({
"values": values,
"total": total,
"hasMore": total > MCP_COMPLETION_MAX_VALUES,
})
}
pub fn tool_execution(task_support: McpToolTaskSupport) -> JsonValue {
json!({
"taskSupport": task_support.as_str(),
})
}
pub fn related_task_meta(task_id: &str) -> JsonValue {
json!({
RELATED_TASK_META_KEY: {
"taskId": task_id,
}
})
}
pub fn mcp_list_page_size() -> usize {
mcp_list_page_size_from_env(std::env::var(MCP_LIST_PAGE_SIZE_ENV).ok().as_deref())
}
fn mcp_list_page_size_from_env(raw: Option<&str>) -> usize {
raw.and_then(|value| value.parse::<usize>().ok())
.filter(|size| *size > 0)
.unwrap_or(DEFAULT_MCP_LIST_PAGE_SIZE)
}
pub fn encode_mcp_list_cursor(offset: usize) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(offset.to_string().as_bytes())
}
pub fn mcp_list_page(
params: &JsonValue,
total_len: usize,
method: &str,
) -> Result<McpListPage, String> {
let offset = parse_mcp_list_cursor(params, method)?;
let page_size = mcp_list_page_size();
let start = offset.min(total_len);
let end = start.saturating_add(page_size).min(total_len);
let next_cursor = (end < total_len).then(|| encode_mcp_list_cursor(end));
Ok(McpListPage {
start,
end,
next_cursor,
})
}
fn unsupported_method_data(entry: &UnsupportedMcpMethod) -> JsonValue {
json!({
"type": "mcp.unsupportedFeature",
"protocolVersion": PROTOCOL_VERSION,
"method": entry.method,
"feature": entry.feature,
"role": entry.role,
"status": "unsupported",
"reason": entry.reason,
})
}
fn parse_mcp_list_cursor(params: &JsonValue, method: &str) -> Result<usize, String> {
let Some(cursor) = params.get("cursor") else {
return Ok(0);
};
let Some(cursor) = cursor.as_str() else {
return Err(format!("invalid {method} cursor"));
};
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(cursor)
.map_err(|_| format!("invalid {method} cursor"))?;
let decoded = String::from_utf8(bytes).map_err(|_| format!("invalid {method} cursor"))?;
decoded
.parse::<usize>()
.map_err(|_| format!("invalid {method} cursor"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completion_complete_is_no_longer_in_the_unsupported_gap_list() {
assert!(unsupported_latest_spec_method(METHOD_COMPLETION_COMPLETE).is_none());
let response = completion_result(
json!(1),
vec![
"typescript".to_string(),
"rust".to_string(),
"ruby".to_string(),
"rust".to_string(),
],
"ru",
);
assert_eq!(
response["result"]["completion"]["values"],
json!(["ruby", "rust"])
);
assert_eq!(response["result"]["completion"]["total"], json!(2));
assert_eq!(response["result"]["completion"]["hasMore"], json!(false));
}
#[test]
fn elicitation_create_is_no_longer_in_the_unsupported_gap_list() {
assert!(unsupported_latest_spec_method("elicitation/create").is_none());
}
#[test]
fn roots_list_is_no_longer_in_the_unsupported_gap_list() {
assert!(unsupported_latest_spec_method(METHOD_ROOTS_LIST).is_none());
}
#[test]
fn resource_subscriptions_are_no_longer_in_the_unsupported_gap_list() {
assert!(unsupported_latest_spec_method("resources/subscribe").is_none());
assert!(unsupported_latest_spec_method("resources/unsubscribe").is_none());
}
#[test]
fn sampling_create_message_is_no_longer_in_the_unsupported_gap_list() {
assert!(unsupported_latest_spec_method("sampling/createMessage").is_none());
}
#[test]
fn task_augmentation_error_is_json_rpc_shaped() {
let response = unsupported_task_augmentation_response(json!("call-1"), "tools/call");
assert_eq!(response["jsonrpc"], json!("2.0"));
assert_eq!(response["id"], json!("call-1"));
assert_eq!(response["error"]["code"], json!(-32602));
assert_eq!(response["error"]["data"]["feature"], json!("tasks"));
}
#[test]
fn task_protocol_shapes_match_latest_spec_names() {
assert_eq!(McpTaskStatus::Working.as_str(), "working");
assert_eq!(McpTaskStatus::InputRequired.as_str(), "input_required");
assert!(McpTaskStatus::Completed.is_terminal());
assert_eq!(tasks_capability()["requests"]["tools"]["call"], json!({}));
assert_eq!(
tool_execution(McpToolTaskSupport::Optional)["taskSupport"],
json!("optional")
);
assert_eq!(
related_task_meta("task-1")[RELATED_TASK_META_KEY]["taskId"],
json!("task-1")
);
}
#[test]
fn mcp_list_page_uses_default_size_and_next_cursor() {
let page = mcp_list_page(&json!({}), 105, "tools/list").unwrap();
assert_eq!(page.start, 0);
assert_eq!(page.end, DEFAULT_MCP_LIST_PAGE_SIZE);
assert_eq!(
page.next_cursor,
Some(encode_mcp_list_cursor(DEFAULT_MCP_LIST_PAGE_SIZE))
);
let next = mcp_list_page(
&json!({"cursor": page.next_cursor.unwrap()}),
105,
"tools/list",
)
.unwrap();
assert_eq!(next.start, DEFAULT_MCP_LIST_PAGE_SIZE);
assert_eq!(next.end, 105);
assert_eq!(next.next_cursor, None);
}
#[test]
fn log_levels_round_trip_through_string_form() {
for level in [
McpLogLevel::Debug,
McpLogLevel::Info,
McpLogLevel::Notice,
McpLogLevel::Warning,
McpLogLevel::Error,
McpLogLevel::Critical,
McpLogLevel::Alert,
McpLogLevel::Emergency,
] {
assert_eq!(McpLogLevel::from_str_ci(level.as_str()), Some(level));
}
assert_eq!(McpLogLevel::from_str_ci("WARN"), Some(McpLogLevel::Warning));
assert_eq!(
McpLogLevel::from_str_ci("Crit"),
Some(McpLogLevel::Critical)
);
assert_eq!(McpLogLevel::from_str_ci(""), None);
assert_eq!(McpLogLevel::from_str_ci("trace"), None);
}
#[test]
fn log_levels_order_from_debug_to_emergency() {
assert!(McpLogLevel::Debug < McpLogLevel::Info);
assert!(McpLogLevel::Warning < McpLogLevel::Error);
assert!(McpLogLevel::Error < McpLogLevel::Emergency);
}
#[test]
fn logging_message_notification_matches_spec_envelope() {
let notification = logging_message_notification(
McpLogLevel::Warning,
Some("audit.signature_verify"),
json!({"event_id": 1, "kind": "verify_failed"}),
);
assert_eq!(notification["jsonrpc"], json!("2.0"));
assert_eq!(
notification["method"],
json!(METHOD_LOGGING_MESSAGE_NOTIFICATION)
);
assert_eq!(notification["params"]["level"], json!("warning"));
assert_eq!(
notification["params"]["logger"],
json!("audit.signature_verify")
);
assert_eq!(
notification["params"]["data"]["kind"],
json!("verify_failed")
);
let no_logger =
logging_message_notification(McpLogLevel::Info, None, json!({"hello": "world"}));
assert!(no_logger["params"].get("logger").is_none());
}
#[test]
fn mcp_list_page_size_parses_positive_env_override() {
assert_eq!(mcp_list_page_size_from_env(Some("2")), 2);
assert_eq!(
mcp_list_page_size_from_env(Some("0")),
DEFAULT_MCP_LIST_PAGE_SIZE
);
assert_eq!(
mcp_list_page_size_from_env(Some("nope")),
DEFAULT_MCP_LIST_PAGE_SIZE
);
assert_eq!(
mcp_list_page_size_from_env(None),
DEFAULT_MCP_LIST_PAGE_SIZE
);
}
#[test]
fn mcp_list_page_rejects_malformed_cursor() {
let err = mcp_list_page(&json!({"cursor": "not-base64"}), 5, "resources/list")
.expect_err("malformed cursor should fail");
assert_eq!(err, "invalid resources/list cursor");
}
}