use serde_json::Value;
pub const PROTOCOL_VERSION: &str = "2026-07-28";
pub const PROTOCOL_VERSION_LEGACY: &str = "2025-06-18";
pub const CLIENT_INFO_KEY: &str = "io.modelcontextprotocol/clientInfo";
pub fn meta(msg: &Value) -> Option<&Value> {
msg.get("params")?.get("_meta")
}
pub fn claimed_client(msg: &Value) -> Option<&str> {
meta(msg)?.get(CLIENT_INFO_KEY)?.get("name")?.as_str()
}
#[derive(Default)]
pub struct SessionIdentity {
first: Option<String>,
}
impl SessionIdentity {
pub fn new() -> Self {
Self::default()
}
pub fn observe(&mut self, msg: &Value) -> Option<(String, String)> {
let claimed = claimed_client(msg)?.to_string();
match &self.first {
None => {
self.first = Some(claimed);
None
}
Some(first) if *first != claimed => Some((first.clone(), claimed)),
Some(_) => None,
}
}
}
pub fn meta_strings(msg: &Value) -> Vec<String> {
let mut out = Vec::new();
if let Some(m) = meta(msg) {
collect(m, &mut out);
}
out
}
fn collect(v: &Value, out: &mut Vec<String>) {
match v {
Value::String(s) => out.push(s.clone()),
Value::Array(a) => a.iter().for_each(|x| collect(x, out)),
Value::Object(o) => o.values().for_each(|x| collect(x, out)),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn req(meta: Value) -> Value {
json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "write_file", "arguments": {}, "_meta": meta }
})
}
#[test]
fn reads_client_info_from_the_new_meta_key() {
let m = req(json!({ CLIENT_INFO_KEY: { "name": "claude-code", "version": "1.0" } }));
assert_eq!(claimed_client(&m), Some("claude-code"));
}
#[test]
fn a_request_without_meta_is_not_an_error() {
let m = json!({"jsonrpc":"2.0","id":1,"method":"tools/list"});
assert!(meta(&m).is_none());
assert!(claimed_client(&m).is_none());
assert!(meta_strings(&m).is_empty());
}
#[test]
fn identity_change_mid_session_is_reported() {
let mut id = SessionIdentity::new();
let a = req(json!({ CLIENT_INFO_KEY: { "name": "claude-code" } }));
let b = req(json!({ CLIENT_INFO_KEY: { "name": "evil-client" } }));
assert_eq!(id.observe(&a), None, "the first claim establishes identity");
assert_eq!(id.observe(&a), None, "repeating the same claim is fine");
assert_eq!(
id.observe(&b),
Some(("claude-code".into(), "evil-client".into())),
"a contradicting claim must be surfaced"
);
}
#[test]
fn meta_strings_are_collected_for_taint_scanning() {
let m = req(json!({
CLIENT_INFO_KEY: { "name": "claude-code" },
"baggage": "note=visit https://evil.example/x",
}));
let s = meta_strings(&m);
assert!(s.iter().any(|x| x.contains("evil.example")));
assert!(s.iter().any(|x| x == "claude-code"));
}
}
pub fn tool_annotations(msg: &Value) -> Vec<(String, crate::classify::Ann)> {
let Some(tools) = msg.pointer("/result/tools").and_then(Value::as_array) else {
return Vec::new();
};
tools
.iter()
.filter_map(|t| {
let name = t.get("name")?.as_str()?.to_string();
let a = t.get("annotations");
Some((
name,
crate::classify::Ann {
read_only: a
.and_then(|x| x.get("readOnlyHint"))
.and_then(Value::as_bool),
destructive: a
.and_then(|x| x.get("destructiveHint"))
.and_then(Value::as_bool),
},
))
})
.collect()
}
#[cfg(test)]
mod annotation_parsing {
use super::*;
use serde_json::json;
#[test]
fn reads_hints_from_a_real_tools_list_shape() {
let msg = json!({"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"directory_tree","annotations":{"readOnlyHint":true,"openWorldHint":false}},
{"name":"write_file","annotations":{"readOnlyHint":false,"destructiveHint":true}},
{"name":"mystery"}
]}});
let got = tool_annotations(&msg);
assert_eq!(got.len(), 3);
assert_eq!(got[0].0, "directory_tree");
assert_eq!(got[0].1.read_only, Some(true));
assert_eq!(got[1].1.destructive, Some(true));
assert_eq!(
got[2].1.read_only, None,
"a tool with no annotations is unknown, not safe"
);
}
#[test]
fn a_non_tools_list_message_yields_nothing() {
assert!(tool_annotations(&json!({"result":{"content":[]}})).is_empty());
assert!(tool_annotations(&json!({"method":"tools/call"})).is_empty());
}
}
const MIN_SIBLINGS: usize = 3;
pub fn namespaces(names: &[String]) -> std::collections::HashSet<String> {
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for n in names {
if let Some(head) = head_token(n) {
if is_known_verb(&head) {
continue;
}
*counts.entry(head).or_default() += 1;
}
}
counts
.into_iter()
.filter(|(_, c)| *c >= MIN_SIBLINGS)
.map(|(k, _)| k)
.collect()
}
fn is_known_verb(token: &str) -> bool {
if !kedge_core::classify(token).is_mutating() {
return true;
}
kedge_core::classify(&format!("read_{token}")).is_mutating()
}
fn head_token(name: &str) -> Option<String> {
name.split(|c: char| !c.is_ascii_alphanumeric())
.find(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase())
}
pub fn strip_namespace(name: &str, spaces: &std::collections::HashSet<String>) -> String {
let Some(head) = head_token(name) else {
return name.to_string();
};
if !spaces.contains(&head) {
return name.to_string();
}
let rest: String = name
.chars()
.skip(head.chars().count())
.skip_while(|c| !c.is_ascii_alphanumeric())
.collect();
if rest.trim().is_empty() {
name.to_string()
} else {
rest
}
}
#[cfg(test)]
mod namespace_detection {
use super::*;
fn names(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_shared_prefix_is_recognised_as_a_namespace() {
let ns = namespaces(&names(&[
"puppeteer_navigate",
"puppeteer_screenshot",
"puppeteer_click",
"puppeteer_fill",
"puppeteer_select",
"puppeteer_hover",
"puppeteer_evaluate",
]));
assert!(ns.contains("puppeteer"));
assert_eq!(strip_namespace("puppeteer_screenshot", &ns), "screenshot");
}
#[test]
fn a_lone_prefix_earns_nothing() {
let ns = namespaces(&names(&["ns_get_frobnicate", "read_file", "write_file"]));
assert!(!ns.contains("ns"), "a single occurrence is not a namespace");
assert_eq!(
strip_namespace("ns_get_frobnicate", &ns),
"ns_get_frobnicate",
"unchanged, so kedge still fails safe on the unknown head"
);
}
#[test]
fn two_siblings_are_still_not_enough() {
let ns = namespaces(&names(&["ns_get_a", "ns_get_b", "read_file"]));
assert!(!ns.contains("ns"), "below the corroboration threshold");
}
#[test]
fn the_real_filesystem_catalogue_declares_no_namespace() {
let ns = namespaces(&names(&[
"read_file",
"read_text_file",
"read_media_file",
"write_file",
"edit_file",
"list_directory",
"directory_tree",
"search_files",
"get_file_info",
"move_file",
]));
assert!(!ns.contains("directory"), "one occurrence, not a namespace");
assert_eq!(strip_namespace("directory_tree", &ns), "directory_tree");
}
#[test]
fn a_shared_verb_is_never_treated_as_a_namespace() {
let ns = namespaces(&names(&[
"write_file",
"write_query",
"write_config",
"write_log",
]));
assert!(
!ns.contains("write"),
"BYPASS: a verb was treated as a namespace"
);
assert_eq!(strip_namespace("write_query", &ns), "write_query");
for verb in ["get", "delete", "read", "list", "rm", "create"] {
let catalogue = names(&[
&format!("{verb}_a"),
&format!("{verb}_b"),
&format!("{verb}_c"),
&format!("{verb}_d"),
]);
let ns = namespaces(&catalogue);
assert!(
!ns.contains(verb),
"BYPASS: verb {verb:?} treated as a namespace"
);
}
}
#[test]
fn stripping_never_empties_a_name() {
let ns: std::collections::HashSet<String> = ["puppeteer".to_string()].into_iter().collect();
assert_eq!(strip_namespace("puppeteer", &ns), "puppeteer");
assert_eq!(strip_namespace("puppeteer_", &ns), "puppeteer_");
}
}