diff --git a/TASKS.md b/TASKS.md index b0b63c4..a7d4fdc 100644 --- a/TASKS.md +++ b/TASKS.md @@ -61,9 +61,9 @@ Progress: - admin-api and mcp-server ingress rate limiting now use shared external cache state when `CRANK_CACHE_BACKEND` is `valkey` or `redis` - `ExecutionConfig.response_cache` is now part of the public operation model, and `admin-api` validates the safe Community baseline: only explicit `REST GET` operations without `auth_profile_ref` - cache boundary and namespace rules are now documented for `platform / coordination cache` versus `response cache` + - runtime executor now performs actual response cache reads/writes for eligible `REST GET` operations with keys isolated by `workspace + agent + operation + request fingerprint` - pending: - - preserve strict cache isolation between workspace/agent/operation scopes while wiring concrete cache-key builders into runtime hot paths - - wire actual response cache reads/writes into concrete runtime execution paths + - preserve the same isolation model while extending response cache beyond the first `REST GET` hot path - wire replay guard / coordination state into additional concrete runtime and transport paths ## Planned diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 61cacd7..b0747f6 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -66,7 +66,8 @@ async fn main() -> Result<(), Box> { let cache_stores = RuntimeCacheStores::from_config(&cache_config).await?; let api_rate_limit = admin_api_rate_limit_config_from_env()?; let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; - let runtime = RuntimeExecutor::with_limits(runtime_limits); + let runtime = RuntimeExecutor::with_limits(runtime_limits) + .with_response_cache_store(cache_stores.response.clone()); let service = AdminService::new_with_runtime( registry, storage_root, diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 597a92f..71bab9f 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -702,7 +702,11 @@ async fn handle_base_tool_call( transport_request_id: &str, ) -> Response { let operation = runtime_operation(&tool); - let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id); + let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id) + .with_response_cache_scope( + tool.workspace_id.as_str().to_owned(), + tool.agent_id.as_str().to_owned(), + ); let request_preview = build_request_preview(&state.runtime, &operation, &arguments); let started_at = Instant::now(); let is_window_mode = matches!( @@ -813,7 +817,11 @@ async fn handle_session_start_call( transport_request_id: &str, ) -> Response { let runtime_operation = runtime_operation(&tool); - let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id); + let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id) + .with_response_cache_scope( + tool.workspace_id.as_str().to_owned(), + tool.agent_id.as_str().to_owned(), + ); let request_preview = build_request_preview(&state.runtime, &runtime_operation, &arguments); let started_at = Instant::now(); let Some(streaming) = runtime_operation.execution_config.streaming.as_ref() else { @@ -1240,7 +1248,11 @@ async fn handle_async_job_start_call( transport_request_id: &str, ) -> Response { let operation = runtime_operation(&tool); - let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id); + let runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id) + .with_response_cache_scope( + tool.workspace_id.as_str().to_owned(), + tool.agent_id.as_str().to_owned(), + ); let request_preview = build_request_preview(&state.runtime, &operation, &arguments); let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else { return tool_error_response( diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 42502ec..1ded0ed 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -54,7 +54,8 @@ async fn main() -> Result<(), Box> { ) .await?; let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; - let runtime = RuntimeExecutor::with_limits(runtime_limits); + let runtime = RuntimeExecutor::with_limits(runtime_limits) + .with_response_cache_store(cache_stores.response.clone()); let app = build_app( registry, refresh_interval, @@ -3043,6 +3044,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, @@ -3109,6 +3111,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, @@ -3176,6 +3179,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, diff --git a/crates/crank-core/src/operation.rs b/crates/crank-core/src/operation.rs index 0a31ab0..8dddfe1 100644 --- a/crates/crank-core/src/operation.rs +++ b/crates/crank-core/src/operation.rs @@ -413,6 +413,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 10_000, retry_policy: None, + response_cache: None, auth_profile_ref: Some(AuthProfileId::new("auth_01")), headers: BTreeMap::new(), protocol_options: Some(ProtocolOptions { @@ -502,6 +503,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 10_000, retry_policy: None, + response_cache: None, auth_profile_ref: Some(AuthProfileId::new("auth_01")), headers: BTreeMap::new(), protocol_options: Some(ProtocolOptions::default()), diff --git a/crates/crank-runtime/src/executor.rs b/crates/crank-runtime/src/executor.rs index bdd039f..7f96661 100644 --- a/crates/crank-runtime/src/executor.rs +++ b/crates/crank-runtime/src/executor.rs @@ -1,13 +1,19 @@ use std::collections::BTreeMap; use std::sync::Arc; +use std::time::Duration; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_adapter_graphql::{GraphqlAdapter, GraphqlRequest}; use crank_adapter_grpc::{GrpcAdapter, GrpcRequest, GrpcWindowRequest}; use crank_adapter_rest::{RestAdapter, RestRequest, RestWindowRequest}; use crank_adapter_soap::{SoapAdapter, SoapRequest}; use crank_adapter_websocket::{WebsocketAdapter, WebsocketWindowRequest}; -use crank_core::{ExecutionMode, Target, TransportBehavior}; +use crank_core::{ + CachedHeader, CachedResponse, ExecutionMode, HttpMethod, ResponseCacheStore, Target, + TransportBehavior, +}; use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tracing::debug; @@ -16,7 +22,7 @@ use crate::{ RuntimeRequestContext, WindowExecutionResult, }; -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct RuntimeExecutor { graphql_adapter: GraphqlAdapter, grpc_adapter: GrpcAdapter, @@ -28,6 +34,7 @@ pub struct RuntimeExecutor { window_limiter: Arc, session_limiter: Arc, job_limiter: Arc, + response_cache: Option>, } impl Default for RuntimeExecutor { @@ -53,9 +60,18 @@ impl RuntimeExecutor { session_limiter: Arc::new(Semaphore::new(limits.max_concurrent_sessions)), job_limiter: Arc::new(Semaphore::new(limits.max_concurrent_jobs)), limits, + response_cache: None, } } + pub fn with_response_cache_store( + mut self, + response_cache: Arc, + ) -> Self { + self.response_cache = Some(response_cache); + self + } + pub async fn execute( &self, operation: &RuntimeOperation, @@ -260,9 +276,25 @@ impl RuntimeExecutor { prepared_request: PreparedRequest, request_context: Option<&RuntimeRequestContext>, ) -> Result { - let adapter_response = self - .execute_adapter(operation, prepared_request, request_context) - .await?; + let adapter_response = match self + .load_cached_adapter_response(operation, &prepared_request, request_context) + .await + { + Some(response) => response, + None => { + let adapter_response = self + .execute_adapter(operation, prepared_request.clone(), request_context) + .await?; + self.store_cached_adapter_response( + operation, + &prepared_request, + request_context, + &adapter_response, + ) + .await; + adapter_response + } + }; let finalized_output = finalize_output(operation, &adapter_response)?; operation.output_schema.validate_shape(&finalized_output)?; @@ -543,6 +575,81 @@ impl RuntimeExecutor { self.limits.max_concurrent_sessions, ) } + + async fn load_cached_adapter_response( + &self, + operation: &RuntimeOperation, + prepared_request: &PreparedRequest, + request_context: Option<&RuntimeRequestContext>, + ) -> Option { + let response_cache = self.response_cache.as_ref()?; + let cache_key = response_cache_key(operation, prepared_request, request_context)?; + let cached = match response_cache.get(&cache_key).await { + Ok(cached) => cached?, + Err(error) => { + debug!( + operation_id = %operation.operation_id, + cache_key, + error = %error, + "response cache lookup skipped" + ); + return None; + } + }; + + match adapter_response_from_cached(cached) { + Ok(response) => Some(response), + Err(error) => { + debug!( + operation_id = %operation.operation_id, + cache_key, + error, + "cached response payload was invalid" + ); + let _ = response_cache.delete(&cache_key).await; + None + } + } + } + + async fn store_cached_adapter_response( + &self, + operation: &RuntimeOperation, + prepared_request: &PreparedRequest, + request_context: Option<&RuntimeRequestContext>, + adapter_response: &AdapterResponse, + ) { + let Some(response_cache) = self.response_cache.as_ref() else { + return; + }; + + if !(200..=299).contains(&adapter_response.status_code) { + return; + } + + let Some(cache_ttl) = response_cache_ttl(operation) else { + return; + }; + let Some(cache_key) = response_cache_key(operation, prepared_request, request_context) + else { + return; + }; + let Some(cached_response) = cached_response_from_adapter(adapter_response) else { + return; + }; + + if let Err(error) = response_cache + .put(&cache_key, cached_response, cache_ttl) + .await + { + debug!( + operation_id = %operation.operation_id, + cache_key, + error = %error, + "response cache write skipped" + ); + } + } } impl PreparedRequest { @@ -649,6 +756,80 @@ fn log_runtime_event( ); } +fn response_cache_ttl(operation: &RuntimeOperation) -> Option { + if !matches!( + &operation.target, + Target::Rest(target) if target.method == HttpMethod::Get + ) { + return None; + } + + let policy = operation.execution_config.response_cache.as_ref()?; + if operation.execution_config.auth_profile_ref.is_some() || policy.ttl_ms == 0 { + return None; + } + + Some(Duration::from_millis(policy.ttl_ms)) +} + +fn response_cache_key( + operation: &RuntimeOperation, + prepared_request: &PreparedRequest, + request_context: Option<&RuntimeRequestContext>, +) -> Option { + let _ = response_cache_ttl(operation)?; + let scope = request_context?.response_cache_scope()?; + let fingerprint = json!({ + "path_params": prepared_request.path_params, + "query_params": prepared_request.query_params, + "headers": prepared_request.headers, + "body": prepared_request.body, + }); + let fingerprint_bytes = serde_json::to_vec(&fingerprint).ok()?; + let fingerprint_hash = URL_SAFE_NO_PAD.encode(Sha256::digest(fingerprint_bytes)); + + Some(format!( + "crank:response:workspace:{}:agent:{}:operation:{}:request:{}", + scope.workspace_key, + scope.agent_key, + operation.operation_id.as_str(), + fingerprint_hash + )) +} + +fn cached_response_from_adapter(adapter_response: &AdapterResponse) -> Option { + let body = serde_json::to_vec(&adapter_response.body).ok()?; + Some(CachedResponse { + status: adapter_response.status_code, + headers: adapter_response + .headers + .iter() + .map(|(name, value)| CachedHeader { + name: name.clone(), + value: value.clone(), + }) + .collect(), + body, + }) +} + +fn adapter_response_from_cached( + cached_response: CachedResponse, +) -> Result { + let body: Value = + serde_json::from_slice(&cached_response.body).map_err(|error| error.to_string())?; + Ok(AdapterResponse { + status_code: cached_response.status, + headers: cached_response + .headers + .into_iter() + .map(|header| (header.name, header.value)) + .collect(), + body, + data: Value::Null, + }) +} + fn read_string_map( value: Option<&Value>, field_name: &str, @@ -706,13 +887,17 @@ fn non_empty_payload(value: Option) -> Option { mod tests { use std::collections::BTreeMap; use std::io; - use std::sync::{Arc, Mutex}; + use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }; use axum::{ Json, Router, + extract::Query, http::HeaderMap, response::sse::{Event, KeepAlive, Sse}, - routing::post, + routing::{get, post}, }; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ @@ -736,8 +921,8 @@ mod tests { use tracing_subscriber::{filter::LevelFilter, fmt::MakeWriter, prelude::*}; use crate::{ - PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeLimits, RuntimeOperation, - RuntimeRequestContext, + InMemoryResponseCacheStore, PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeLimits, + RuntimeOperation, RuntimeRequestContext, }; fn timestamp(value: &str) -> OffsetDateTime { @@ -817,6 +1002,66 @@ mod tests { assert_eq!(output, json!({ "id": "req_rest_123|corr_rest_123" })); } + #[tokio::test] + async fn caches_rest_get_responses_within_agent_scope() { + let request_count = Arc::new(AtomicUsize::new(0)); + let base_url = spawn_cached_rest_server(Arc::clone(&request_count)).await; + let executor = RuntimeExecutor::new() + .with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default())); + let operation = test_cached_rest_get_operation(&base_url); + let first_context = RuntimeRequestContext::from_request_id("req_cache_1") + .with_response_cache_scope("ws_cache", "agent_sales"); + let second_context = RuntimeRequestContext::from_request_id("req_cache_2") + .with_response_cache_scope("ws_cache", "agent_sales"); + + let first_output = executor + .execute_with_context(&operation, &json!({ "city": "msk" }), Some(&first_context)) + .await + .unwrap(); + let second_output = executor + .execute_with_context(&operation, &json!({ "city": "msk" }), Some(&second_context)) + .await + .unwrap(); + + assert_eq!(first_output, json!({ "city": "msk", "request_count": 1 })); + assert_eq!(second_output, json!({ "city": "msk", "request_count": 1 })); + assert_eq!(request_count.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn isolates_rest_get_cache_between_agents() { + let request_count = Arc::new(AtomicUsize::new(0)); + let base_url = spawn_cached_rest_server(Arc::clone(&request_count)).await; + let executor = RuntimeExecutor::new() + .with_response_cache_store(Arc::new(InMemoryResponseCacheStore::default())); + let operation = test_cached_rest_get_operation(&base_url); + let agent_a_context = RuntimeRequestContext::from_request_id("req_cache_agent_a") + .with_response_cache_scope("ws_cache", "agent_a"); + let agent_b_context = RuntimeRequestContext::from_request_id("req_cache_agent_b") + .with_response_cache_scope("ws_cache", "agent_b"); + + let first_output = executor + .execute_with_context( + &operation, + &json!({ "city": "msk" }), + Some(&agent_a_context), + ) + .await + .unwrap(); + let second_output = executor + .execute_with_context( + &operation, + &json!({ "city": "msk" }), + Some(&agent_b_context), + ) + .await + .unwrap(); + + assert_eq!(first_output, json!({ "city": "msk", "request_count": 1 })); + assert_eq!(second_output, json!({ "city": "msk", "request_count": 2 })); + assert_eq!(request_count.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn emits_runtime_tracing_with_request_context() { let base_url = spawn_runtime_server().await; @@ -1295,6 +1540,34 @@ mod tests { format!("http://{}", address) } + async fn spawn_cached_rest_server(request_count: Arc) -> String { + let app = Router::new().route( + "/catalog", + get(move |Query(params): Query>| { + let request_count = Arc::clone(&request_count); + async move { + let count = request_count.fetch_add(1, Ordering::SeqCst) + 1; + let city = params.get("city").cloned().unwrap_or_default(); + ( + axum::http::StatusCode::OK, + Json(json!({ + "city": city, + "request_count": count, + })), + ) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + async fn spawn_graphql_server() -> String { let app = Router::new().route("/", post(graphql_handler)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -1680,6 +1953,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, @@ -1757,6 +2031,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, @@ -1833,6 +2108,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, @@ -1903,6 +2179,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, @@ -2001,6 +2278,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, @@ -2080,6 +2358,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, @@ -2184,6 +2463,7 @@ mod tests { execution_config: ExecutionConfig { timeout_ms: 1_000, retry_policy: None, + response_cache: None, auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: Some(ProtocolOptions { @@ -2275,6 +2555,122 @@ mod tests { operation } + fn test_cached_rest_get_operation(base_url: &str) -> RuntimeOperation { + RuntimeOperation::from(Operation { + id: OperationId::new("op_rest_cache_runtime"), + name: "catalog_lookup".to_owned(), + display_name: "Catalog Lookup".to_owned(), + category: "lookup".to_owned(), + protocol: Protocol::Rest, + security_level: OperationSecurityLevel::Standard, + status: OperationStatus::Published, + version: 1, + target: Target::Rest(RestTarget { + base_url: base_url.to_owned(), + method: HttpMethod::Get, + path_template: "/catalog".to_owned(), + static_headers: BTreeMap::new(), + }), + input_schema: object_schema("city", SchemaKind::String), + output_schema: Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([ + ( + "city".to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + ), + ( + "request_count".to_owned(), + Schema { + kind: SchemaKind::Number, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + ), + ]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.city".to_owned(), + target: "$.request.query.city".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![ + MappingRule { + source: "$.response.body.city".to_owned(), + target: "$.output.city".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }, + MappingRule { + source: "$.response.body.request_count".to_owned(), + target: "$.output.request_count".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }, + ], + }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + response_cache: Some(crank_core::ResponseCachePolicy { ttl_ms: 60_000 }), + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Catalog Lookup".to_owned(), + description: "Loads a read-only catalog item".to_owned(), + tags: vec!["lookup".to_owned()], + examples: vec![ToolExample { + input: json!({ "city": "msk" }), + }], + }, + samples: Some(Samples::default()), + generated_draft: None, + config_export: None, + created_at: timestamp("2026-04-06T12:00:00Z"), + updated_at: timestamp("2026-04-06T12:00:00Z"), + published_at: Some(timestamp("2026-04-06T12:00:00Z")), + }) + } + fn test_session_seed_operation(base_url: &str) -> RuntimeOperation { let mut operation = test_window_snapshot_operation(base_url, AggregationMode::RawItems, Some(10), None); diff --git a/crates/crank-runtime/src/lib.rs b/crates/crank-runtime/src/lib.rs index 3d09949..1f0f74d 100644 --- a/crates/crank-runtime/src/lib.rs +++ b/crates/crank-runtime/src/lib.rs @@ -24,6 +24,6 @@ pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation}; pub use rate_limit::{ RateLimitRejection, RequestRateLimitConfig, RequestRateLimitConfigError, RequestRateLimiter, }; -pub use request_context::RuntimeRequestContext; +pub use request_context::{ResponseCacheScope, RuntimeRequestContext}; pub use secret_crypto::SecretCrypto; pub use streaming::WindowExecutionResult; diff --git a/crates/crank-runtime/src/request_context.rs b/crates/crank-runtime/src/request_context.rs index d697b67..b292dc0 100644 --- a/crates/crank-runtime/src/request_context.rs +++ b/crates/crank-runtime/src/request_context.rs @@ -1,9 +1,16 @@ use std::collections::BTreeMap; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResponseCacheScope { + pub workspace_key: String, + pub agent_key: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeRequestContext { pub request_id: String, pub correlation_id: String, + pub response_cache_scope: Option, } impl RuntimeRequestContext { @@ -11,6 +18,7 @@ impl RuntimeRequestContext { Self { request_id: request_id.into(), correlation_id: correlation_id.into(), + response_cache_scope: None, } } @@ -25,6 +33,22 @@ impl RuntimeRequestContext { ("x-correlation-id".to_owned(), self.correlation_id.clone()), ]) } + + pub fn with_response_cache_scope( + mut self, + workspace_key: impl Into, + agent_key: impl Into, + ) -> Self { + self.response_cache_scope = Some(ResponseCacheScope { + workspace_key: workspace_key.into(), + agent_key: agent_key.into(), + }); + self + } + + pub fn response_cache_scope(&self) -> Option<&ResponseCacheScope> { + self.response_cache_scope.as_ref() + } } #[cfg(test)] @@ -37,5 +61,16 @@ mod tests { assert_eq!(context.request_id, "req_123"); assert_eq!(context.correlation_id, "req_123"); + assert!(context.response_cache_scope.is_none()); + } + + #[test] + fn attaches_response_cache_scope() { + let context = RuntimeRequestContext::from_request_id("req_123") + .with_response_cache_scope("ws_01", "agent_01"); + + let scope = context.response_cache_scope().unwrap(); + assert_eq!(scope.workspace_key, "ws_01"); + assert_eq!(scope.agent_key, "agent_01"); } } diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 4cad8c1..e60b632 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -237,6 +237,11 @@ Crank должен корректно работать, если MCP client де - `POST` и `GET` transport semantics соответствуют MCP spec `2025-06-18`; - endpoint определяется парой `workspace + agent`; - одна published operation = один MCP tool внутри agent; +- если операция явно включает `execution_config.response_cache`, первый поддержанный runtime path ограничен: + - `REST` + - `GET` + - без `auth_profile_ref` + - cache keys изолируются минимум по `workspace + agent + operation + request fingerprint` - streaming operations публикуются как bounded tools или tool families; - reload published tools без пересборки сервиса; - никакой draft-логики или admin CRUD в MCP слое.