use serde_json::{json, Value as JsonValue};
pub const PROTOCOL_VERSION: &str = "2025-11-25";
pub const DRAFT_PROTOCOL_VERSION: &str = "DRAFT-2026-v1";
pub const METHOD_SERVER_DISCOVER: &str = "server/discover";
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 RC_META_KEY_PROTOCOL_VERSION: &str = "io.modelcontextprotocol/protocolVersion";
pub const RC_META_KEY_CLIENT_INFO: &str = "io.modelcontextprotocol/clientInfo";
pub const RC_META_KEY_CLIENT_CAPABILITIES: &str = "io.modelcontextprotocol/clientCapabilities";
pub const RC_HEADER_PROTOCOL_VERSION: &str = "mcp-protocol-version";
pub const RC_HEADER_METHOD: &str = "mcp-method";
pub const RC_HEADER_NAME: &str = "mcp-name";
pub const RESULT_TYPE_COMPLETE: &str = "complete";
pub const RESULT_TYPE_INPUT_REQUIRED: &str = "input_required";
pub const UNSUPPORTED_PROTOCOL_VERSION_CODE: i64 = -32004;
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";
pub const DEFAULT_LIST_CACHE_TTL_MS: u64 = 5_000;
pub const DEFAULT_LIST_CACHE_SCOPE: &str = "private";
pub const DEFAULT_READ_CACHE_TTL_MS: u64 = 1_000;
pub const DEFAULT_READ_CACHE_SCOPE: &str = "private";
#[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",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum McpProtocolMode {
#[default]
Legacy,
Modern,
}
impl McpProtocolMode {
pub fn is_modern(self) -> bool {
matches!(self, Self::Modern)
}
pub fn default_protocol_version(self) -> &'static str {
match self {
Self::Legacy => PROTOCOL_VERSION,
Self::Modern => DRAFT_PROTOCOL_VERSION,
}
}
}
pub fn supported_protocol_versions() -> &'static [&'static str] {
&[DRAFT_PROTOCOL_VERSION, PROTOCOL_VERSION]
}
pub fn is_supported_protocol_version(version: &str) -> bool {
supported_protocol_versions().contains(&version)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct McpRequestMetadata {
pub protocol_version: Option<String>,
pub client_info: Option<JsonValue>,
pub client_capabilities: Option<JsonValue>,
}
impl McpRequestMetadata {
pub fn mode(&self) -> McpProtocolMode {
match self.protocol_version.as_deref() {
Some(DRAFT_PROTOCOL_VERSION) => McpProtocolMode::Modern,
_ => McpProtocolMode::Legacy,
}
}
}
pub fn parse_request_metadata(params: &JsonValue) -> McpRequestMetadata {
let Some(meta) = params.get("_meta").and_then(JsonValue::as_object) else {
return McpRequestMetadata::default();
};
let protocol_version = meta
.get(RC_META_KEY_PROTOCOL_VERSION)
.and_then(JsonValue::as_str)
.map(str::to_string);
let client_info = meta.get(RC_META_KEY_CLIENT_INFO).cloned();
let client_capabilities = meta.get(RC_META_KEY_CLIENT_CAPABILITIES).cloned();
McpRequestMetadata {
protocol_version,
client_info,
client_capabilities,
}
}
pub fn enforce_request_protocol_version(
id: &JsonValue,
metadata: &McpRequestMetadata,
) -> Result<Option<McpProtocolMode>, JsonValue> {
let Some(version) = metadata.protocol_version.as_deref() else {
return Ok(None);
};
if !is_supported_protocol_version(version) {
return Err(unsupported_protocol_version_response(id.clone(), version));
}
Ok(Some(metadata.mode()))
}
pub fn unsupported_protocol_version_response(
id: impl Into<JsonValue>,
requested: &str,
) -> JsonValue {
crate::jsonrpc::error_response_with_data(
id,
UNSUPPORTED_PROTOCOL_VERSION_CODE,
"Unsupported protocol version",
json!({
"supported": supported_protocol_versions(),
"requested": requested,
}),
)
}
#[derive(Clone, Debug)]
pub struct RcHttpHeaderOutcome {
pub mode: McpProtocolMode,
pub protocol_version: Option<String>,
}
pub fn negotiate_rc_http_request<'a, F>(
headers: F,
body_method: Option<&str>,
body_name: Option<&str>,
request_id: &JsonValue,
) -> Result<RcHttpHeaderOutcome, JsonValue>
where
F: Fn(&str) -> Option<&'a str>,
{
let mut outcome = RcHttpHeaderOutcome {
mode: McpProtocolMode::Legacy,
protocol_version: None,
};
if let Some(value) = headers(RC_HEADER_PROTOCOL_VERSION) {
if !is_supported_protocol_version(value) {
return Err(unsupported_protocol_version_response(
request_id.clone(),
value,
));
}
outcome.protocol_version = Some(value.to_string());
if value == DRAFT_PROTOCOL_VERSION {
outcome.mode = McpProtocolMode::Modern;
}
}
if let Some(method_header) = headers(RC_HEADER_METHOD) {
outcome.mode = McpProtocolMode::Modern;
if let Some(body_method) = body_method {
if method_header != body_method {
return Err(crate::jsonrpc::error_response_with_data(
request_id.clone(),
-32600,
"Mcp-Method header does not match request body",
json!({
"headerValue": method_header,
"bodyMethod": body_method,
}),
));
}
}
}
if let Some(name_header) = headers(RC_HEADER_NAME) {
outcome.mode = McpProtocolMode::Modern;
let expected = body_name.unwrap_or_default();
if !expected.is_empty() && name_header != expected {
return Err(crate::jsonrpc::error_response_with_data(
request_id.clone(),
-32600,
"Mcp-Name header does not match request body",
json!({
"headerValue": name_header,
"bodyName": expected,
}),
));
}
}
Ok(outcome)
}
pub fn rc_name_header_value(method: &str, params: &JsonValue) -> Option<String> {
match method {
"tools/call" | "prompts/get" => params
.get("name")
.and_then(JsonValue::as_str)
.map(str::to_string),
"resources/read" => params
.get("uri")
.and_then(JsonValue::as_str)
.map(str::to_string),
_ => None,
}
}
pub fn apply_rc_result_envelope(
result: &mut JsonValue,
mode: McpProtocolMode,
cache: Option<&McpCacheHint>,
) {
if !mode.is_modern() {
return;
}
let Some(object) = result.as_object_mut() else {
return;
};
object
.entry("resultType")
.or_insert_with(|| JsonValue::String(RESULT_TYPE_COMPLETE.to_string()));
if let Some(hint) = cache {
if let Some(ttl) = hint.ttl_ms {
object.insert("ttlMs".to_string(), json!(ttl));
}
if let Some(scope) = hint.scope {
object.insert("cacheScope".to_string(), JsonValue::String(scope.into()));
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct McpCacheHint {
pub ttl_ms: Option<u64>,
pub scope: Option<&'static str>,
}
impl McpCacheHint {
pub const fn list_default() -> Self {
Self {
ttl_ms: Some(DEFAULT_LIST_CACHE_TTL_MS),
scope: Some(DEFAULT_LIST_CACHE_SCOPE),
}
}
pub const fn read_default() -> Self {
Self {
ttl_ms: Some(DEFAULT_READ_CACHE_TTL_MS),
scope: Some(DEFAULT_READ_CACHE_SCOPE),
}
}
pub const fn none() -> Self {
Self {
ttl_ms: None,
scope: None,
}
}
}
pub fn server_discover_result(
capabilities: JsonValue,
server_info: JsonValue,
instructions: Option<&str>,
) -> JsonValue {
let mut result = json!({
"resultType": RESULT_TYPE_COMPLETE,
"protocolVersion": DRAFT_PROTOCOL_VERSION,
"supportedVersions": supported_protocol_versions(),
"capabilities": capabilities,
"serverInfo": server_info,
});
if let Some(instructions) = instructions {
result["instructions"] = JsonValue::String(instructions.to_string());
}
result
}
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");
}
#[test]
fn rc_metadata_round_trips_through_meta_block() {
let params = json!({
"_meta": {
RC_META_KEY_PROTOCOL_VERSION: DRAFT_PROTOCOL_VERSION,
RC_META_KEY_CLIENT_INFO: {"name": "harn", "version": "x"},
RC_META_KEY_CLIENT_CAPABILITIES: {"roots": {}},
}
});
let meta = parse_request_metadata(¶ms);
assert_eq!(
meta.protocol_version.as_deref(),
Some(DRAFT_PROTOCOL_VERSION)
);
assert_eq!(
meta.client_info,
Some(json!({"name": "harn", "version": "x"}))
);
assert_eq!(meta.client_capabilities, Some(json!({"roots": {}})));
assert_eq!(meta.mode(), McpProtocolMode::Modern);
}
#[test]
fn rc_metadata_defaults_to_legacy_when_absent() {
let meta = parse_request_metadata(&json!({}));
assert_eq!(meta, McpRequestMetadata::default());
assert_eq!(meta.mode(), McpProtocolMode::Legacy);
}
#[test]
fn enforce_request_protocol_version_rejects_unknown_version() {
let meta = McpRequestMetadata {
protocol_version: Some("2099-01-01".to_string()),
..Default::default()
};
let id = json!(7);
let err =
enforce_request_protocol_version(&id, &meta).expect_err("unknown version should error");
assert_eq!(err["id"], id);
assert_eq!(
err["error"]["code"],
json!(UNSUPPORTED_PROTOCOL_VERSION_CODE)
);
assert_eq!(err["error"]["data"]["requested"], json!("2099-01-01"));
let supported = err["error"]["data"]["supported"].as_array().unwrap();
assert!(supported.iter().any(|v| v == DRAFT_PROTOCOL_VERSION));
assert!(supported.iter().any(|v| v == PROTOCOL_VERSION));
}
#[test]
fn enforce_request_protocol_version_returns_modern_mode_for_draft() {
let meta = McpRequestMetadata {
protocol_version: Some(DRAFT_PROTOCOL_VERSION.to_string()),
..Default::default()
};
let mode = enforce_request_protocol_version(&json!(1), &meta).unwrap();
assert_eq!(mode, Some(McpProtocolMode::Modern));
}
#[test]
fn negotiate_rc_http_headers_detects_draft_protocol_header() {
let headers = std::collections::HashMap::from([(
RC_HEADER_PROTOCOL_VERSION.to_string(),
DRAFT_PROTOCOL_VERSION.to_string(),
)]);
let outcome = negotiate_rc_http_request(
|key| headers.get(key).map(String::as_str),
Some("tools/list"),
None,
&json!(1),
)
.unwrap();
assert_eq!(outcome.mode, McpProtocolMode::Modern);
assert_eq!(
outcome.protocol_version.as_deref(),
Some(DRAFT_PROTOCOL_VERSION)
);
}
#[test]
fn negotiate_rc_http_headers_rejects_method_body_mismatch() {
let headers = std::collections::HashMap::from([(
RC_HEADER_METHOD.to_string(),
"tools/list".to_string(),
)]);
let err = negotiate_rc_http_request(
|key| headers.get(key).map(String::as_str),
Some("tools/call"),
None,
&json!(2),
)
.expect_err("header/body mismatch must error");
assert_eq!(err["error"]["code"], json!(-32600));
assert_eq!(err["error"]["data"]["headerValue"], json!("tools/list"));
assert_eq!(err["error"]["data"]["bodyMethod"], json!("tools/call"));
}
#[test]
fn negotiate_rc_http_headers_rejects_name_body_mismatch() {
let headers = std::collections::HashMap::from([
(RC_HEADER_METHOD.to_string(), "tools/call".to_string()),
(RC_HEADER_NAME.to_string(), "wrong".to_string()),
]);
let err = negotiate_rc_http_request(
|key| headers.get(key).map(String::as_str),
Some("tools/call"),
Some("right"),
&json!(3),
)
.expect_err("name mismatch must error");
assert_eq!(err["error"]["code"], json!(-32600));
assert_eq!(err["error"]["data"]["bodyName"], json!("right"));
}
#[test]
fn rc_name_header_value_extracts_method_subject() {
assert_eq!(
rc_name_header_value("tools/call", &json!({"name": "demo"})),
Some("demo".to_string())
);
assert_eq!(
rc_name_header_value("prompts/get", &json!({"name": "p"})),
Some("p".to_string())
);
assert_eq!(
rc_name_header_value("resources/read", &json!({"uri": "harn://x"})),
Some("harn://x".to_string())
);
assert_eq!(rc_name_header_value("tools/list", &json!({})), None);
}
#[test]
fn apply_rc_result_envelope_adds_result_type_and_cache_only_for_modern() {
let mut modern = json!({"tools": []});
apply_rc_result_envelope(
&mut modern,
McpProtocolMode::Modern,
Some(&McpCacheHint::list_default()),
);
assert_eq!(modern["resultType"], json!(RESULT_TYPE_COMPLETE));
assert_eq!(modern["ttlMs"], json!(DEFAULT_LIST_CACHE_TTL_MS));
assert_eq!(modern["cacheScope"], json!(DEFAULT_LIST_CACHE_SCOPE));
let mut legacy = json!({"tools": []});
apply_rc_result_envelope(
&mut legacy,
McpProtocolMode::Legacy,
Some(&McpCacheHint::list_default()),
);
assert!(legacy.get("resultType").is_none());
assert!(legacy.get("ttlMs").is_none());
assert!(legacy.get("cacheScope").is_none());
}
#[test]
fn apply_rc_result_envelope_preserves_caller_provided_result_type() {
let mut result = json!({"resultType": RESULT_TYPE_INPUT_REQUIRED});
apply_rc_result_envelope(&mut result, McpProtocolMode::Modern, None);
assert_eq!(result["resultType"], json!(RESULT_TYPE_INPUT_REQUIRED));
}
#[test]
fn server_discover_result_advertises_both_versions() {
let discover = server_discover_result(
json!({"tools": {}}),
json!({"name": "harn", "version": "x"}),
Some("hello"),
);
assert_eq!(discover["resultType"], json!(RESULT_TYPE_COMPLETE));
assert_eq!(discover["protocolVersion"], json!(DRAFT_PROTOCOL_VERSION));
let supported = discover["supportedVersions"].as_array().unwrap();
assert!(supported.iter().any(|v| v == DRAFT_PROTOCOL_VERSION));
assert!(supported.iter().any(|v| v == PROTOCOL_VERSION));
assert_eq!(discover["instructions"], json!("hello"));
}
}