From b1f17b5808aad09f27c4a15fa9a1d1174b25a512 Mon Sep 17 00:00:00 2001 From: Lauritz-Timm Date: Fri, 7 Aug 2026 21:44:44 +0200 Subject: [PATCH 1/3] feat(store): add bounded exact topic reads --- crates/icm-store/src/backend.rs | 3 ++ crates/icm-store/src/opensearch.rs | 44 ++++++++++++++++ crates/icm-store/src/postgres.rs | 20 +++++++ crates/icm-store/src/store.rs | 55 ++++++++++++++++++++ crates/icm-store/tests/opensearch_backend.rs | 27 ++++++++++ 5 files changed, 149 insertions(+) diff --git a/crates/icm-store/src/backend.rs b/crates/icm-store/src/backend.rs index 4188735..0341a22 100644 --- a/crates/icm-store/src/backend.rs +++ b/crates/icm-store/src/backend.rs @@ -450,6 +450,9 @@ impl Store { pub fn get_many(&self, ids: &[&str]) -> IcmResult> { dispatch!(self, get_many(ids)) } + pub fn get_by_topics_limited(&self, topics: &[&str], limit: usize) -> IcmResult> { + dispatch!(self, get_by_topics_limited(topics, limit)) + } pub fn get_by_topic_prefix(&self, topic: &str) -> IcmResult> { dispatch!(self, get_by_topic_prefix(topic)) } diff --git a/crates/icm-store/src/opensearch.rs b/crates/icm-store/src/opensearch.rs index b551726..893cdfd 100644 --- a/crates/icm-store/src/opensearch.rs +++ b/crates/icm-store/src/opensearch.rs @@ -378,6 +378,7 @@ impl OpenSearchStore { "last_accessed": {"type": "date"}, "access_count": {"type": "integer"}, "weight": {"type": "float"}, + "memory_id": {"type": "keyword"}, "topic": {"type": "text", "fields": {"keyword": {"type": "keyword", "ignore_above": 1024}}}, "summary": {"type": "text"}, "raw_excerpt": {"type": "text"}, @@ -395,6 +396,25 @@ impl OpenSearchStore { }} }), )?; + self.request( + "PUT", + &format!("{IDX_MEMORIES}/_mapping"), + Some(json!({"properties": {"memory_id": {"type": "keyword"}}})), + false, + )?; + self.post( + &format!( + "{IDX_MEMORIES}/_update_by_query?conflicts=proceed&{}", + self.refresh_param() + ), + json!({ + "query": {"bool": {"must_not": {"exists": {"field": "memory_id"}}}}, + "script": { + "lang": "painless", + "source": "ctx._source.memory_id = ctx._id" + } + }), + )?; self.create_index(IDX_METADATA, json!({"mappings": {"properties": {"value": {"type": "double"}, "text_value": {"type": "keyword"}}}}))?; self.create_index( IDX_HOOKS, @@ -473,6 +493,7 @@ impl OpenSearchStore { fn memory_to_source(memory: &Memory) -> Value { let mut doc = json!({ + "memory_id": memory.id, "created_at": memory.created_at.to_rfc3339(), "updated_at": memory.updated_at.to_rfc3339(), "last_accessed": memory.last_accessed.to_rfc3339(), @@ -1656,6 +1677,29 @@ impl OpenSearchStore { Ok(out) } + /// Fetch a bounded, deterministic merge of exact topics in one query. + pub fn get_by_topics_limited(&self, topics: &[&str], limit: usize) -> IcmResult> { + if topics.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let response = self.post( + &format!("{IDX_MEMORIES}/_search"), + json!({ + "size": limit, + "query": {"terms": {"topic.keyword": topics}}, + "sort": [{"weight": "desc"}, {"memory_id": "asc"}] + }), + )?; + let mut memories = Self::hits_to_memories(&response); + memories.sort_by(|left, right| { + right + .weight + .total_cmp(&left.weight) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(memories) + } + pub fn get_by_topic_prefix(&self, topic: &str) -> IcmResult> { let resp = self.post( &format!("{IDX_MEMORIES}/_search"), diff --git a/crates/icm-store/src/postgres.rs b/crates/icm-store/src/postgres.rs index a937a1e..3df2b0c 100644 --- a/crates/icm-store/src/postgres.rs +++ b/crates/icm-store/src/postgres.rs @@ -827,6 +827,26 @@ impl PostgresStore { Ok(map) } + /// Fetch a bounded, deterministic merge of exact topics in one query. + pub fn get_by_topics_limited(&self, topics: &[&str], limit: usize) -> IcmResult> { + if topics.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let topics: Vec = topics.iter().map(|topic| (*topic).to_owned()).collect(); + let limit = i64::try_from(limit).unwrap_or(i64::MAX); + let mut connection = self.conn()?; + let rows = connection + .query( + &format!( + "SELECT {SELECT_COLS} FROM memories WHERE topic = ANY($1) \ + ORDER BY weight DESC, id COLLATE \"C\" ASC LIMIT $2" + ), + &[&topics, &limit], + ) + .map_err(pg_err)?; + Ok(rows.iter().map(row_to_memory).collect()) + } + /// Expand a scored result set with one hop of related memories. /// Backend-agnostic logic mirrored from the SQLite store. pub fn expand_with_neighbors( diff --git a/crates/icm-store/src/store.rs b/crates/icm-store/src/store.rs index 2929df5..3e70aa2 100644 --- a/crates/icm-store/src/store.rs +++ b/crates/icm-store/src/store.rs @@ -4285,6 +4285,33 @@ impl SqliteStore { Ok(out) } + /// Fetch a bounded, deterministic merge of exact topics in one query. + pub fn get_by_topics_limited(&self, topics: &[&str], limit: usize) -> IcmResult> { + if topics.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let topic_slots: Vec = (1..=topics.len()) + .map(|index| format!("?{index}")) + .collect(); + let limit_slot = topics.len() + 1; + let sql = format!( + "SELECT {SELECT_COLS} FROM memories WHERE topic IN ({}) \ + ORDER BY weight DESC, id ASC LIMIT ?{limit_slot}", + topic_slots.join(", ") + ); + let limit = i64::try_from(limit).unwrap_or(i64::MAX); + let mut parameters: Vec<&dyn rusqlite::types::ToSql> = topics + .iter() + .map(|topic| topic as &dyn rusqlite::types::ToSql) + .collect(); + parameters.push(&limit); + let mut statement = self.conn.prepare(&sql).map_err(db_err)?; + let rows = statement + .query_map(parameters.as_slice(), row_to_memory) + .map_err(db_err)?; + collect_rows(rows) + } + /// Get memories by topic prefix (e.g., "wshm" matches "wshm:owner/repo"). /// /// If `topic` ends with `*`, uses LIKE matching. Otherwise exact match. @@ -7831,6 +7858,34 @@ mod tests { assert_eq!(got.len(), 1); } + #[test] + fn test_get_by_topics_limited_is_exact_bounded_and_deterministic() { + let store = test_store(); + for (id, topic, weight) in [ + ("01A", "wanted-a", 0.9), + ("01B", "wanted-b", 0.9), + ("01C", "wanted-a", 0.8), + ("01D", "wanted-a-extra", 1.0), + ] { + let mut memory = make_memory(topic, &format!("summary {id}")); + memory.id = id.into(); + memory.weight = weight; + store.store(memory).unwrap(); + } + + let memories = store + .get_by_topics_limited(&["wanted-a", "wanted-b"], 2) + .unwrap(); + assert_eq!( + memories + .iter() + .map(|memory| memory.id.as_str()) + .collect::>(), + ["01A", "01B"] + ); + assert!(store.get_by_topics_limited(&[], 2).unwrap().is_empty()); + } + // ── LRU cache invalidation ──────────────────────────────────────────── #[test] diff --git a/crates/icm-store/tests/opensearch_backend.rs b/crates/icm-store/tests/opensearch_backend.rs index 9028c48..05975fa 100644 --- a/crates/icm-store/tests/opensearch_backend.rs +++ b/crates/icm-store/tests/opensearch_backend.rs @@ -275,3 +275,30 @@ fn opensearch_consolidate_topic_cleans_up_dangling_related_ids_in_other_memories } let _ = store.delete(&external_id); } + +#[test] +fn opensearch_topic_limit_breaks_weight_ties_by_id() { + if skip_if_no_os() { + return; + } + let store = Store::with_dims( + std::path::Path::new("ignored"), + icm_core::DEFAULT_EMBEDDING_DIMS, + ) + .expect("connect + migrate opensearch"); + let topic = format!("itest-order-{}", ulid::Ulid::new()); + let mut later = mem(&topic, "later id", Importance::Medium); + later.id = "01ZZZZZZZZZZZZZZZZZZZZZZZZ".into(); + later.weight = 1.0; + let mut earlier = mem(&topic, "earlier id", Importance::Medium); + earlier.id = "01000000000000000000000000".into(); + earlier.weight = 1.0; + store.store(later).unwrap(); + store.store(earlier).unwrap(); + + let selected = store.get_by_topics_limited(&[&topic], 1).unwrap(); + assert_eq!(selected[0].id, "01000000000000000000000000"); + for memory in store.get_by_topic(&topic).unwrap() { + let _ = store.delete(&memory.id); + } +} From a2f6e9b25bbecbab182b6ef30b6735c413917023 Mon Sep 17 00:00:00 2001 From: Lauritz-Timm Date: Fri, 7 Aug 2026 21:45:18 +0200 Subject: [PATCH 2/3] feat(mcp): expose bounded project context --- crates/icm-mcp/src/service.rs | 408 ++++++++++++++++++++++++++++++---- 1 file changed, 366 insertions(+), 42 deletions(-) diff --git a/crates/icm-mcp/src/service.rs b/crates/icm-mcp/src/service.rs index 60923c7..97b8a1c 100644 --- a/crates/icm-mcp/src/service.rs +++ b/crates/icm-mcp/src/service.rs @@ -3,8 +3,9 @@ use std::collections::HashSet; use std::path::PathBuf; -use icm_core::Embedder; +use icm_core::{project::project_from_path, Embedder, IcmError, IcmResult, Memory}; use icm_store::Store; +use serde::Serialize; use serde_json::{json, Map, Value}; use crate::catalog::{DispatchResult, InputValidation, ToolCatalog, ToolContext}; @@ -23,6 +24,11 @@ const MODERN_SERVER_INFO_KEY: &str = "io.modelcontextprotocol/serverInfo"; const MODERN_LOG_LEVEL_KEY: &str = "io.modelcontextprotocol/logLevel"; const MODERN_SUBSCRIPTION_ID_KEY: &str = "io.modelcontextprotocol/subscriptionId"; const MAX_STORED_LIFECYCLE_METHOD_BYTES: usize = 256; +const ACTIVE_PROJECT_CONTEXT_URI: &str = "icm://active-project/context"; +const ACTIVE_PROJECT_CONTEXT_MIME_TYPE: &str = "application/json"; +const RESOURCE_ROW_LIMIT: usize = 64; +const RESOURCE_FIELD_BYTES: usize = 512; +const RESOURCE_MAX_BYTES: usize = 2048; pub const ICM_INSTRUCTIONS: &str = "\ Use ICM (Infinite Context Memory) proactively to maintain long-term memory across sessions.\n\ @@ -86,6 +92,7 @@ pub struct McpService<'a> { compact: bool, auto_consolidate: AutoConsolidate, working_directory: PathBuf, + active_project: Option, catalog: ToolCatalog, } @@ -113,12 +120,17 @@ impl<'a> McpService<'a> { auto_consolidate: AutoConsolidate, working_directory: PathBuf, ) -> Self { + let active_project = working_directory + .to_str() + .and_then(project_from_path) + .filter(|project| valid_resource_project(project)); Self { store, embedder, compact, auto_consolidate, working_directory, + active_project, catalog: tools::build_catalog(embedder.is_some()), } } @@ -448,50 +460,132 @@ impl<'a> McpService<'a> { "tools/list" => self.list_tools(id, revision, message), "tools/call" => self.call_tool(state, id, revision, message), "resources/list" if revision != ProtocolRevision::V2024_11_05 => { - let result = if revision == ProtocolRevision::V2026_07_28 { - project_result( - revision, - json!({ "resources": [] }), - Some((3_600_000, "private")), - ) - } else { - json!({ - "resources": [], - "_meta": { "ttlMs": 0, "cacheScope": "private" } - }) - }; - JsonRpcResponse::ok(id, result) + self.list_resources(id, revision, message) } "resources/read" if revision != ProtocolRevision::V2024_11_05 => { - let uri = message - .params - .as_ref() - .and_then(Value::as_object) - .and_then(|params| params.get("uri")) - .and_then(Value::as_str); - let Some(uri) = uri else { - return JsonRpcResponse::err( - id, - -32602, - "resources/read.uri must be a string".into(), - ); - }; - let code = if revision == ProtocolRevision::V2026_07_28 { - -32602 - } else { - -32002 - }; - JsonRpcResponse::err_with_data( - id, - code, - "resource not found".into(), - Some(json!({ "uri": uri })), - ) + self.read_resource(id, revision, message) } other => JsonRpcResponse::method_not_found(id, other), } } + fn list_resources( + &self, + id: Value, + revision: ProtocolRevision, + message: &JsonRpcMessage, + ) -> JsonRpcResponse { + let cursor = message + .params + .as_ref() + .and_then(Value::as_object) + .and_then(|params| params.get("cursor")); + if cursor.is_some_and(|cursor| !cursor.is_null() && cursor.as_str() != Some("")) { + return JsonRpcResponse::err( + id, + -32602, + "resources/list cursor is not supported".into(), + ); + } + + let resources = self + .active_project + .as_ref() + .map(|_| { + json!({ + "uri": ACTIVE_PROJECT_CONTEXT_URI, + "name": "active-project-context", + "title": "Active Project Context", + "description": "Stored context for the project inferred from the MCP server working directory.", + "mimeType": ACTIVE_PROJECT_CONTEXT_MIME_TYPE, + "annotations": { "audience": ["assistant"], "priority": 1.0 } + }) + }) + .into_iter() + .collect::>(); + let result = if revision == ProtocolRevision::V2026_07_28 { + project_result( + revision, + json!({ "resources": resources }), + Some((3_600_000, "private")), + ) + } else { + json!({ + "resources": resources, + "_meta": { "ttlMs": 0, "cacheScope": "private" } + }) + }; + JsonRpcResponse::ok(id, result) + } + + fn read_resource( + &self, + id: Value, + revision: ProtocolRevision, + message: &JsonRpcMessage, + ) -> JsonRpcResponse { + let Some(params) = message.params.as_ref().and_then(Value::as_object) else { + return JsonRpcResponse::err( + id, + -32602, + "resources/read params must be an object".into(), + ); + }; + if params + .keys() + .any(|key| !matches!(key.as_str(), "uri" | "_meta")) + { + return JsonRpcResponse::err( + id, + -32602, + "resources/read accepts only uri and _meta".into(), + ); + } + let Some(uri) = params.get("uri").and_then(Value::as_str) else { + return JsonRpcResponse::err(id, -32602, "resources/read.uri must be a string".into()); + }; + let Some(project) = self + .active_project + .as_deref() + .filter(|_| uri == ACTIVE_PROJECT_CONTEXT_URI) + else { + let code = if revision == ProtocolRevision::V2026_07_28 { + -32602 + } else { + -32002 + }; + return JsonRpcResponse::err_with_data( + id, + code, + "resource not found".into(), + Some(json!({ "uri": uri })), + ); + }; + + let text = match active_project_context(self.store, project) { + Ok(text) => text, + Err(error) => { + tracing::warn!(%error, "failed to read active-project MCP resource"); + return JsonRpcResponse::err(id, -32603, "failed to read resource".into()); + } + }; + let value = json!({ + "contents": [{ + "uri": ACTIVE_PROJECT_CONTEXT_URI, + "mimeType": ACTIVE_PROJECT_CONTEXT_MIME_TYPE, + "text": text, + }] + }); + let result = if revision == ProtocolRevision::V2026_07_28 { + project_result(revision, value, Some((0, "private"))) + } else { + let mut value = value; + value["_meta"] = json!({ "ttlMs": 0, "cacheScope": "private" }); + value + }; + JsonRpcResponse::ok(id, result) + } + fn list_tools( &self, id: Value, @@ -1363,6 +1457,152 @@ fn trim_ows(value: &str) -> &str { value.trim_matches(|character| matches!(character, ' ' | '\t')) } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ActiveProjectContext { + project: String, + topics: Vec, + memories: Vec, + truncated: bool, + truncation_reasons: Vec<&'static str>, + omitted_at_least: usize, + budget: ResourceBudget, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ResourceMemory { + id: String, + topic: String, + summary: String, + importance: String, + weight: f32, + updated_at: String, + field_truncated: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ResourceBudget { + max_portable_tokens: usize, + used_portable_tokens: usize, + algorithm: &'static str, +} + +fn active_project_context(store: &Store, project: &str) -> IcmResult { + let mut context = empty_resource_context(project); + let topic_refs = context + .topics + .iter() + .map(String::as_str) + .collect::>(); + let fetched = store.get_by_topics_limited(&topic_refs, RESOURCE_ROW_LIMIT + 1)?; + let fetched_count = fetched.len(); + let memories = fetched + .into_iter() + .take(RESOURCE_ROW_LIMIT) + .map(resource_memory) + .collect::>(); + let field_limited = memories.iter().any(|memory| memory.field_truncated); + let row_limited = fetched_count > RESOURCE_ROW_LIMIT; + let mut truncation_reasons = Vec::new(); + if row_limited { + truncation_reasons.push("rowLimit"); + } + if field_limited { + truncation_reasons.push("fieldLimit"); + } + context.omitted_at_least = fetched_count.saturating_sub(memories.len()); + context.truncated = !truncation_reasons.is_empty(); + context.truncation_reasons = truncation_reasons; + context.memories = memories; + + loop { + let text = serialize_resource_context(&mut context)?; + if text.len() <= RESOURCE_MAX_BYTES { + return Ok(text); + } + if !context.truncation_reasons.contains(&"tokenBudget") { + context.truncation_reasons.push("tokenBudget"); + } + context.truncated = true; + if context.memories.pop().is_none() { + return Err(IcmError::InvalidInput( + "active-project resource metadata exceeds its fixed budget".into(), + )); + } + context.omitted_at_least = fetched_count.saturating_sub(context.memories.len()); + } +} + +fn empty_resource_context(project: &str) -> ActiveProjectContext { + ActiveProjectContext { + project: project.into(), + topics: vec![ + format!("context-{project}"), + format!("contexte-{project}"), + format!("decisions-{project}"), + ], + memories: Vec::new(), + truncated: false, + truncation_reasons: Vec::new(), + omitted_at_least: 0, + budget: ResourceBudget { + max_portable_tokens: RESOURCE_MAX_BYTES, + used_portable_tokens: 0, + algorithm: "utf8-bytes-v1", + }, + } +} + +fn resource_memory(memory: Memory) -> ResourceMemory { + let (id, id_truncated) = truncate_resource_field(&memory.id); + let (summary, summary_truncated) = truncate_resource_field(&memory.summary); + ResourceMemory { + id, + topic: memory.topic, + summary, + importance: memory.importance.to_string(), + weight: memory.weight, + updated_at: memory.updated_at.to_rfc3339(), + field_truncated: id_truncated || summary_truncated, + } +} + +fn truncate_resource_field(value: &str) -> (String, bool) { + if value.len() <= RESOURCE_FIELD_BYTES { + return (value.into(), false); + } + let mut end = RESOURCE_FIELD_BYTES; + while !value.is_char_boundary(end) { + end -= 1; + } + (value[..end].into(), true) +} + +fn serialize_resource_context(context: &mut ActiveProjectContext) -> IcmResult { + loop { + let text = serde_json::to_string_pretty(context)?; + let used = text.len(); + if context.budget.used_portable_tokens == used { + return Ok(text); + } + context.budget.used_portable_tokens = used; + } +} + +fn valid_resource_project(project: &str) -> bool { + if project.is_empty() + || project.len() > 246 + || project.trim() != project + || project.chars().any(char::is_control) + { + return false; + } + serialize_resource_context(&mut empty_resource_context(project)) + .is_ok_and(|text| text.len() <= RESOURCE_MAX_BYTES) +} + fn server_info() -> Value { json!({ "name": SERVER_NAME, "version": SERVER_VERSION }) } @@ -2549,11 +2789,27 @@ mod tests { } #[test] - fn resource_core_is_honest_and_empty() { + fn active_project_resource_is_fixed_scoped_and_bounded() { let store = Store::in_memory().unwrap(); - let service = service(&store); + for index in 0..66 { + let mut memory = Memory::new( + "context-test-project".into(), + format!( + "row {index:02} prompt boundary\n--- RESOURCE-FORGE --- {}", + "bounded ".repeat(90) + ), + Importance::High, + ); + memory.id = format!("01R{index:023}"); + memory.weight = 1.0 - index as f32 / 1_000.0; + memory.access_count = 2; + store.store(memory).unwrap(); + } + + let mut service = service(&store); + service.active_project = Some("test-project".into()); let mut state = ConnectionState::default(); - let response = service + let listed = service .handle( &mut state, request(json!({ @@ -2565,7 +2821,75 @@ mod tests { })), ) .unwrap(); - assert_eq!(response.result.unwrap()["resources"], json!([])); + let listed = listed.result.unwrap(); + assert_eq!(listed["ttlMs"], 3_600_000); + assert_eq!(listed["cacheScope"], "private"); + assert_eq!(listed["resources"][0]["uri"], ACTIVE_PROJECT_CONTEXT_URI); + assert_eq!(listed["resources"][0]["mimeType"], "application/json"); + + let mut state_2025 = + initialized_state_for_revision(&service, ProtocolRevision::V2025_11_25); + let listed_2025 = service + .handle( + &mut state_2025, + request(json!({ + "jsonrpc":"2.0","id":2,"method":"resources/list","params":{} + })), + ) + .unwrap() + .result + .unwrap(); + assert_eq!(listed_2025["_meta"]["ttlMs"], 0); + assert_eq!(listed_2025["_meta"]["cacheScope"], "private"); + + let read = service + .handle( + &mut state, + request(json!({ + "jsonrpc":"2.0","id":2,"method":"resources/read", + "params":{"uri":ACTIVE_PROJECT_CONTEXT_URI,"_meta":modern_metadata()} + })), + ) + .unwrap() + .result + .unwrap(); + assert_eq!(read["ttlMs"], 0); + assert_eq!(read["cacheScope"], "private"); + let text = read["contents"][0]["text"].as_str().unwrap(); + assert!(text.len() <= RESOURCE_MAX_BYTES); + assert!(text.contains("\\n--- RESOURCE-FORGE")); + let context: Value = serde_json::from_str(text).unwrap(); + assert_eq!(context["project"], "test-project"); + assert_eq!( + context["topics"], + json!([ + "context-test-project", + "contexte-test-project", + "decisions-test-project" + ]) + ); + assert_eq!(context["budget"]["usedPortableTokens"], text.len()); + assert_eq!(context["budget"]["algorithm"], "utf8-bytes-v1"); + assert_eq!(context["truncated"], true); + for reason in ["rowLimit", "fieldLimit", "tokenBudget"] { + assert!(context["truncationReasons"] + .as_array() + .unwrap() + .contains(&json!(reason))); + } + assert!(context["memories"] + .as_array() + .unwrap() + .iter() + .any(|memory| memory["fieldTruncated"] == true)); + assert_eq!( + store + .get("01R00000000000000000000000") + .unwrap() + .unwrap() + .access_count, + 2 + ); } #[test] From 69b5a302098bdf3ef9af28774d5bd1ed6a9a96dc Mon Sep 17 00:00:00 2001 From: Lauritz-Timm Date: Sun, 9 Aug 2026 23:03:33 +0200 Subject: [PATCH 3/3] test(mcp): cover project resource boundaries --- crates/icm-mcp/src/service.rs | 104 ++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/crates/icm-mcp/src/service.rs b/crates/icm-mcp/src/service.rs index 97b8a1c..c292548 100644 --- a/crates/icm-mcp/src/service.rs +++ b/crates/icm-mcp/src/service.rs @@ -2892,6 +2892,110 @@ mod tests { ); } + #[test] + fn active_project_resource_is_empty_and_cross_project_isolated_in_2025() { + let store = Store::in_memory().unwrap(); + let mut service = service(&store); + service.active_project = Some("test-project".into()); + let mut state = initialized_state_for_revision(&service, ProtocolRevision::V2025_11_25); + + let read = |service: &McpService<'_>, state: &mut ConnectionState, id| { + service + .handle( + state, + request(json!({ + "jsonrpc":"2.0","id":id,"method":"resources/read", + "params":{"uri":ACTIVE_PROJECT_CONTEXT_URI} + })), + ) + .unwrap() + .result + .unwrap() + }; + let empty = read(&service, &mut state, 2); + assert_eq!(empty["_meta"], json!({"ttlMs":0,"cacheScope":"private"})); + let context: Value = + serde_json::from_str(empty["contents"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(context["memories"], json!([])); + assert_eq!(context["truncated"], false); + + for (topic, summary) in [ + ("context-test-project", "included"), + ("context-other-project", "excluded project"), + ("preferences", "excluded global"), + ] { + store + .store(Memory::new(topic.into(), summary.into(), Importance::High)) + .unwrap(); + } + let populated = read(&service, &mut state, 3); + let context: Value = + serde_json::from_str(populated["contents"][0]["text"].as_str().unwrap()).unwrap(); + let memories = context["memories"].as_array().unwrap(); + assert_eq!(memories.len(), 1); + assert_eq!(memories[0]["summary"], "included"); + } + + #[test] + fn active_project_resource_rejects_bad_uris_and_hides_internal_failures() { + let store = Store::in_memory().unwrap(); + let mut service = service(&store); + service.active_project = Some("test-project".into()); + + let mut legacy = initialized_state_for_revision(&service, ProtocolRevision::V2024_11_05); + let unavailable = service + .handle( + &mut legacy, + request(json!({ + "jsonrpc":"2.0","id":2,"method":"resources/read", + "params":{"uri":ACTIVE_PROJECT_CONTEXT_URI} + })), + ) + .unwrap(); + assert_eq!(unavailable.error.unwrap().code, -32601); + + for (id, mut params) in [ + (3, json!({})), + (4, json!({"uri":42})), + ( + 5, + json!({"uri":"icm://active-project/context?unexpected=1"}), + ), + ] { + params + .as_object_mut() + .unwrap() + .insert("_meta".into(), modern_metadata()); + let mut modern = ConnectionState::default(); + let rejected = service + .handle( + &mut modern, + request(json!({ + "jsonrpc":"2.0","id":id,"method":"resources/read", + "params":params + })), + ) + .unwrap(); + assert_eq!(rejected.error.unwrap().code, -32602); + } + + service.active_project = Some("x".repeat(RESOURCE_MAX_BYTES)); + let mut modern = ConnectionState::default(); + let failed = service + .handle( + &mut modern, + request(json!({ + "jsonrpc":"2.0","id":6,"method":"resources/read", + "params":{"uri":ACTIVE_PROJECT_CONTEXT_URI,"_meta":modern_metadata()} + })), + ) + .unwrap(); + let error = failed.error.unwrap(); + assert_eq!(error.code, -32603); + assert_eq!(error.message, "failed to read resource"); + assert!(error.data.is_none()); + } + #[test] fn explicit_working_directory_scopes_default_recall() { let tmp = tempfile::tempdir().unwrap();