use crate::{
MCP_PROTOCOL_VERSION_2025_03, MCP_PROTOCOL_VERSION_2025_06, MCP_PROTOCOL_VERSION_2026_07,
McpProtocolMode,
};
use serde_json::{Map, Value, json};
use std::collections::BTreeMap;
use std::time::Duration;
pub const CLIENT_NAME: &str = "everruns";
pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const CLIENT_INFO_META_KEY: &str = "io.modelcontextprotocol/clientInfo";
const PROTOCOL_VERSION_META_KEY: &str = "io.modelcontextprotocol/protocolVersion";
const CLIENT_CAPABILITIES_META_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
pub const HEADER_PROTOCOL_VERSION: &str = "MCP-Protocol-Version";
pub const HEADER_METHOD: &str = "Mcp-Method";
pub const HEADER_NAME: &str = "Mcp-Name";
pub const HEADER_SESSION_ID: &str = "Mcp-Session-Id";
pub const DEFAULT_STATEFUL_VERSION: &str = MCP_PROTOCOL_VERSION_2025_06;
pub fn client_info() -> Value {
json!({ "name": CLIENT_NAME, "version": CLIENT_VERSION })
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ClientCapabilities {
pub url_elicitation: bool,
pub form_elicitation: bool,
}
impl ClientCapabilities {
pub fn none() -> Self {
Self::default()
}
}
pub fn client_capabilities(capabilities: ClientCapabilities) -> Value {
let mut modes = Map::new();
if capabilities.url_elicitation {
modes.insert("url".to_string(), json!({}));
}
if capabilities.form_elicitation {
modes.insert("form".to_string(), json!({}));
}
if modes.is_empty() {
json!({})
} else {
json!({ "elicitation": modes })
}
}
pub fn request_meta(version: &str, capabilities: ClientCapabilities) -> Value {
let mut meta = Map::new();
meta.insert(PROTOCOL_VERSION_META_KEY.to_string(), json!(version));
meta.insert(CLIENT_INFO_META_KEY.to_string(), client_info());
meta.insert(
CLIENT_CAPABILITIES_META_KEY.to_string(),
client_capabilities(capabilities),
);
Value::Object(meta)
}
pub fn routable_headers(version: &str, method: &str, name: Option<&str>) -> Vec<(String, String)> {
let mut headers = vec![
(HEADER_PROTOCOL_VERSION.to_string(), version.to_string()),
(HEADER_METHOD.to_string(), method.to_string()),
];
if let Some(name) = name {
headers.push((HEADER_NAME.to_string(), name.to_string()));
}
headers
}
pub fn tools_list_body(id: i64, version: &str, capabilities: ClientCapabilities) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/list",
"params": { "_meta": request_meta(version, capabilities) }
})
}
pub fn request_body(
id: i64,
method: &str,
params: &Value,
version: &str,
capabilities: ClientCapabilities,
) -> Value {
let mut params = params.as_object().cloned().unwrap_or_default();
params.insert("_meta".to_string(), request_meta(version, capabilities));
json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": Value::Object(params),
})
}
pub fn tools_call_body(
id: i64,
name: &str,
arguments: &Value,
version: &str,
capabilities: ClientCapabilities,
) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": name,
"arguments": arguments,
"_meta": request_meta(version, capabilities),
}
})
}
pub fn tools_call_retry_body(
id: i64,
name: &str,
arguments: &Value,
version: &str,
capabilities: ClientCapabilities,
request_state: Option<&str>,
input_responses: &BTreeMap<String, Value>,
) -> Value {
let mut body = tools_call_body(id, name, arguments, version, capabilities);
let Some(params) = body["params"].as_object_mut() else {
return body;
};
if let Some(request_state) = request_state {
params.insert("requestState".to_string(), json!(request_state));
}
if !input_responses.is_empty() {
params.insert(
"inputResponses".to_string(),
Value::Object(input_responses.clone().into_iter().collect()),
);
}
body
}
pub fn initialize_body(id: i64, version: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": {
"protocolVersion": version,
"capabilities": {},
"clientInfo": client_info(),
}
})
}
pub fn initialized_notification() -> Value {
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })
}
pub fn protocol_version_from_initialize(body: &str) -> Option<String> {
let value: Value = serde_json::from_str(body).ok()?;
value
.get("result")?
.get("protocolVersion")?
.as_str()
.map(|s| s.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputRequest {
pub key: String,
pub method: String,
pub params: Value,
}
impl InputRequest {
pub fn url_elicitation(&self) -> Option<(String, String)> {
if self.method != "elicitation/create" {
return None;
}
if self.params.get("mode").and_then(Value::as_str)? != "url" {
return None;
}
let url = self.params.get("url").and_then(Value::as_str)?.to_string();
let message = self
.params
.get("message")
.and_then(Value::as_str)
.unwrap_or("The server needs you to complete an interaction in your browser.")
.to_string();
Some((message, url))
}
}
impl InputRequest {
pub fn form_elicitation(&self) -> Option<(String, Value)> {
if self.method != "elicitation/create" {
return None;
}
match self.params.get("mode").and_then(Value::as_str) {
None | Some("form") => {}
Some(_) => return None,
}
let schema = self.params.get("requestedSchema")?.clone();
let message = self
.params
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
Some((message, schema))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputRequired {
pub requests: Vec<InputRequest>,
pub request_state: Option<String>,
}
impl InputRequired {
pub fn keys(&self) -> Vec<String> {
self.requests.iter().map(|r| r.key.clone()).collect()
}
}
pub fn input_required_from_result(body: &str) -> Option<InputRequired> {
let value: Value = serde_json::from_str(body).ok()?;
let result = value.get("result")?;
if result.get("resultType")?.as_str()? != "input_required" {
return None;
}
let requests = result
.get("inputRequests")
.and_then(Value::as_object)
.map(|requests| {
requests
.iter()
.map(|(key, request)| InputRequest {
key: key.clone(),
method: request
.get("method")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
params: request.get("params").cloned().unwrap_or(Value::Null),
})
.collect()
})
.unwrap_or_default();
Some(InputRequired {
requests,
request_state: result
.get("requestState")
.and_then(Value::as_str)
.map(str::to_string),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheScope {
Public,
Private,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CacheHints {
pub ttl: Duration,
pub scope: CacheScope,
}
pub fn cache_hints_from_result(body: &str) -> Option<CacheHints> {
let value: Value = serde_json::from_str(body).ok()?;
let result = value.get("result")?;
let ttl_ms = result.get("ttlMs")?.as_i64()?;
if ttl_ms <= 0 {
return None;
}
let scope = match result.get("cacheScope").and_then(Value::as_str) {
Some("public") => CacheScope::Public,
_ => CacheScope::Private,
};
Some(CacheHints {
ttl: Duration::from_millis(ttl_ms as u64),
scope,
})
}
pub fn session_id_from_headers(headers: &BTreeMap<String, String>) -> Option<String> {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(HEADER_SESSION_ID))
.map(|(_, value)| value.clone())
.filter(|value| !value.is_empty())
}
pub fn looks_like_handshake_required(status: u16, body: &str) -> bool {
let lower = body.to_ascii_lowercase();
if matches!(status, 400 | 404 | 405 | 409 | 426)
&& (lower.contains("session")
|| lower.contains("initialize")
|| lower.contains("mcp-session-id"))
{
return true;
}
if status == 400
&& (lower.contains("protocol version")
|| lower.contains("protocol-version")
|| lower.contains("protocolversion"))
&& (lower.contains("unsupported")
|| lower.contains("not supported")
|| lower.contains("invalid"))
{
return true;
}
lower.contains("server not initialized")
|| lower.contains("session required")
|| lower.contains("missing session")
|| lower.contains("not initialized")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Negotiated {
pub version: String,
pub stateful: bool,
pub session_id: Option<String>,
}
impl Negotiated {
pub fn stateless(version: impl Into<String>) -> Self {
Self {
version: version.into(),
stateful: false,
session_id: None,
}
}
pub fn initial_for_mode(mode: McpProtocolMode) -> Self {
match mode {
McpProtocolMode::Auto | McpProtocolMode::V2026July => {
Negotiated::stateless(MCP_PROTOCOL_VERSION_2026_07)
}
McpProtocolMode::V2025June => Self {
version: MCP_PROTOCOL_VERSION_2025_06.to_string(),
stateful: true,
session_id: None,
},
McpProtocolMode::V2025March => Self {
version: MCP_PROTOCOL_VERSION_2025_03.to_string(),
stateful: true,
session_id: None,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_body_keeps_params_beside_meta() {
let params = json!({ "name": "issue.created", "arguments": { "team": "eng" } });
let body = request_body(
7,
"events/subscribe",
¶ms,
MCP_PROTOCOL_VERSION_2026_07,
ClientCapabilities::none(),
);
assert_eq!(body["id"], 7);
assert_eq!(body["method"], "events/subscribe");
assert_eq!(body["params"]["name"], "issue.created");
assert_eq!(body["params"]["arguments"], json!({ "team": "eng" }));
assert!(body["params"]["_meta"][CLIENT_INFO_META_KEY].is_object());
let body = request_body(
1,
"ping",
&Value::Null,
MCP_PROTOCOL_VERSION_2026_07,
ClientCapabilities::none(),
);
let params = body["params"].as_object().unwrap();
assert_eq!(params.len(), 1, "only _meta: {params:?}");
}
#[test]
fn request_meta_carries_client_info_under_canonical_key() {
let meta = request_meta(MCP_PROTOCOL_VERSION_2026_07, ClientCapabilities::none());
let info = meta.get(CLIENT_INFO_META_KEY).expect("client info present");
assert_eq!(info.get("name").and_then(|v| v.as_str()), Some(CLIENT_NAME));
assert_eq!(
info.get("version").and_then(|v| v.as_str()),
Some(CLIENT_VERSION)
);
}
#[test]
fn routable_headers_include_name_only_for_calls() {
let list = routable_headers(MCP_PROTOCOL_VERSION_2026_07, "tools/list", None);
assert!(list.iter().all(|(k, _)| k != HEADER_NAME));
assert!(
list.iter()
.any(|(k, v)| k == HEADER_PROTOCOL_VERSION && v == MCP_PROTOCOL_VERSION_2026_07)
);
let call = routable_headers(MCP_PROTOCOL_VERSION_2026_07, "tools/call", Some("search"));
assert!(call.iter().any(|(k, v)| k == HEADER_NAME && v == "search"));
assert!(
call.iter()
.any(|(k, v)| k == HEADER_METHOD && v == "tools/call")
);
}
#[test]
fn bodies_carry_meta() {
let list = tools_list_body(1, MCP_PROTOCOL_VERSION_2026_07, ClientCapabilities::none());
assert!(list["params"]["_meta"].is_object());
let call = tools_call_body(
2,
"search",
&json!({"q": "x"}),
MCP_PROTOCOL_VERSION_2026_07,
ClientCapabilities::none(),
);
assert_eq!(call["params"]["name"], "search");
assert!(call["params"]["_meta"].is_object());
assert_eq!(call["params"]["arguments"]["q"], "x");
}
#[test]
fn parses_protocol_version_and_session_id() {
let body = r#"{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{}}}"#;
assert_eq!(
protocol_version_from_initialize(body).as_deref(),
Some("2025-03-26")
);
let mut headers = BTreeMap::new();
headers.insert("mcp-session-id".to_string(), "abc123".to_string());
assert_eq!(session_id_from_headers(&headers).as_deref(), Some("abc123"));
headers.clear();
assert_eq!(session_id_from_headers(&headers), None);
}
#[test]
fn handshake_detection_requires_explicit_fallback_signals() {
assert!(looks_like_handshake_required(
400,
"Bad Request: Mcp-Session-Id header is required"
));
assert!(looks_like_handshake_required(
200,
r#"{"error":{"code":-32600,"message":"Server not initialized"}}"#
));
assert!(looks_like_handshake_required(
400,
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Unsupported protocol version: 2026-07-28"}}"#
));
assert!(looks_like_handshake_required(
400,
"Invalid MCP-Protocol-Version header"
));
assert!(!looks_like_handshake_required(500, "internal server error"));
assert!(!looks_like_handshake_required(400, "invalid arguments"));
assert!(!looks_like_handshake_required(
400,
"unsupported tool argument"
));
}
#[test]
fn initial_negotiation_matches_mode() {
assert!(!Negotiated::initial_for_mode(McpProtocolMode::Auto).stateful);
assert!(!Negotiated::initial_for_mode(McpProtocolMode::V2026July).stateful);
assert!(Negotiated::initial_for_mode(McpProtocolMode::V2025June).stateful);
assert!(Negotiated::initial_for_mode(McpProtocolMode::V2025March).stateful);
assert_eq!(
Negotiated::initial_for_mode(McpProtocolMode::V2025March).version,
MCP_PROTOCOL_VERSION_2025_03
);
}
#[test]
fn meta_carries_version_capabilities_and_client_info() {
let meta = request_meta(MCP_PROTOCOL_VERSION_2026_07, ClientCapabilities::none());
assert_eq!(
meta.get(PROTOCOL_VERSION_META_KEY).and_then(|v| v.as_str()),
Some(MCP_PROTOCOL_VERSION_2026_07)
);
assert_eq!(meta.get(CLIENT_CAPABILITIES_META_KEY), Some(&json!({})));
let with_elicitation = request_meta(
MCP_PROTOCOL_VERSION_2026_07,
ClientCapabilities {
url_elicitation: true,
form_elicitation: false,
},
);
assert_eq!(
with_elicitation.get(CLIENT_CAPABILITIES_META_KEY),
Some(&json!({ "elicitation": { "url": {} } }))
);
let with_form = request_meta(
MCP_PROTOCOL_VERSION_2026_07,
ClientCapabilities {
url_elicitation: true,
form_elicitation: true,
},
);
assert_eq!(
with_form.get(CLIENT_CAPABILITIES_META_KEY),
Some(&json!({ "elicitation": { "url": {}, "form": {} } }))
);
}
#[test]
fn form_elicitation_requests_are_recognized() {
let body = r#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"input_required",
"inputRequests":{
"explicit":{"method":"elicitation/create","params":{"mode":"form",
"message":"Pick","requestedSchema":{"type":"object","properties":{}}}},
"implicit":{"method":"elicitation/create","params":{
"message":"Pick","requestedSchema":{"type":"object","properties":{}}}},
"link":{"method":"elicitation/create","params":{"mode":"url",
"url":"https://x.example","message":"Go"}},
"noschema":{"method":"elicitation/create","params":{"mode":"form"}}
}}}"#;
let parsed = input_required_from_result(body).expect("input_required");
let by_key = |key: &str| {
parsed
.requests
.iter()
.find(|r| r.key == key)
.expect("request present")
.form_elicitation()
};
assert!(by_key("explicit").is_some());
assert!(by_key("implicit").is_some());
assert!(by_key("link").is_none());
assert!(by_key("noschema").is_none());
}
#[test]
fn url_elicitation_requests_are_recognized_and_others_are_not() {
let body = r#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"input_required",
"inputRequests":{
"connect":{"method":"elicitation/create","params":{"mode":"url",
"url":"https://mcp.example.com/connect?x=1","message":"Authorize Example."}},
"name":{"method":"elicitation/create","params":{"mode":"form","message":"Name?"}},
"guess":{"method":"sampling/createMessage","params":{}}
},"requestState":"blob"}}"#;
let parsed = input_required_from_result(body).expect("input_required");
let by_key = |key: &str| {
parsed
.requests
.iter()
.find(|r| r.key == key)
.expect("request present")
.clone()
};
assert_eq!(
by_key("connect").url_elicitation(),
Some((
"Authorize Example.".to_string(),
"https://mcp.example.com/connect?x=1".to_string()
))
);
assert_eq!(by_key("name").url_elicitation(), None);
assert_eq!(by_key("guess").url_elicitation(), None);
}
#[test]
fn detects_input_required_results() {
let bare = r#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"input_required","requestState":"opaque"}}"#;
let parsed = input_required_from_result(bare).expect("input_required");
assert!(parsed.requests.is_empty());
assert_eq!(parsed.request_state.as_deref(), Some("opaque"));
let with_requests = r#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"input_required","inputRequests":{"github_login":{"method":"elicitation/create"}}}}"#;
let parsed = input_required_from_result(with_requests).expect("input_required");
assert_eq!(parsed.keys(), vec!["github_login".to_string()]);
assert_eq!(parsed.request_state, None);
let complete =
r#"{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","content":[]}}"#;
assert!(input_required_from_result(complete).is_none());
assert!(input_required_from_result(r#"{"result":{"content":[]}}"#).is_none());
}
#[test]
fn retry_body_echoes_request_state_verbatim() {
let mut responses = BTreeMap::new();
responses.insert("connect".to_string(), json!({ "action": "accept" }));
let body = tools_call_retry_body(
2,
"search",
&json!({"q": "x"}),
MCP_PROTOCOL_VERSION_2026_07,
ClientCapabilities {
url_elicitation: true,
form_elicitation: false,
},
Some("AEAD-blob=="),
&responses,
);
assert_eq!(body["id"], 2);
assert_eq!(body["params"]["requestState"], "AEAD-blob==");
assert_eq!(body["params"]["arguments"]["q"], "x");
assert_eq!(
body["params"]["inputResponses"]["connect"]["action"],
"accept"
);
let bare = tools_call_retry_body(
2,
"search",
&json!({}),
MCP_PROTOCOL_VERSION_2026_07,
ClientCapabilities::none(),
None,
&BTreeMap::new(),
);
assert!(bare["params"].get("requestState").is_none());
assert!(bare["params"].get("inputResponses").is_none());
}
#[test]
fn cache_hints_parse_from_result() {
let public = r#"{"result":{"tools":[],"ttlMs":300000,"cacheScope":"public"}}"#;
let hints = cache_hints_from_result(public).expect("hints");
assert_eq!(hints.ttl, Duration::from_millis(300_000));
assert_eq!(hints.scope, CacheScope::Public);
let unscoped = r#"{"result":{"tools":[],"ttlMs":1000}}"#;
assert_eq!(
cache_hints_from_result(unscoped).unwrap().scope,
CacheScope::Private
);
assert!(cache_hints_from_result(r#"{"result":{"ttlMs":0}}"#).is_none());
assert!(cache_hints_from_result(r#"{"result":{"ttlMs":-5}}"#).is_none());
assert!(cache_hints_from_result(r#"{"result":{"tools":[]}}"#).is_none());
}
}