cache: wire rest get response caching

This commit is contained in:
a.tolmachev
2026-05-03 22:32:30 +00:00
parent 851d70ad7a
commit 284ba76b6b
9 changed files with 472 additions and 17 deletions
+2 -2
View File
@@ -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
+2 -1
View File
@@ -66,7 +66,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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,
+15 -3
View File
@@ -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(
+5 -1
View File
@@ -54,7 +54,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
)
.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,
+2
View File
@@ -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()),
+405 -9
View File
@@ -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<Semaphore>,
session_limiter: Arc<Semaphore>,
job_limiter: Arc<Semaphore>,
response_cache: Option<Arc<dyn ResponseCacheStore>>,
}
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<dyn ResponseCacheStore>,
) -> 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<Value, RuntimeError> {
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<AdapterResponse> {
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<Duration> {
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<String> {
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<CachedResponse> {
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<AdapterResponse, String> {
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<Value>) -> Option<Value> {
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<AtomicUsize>) -> String {
let app = Router::new().route(
"/catalog",
get(move |Query(params): Query<BTreeMap<String, String>>| {
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);
+1 -1
View File
@@ -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;
@@ -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<ResponseCacheScope>,
}
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<String>,
agent_key: impl Into<String>,
) -> 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");
}
}
+5
View File
@@ -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 слое.