Skip to content

Commit 5de93d5

Browse files
committed
fix(gateway): quiet log noise, cache resolve_feature_sets, warn on empty workspace header
Default log level drops from per-crate debug to info (RUST_LOG still overrides). resolve_feature_sets gets a per-(space, feature_set_ids) cache invalidated via DomainEvent::affects_mcp_capabilities(), removing a hot-path re-resolve on every tools/list. Present-but-empty X-Mcpmux-Workspace headers now warn instead of silently no-oping the pin, with docs pointing at the Cursor Agents window / ${workspaceFolder} cause. Adds the Aug 14 gateway ops bugs planning doc cataloging the remaining open issues (SIGTERM, session lifecycle noise, startup hygiene) and locking decisions for the next pass. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 0d784c8 commit 5de93d5

14 files changed

Lines changed: 484 additions & 65 deletions

File tree

apps/desktop/src-tauri/src/lib.rs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,17 +80,11 @@ fn init_tracing() -> tracing_appender::non_blocking::WorkerGuard {
8080
.expect("Failed to create log file appender");
8181
let (non_blocking_file, guard) = tracing_appender::non_blocking(file_appender);
8282

83-
// Environment filter for log levels
84-
// RUST_LOG takes precedence, with sensible defaults for our crates
85-
// Note: Rust crate names use underscores in tracing (e.g., mcpmux-core → mcpmux_core)
83+
// RUST_LOG / .env wins. Default is info; set e.g. RUST_LOG=mcpmux_gateway=debug
84+
// to opt a crate back into debug. Crate names use underscores in tracing
85+
// (mcpmux-core → mcpmux_core).
8686
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
87-
// Default filter when RUST_LOG is not set
8887
EnvFilter::new("info")
89-
.add_directive("mcpmux_core=debug".parse().unwrap())
90-
.add_directive("mcpmux_gateway=debug".parse().unwrap())
91-
.add_directive("mcpmux_storage=debug".parse().unwrap())
92-
.add_directive("mcpmux_mcp=debug".parse().unwrap())
93-
.add_directive("mcpmux_lib=debug".parse().unwrap())
9488
.add_directive("tauri=info".parse().unwrap())
9589
.add_directive("tao=warn".parse().unwrap())
9690
.add_directive("wry=warn".parse().unwrap())

crates/mcpmux-core/src/application/server.rs

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -402,14 +402,11 @@ impl ServerAppService {
402402
.get_definition()
403403
.ok_or_else(|| anyhow!("Server has no cached definition"))?;
404404

405-
let user_entry: UserServerEntry = serde_json::from_value(entry)
406-
.map_err(|e| anyhow!("Invalid server entry: {}", e))?;
405+
let user_entry: UserServerEntry =
406+
serde_json::from_value(entry).map_err(|e| anyhow!("Invalid server entry: {}", e))?;
407407

408-
let mut definition = user_entry.to_server_definition(
409-
server_id,
410-
&space_id_str,
411-
std::path::PathBuf::new(),
412-
);
408+
let mut definition =
409+
user_entry.to_server_definition(server_id, &space_id_str, std::path::PathBuf::new());
413410

414411
definition.id = existing.id.clone();
415412
definition.source = ServerSource::ManualEntry;
@@ -847,9 +844,7 @@ mod tests {
847844
name: "PostHog Personal".to_string(),
848845
description: None,
849846
alias: Some("posthog".to_string()),
850-
auth: Some(AuthConfig::ApiKey {
851-
instructions: None,
852-
}),
847+
auth: Some(AuthConfig::ApiKey { instructions: None }),
853848
icon: None,
854849
transport: TransportConfig::Http {
855850
url: "https://mcp.posthog.com/mcp".to_string(),
@@ -898,15 +893,10 @@ mod tests {
898893
"Authorization".to_string(),
899894
"Bearer phx_parent_token".to_string(),
900895
),
901-
(
902-
"x-posthog-project-id".to_string(),
903-
"345911".to_string(),
904-
),
896+
("x-posthog-project-id".to_string(), "345911".to_string()),
905897
]);
906-
let parent_inputs = HashMap::from([(
907-
"POSTHOG_API_KEY".to_string(),
908-
"phc_parent_key".to_string(),
909-
)]);
898+
let parent_inputs =
899+
HashMap::from([("POSTHOG_API_KEY".to_string(), "phc_parent_key".to_string())]);
910900

911901
let definition = user_space_http_definition("posthog-personal");
912902
let mut source = InstalledServer::new(space_id.to_string(), "posthog-personal")
@@ -918,12 +908,7 @@ mod tests {
918908
source.extra_headers = parent_headers.clone();
919909
repo.seed(source).await;
920910

921-
let service = ServerAppService::new(
922-
repo.clone(),
923-
None,
924-
None,
925-
event_bus.sender(),
926-
);
911+
let service = ServerAppService::new(repo.clone(), None, None, event_bus.sender());
927912

928913
let cloned = service
929914
.clone_server(space_id, "posthog-personal", "mesh", None, None)

crates/mcpmux-core/src/application/user_space_sync.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,10 @@ mod tests {
759759
.expect("sync should update existing server");
760760
assert_eq!(result.updated, vec!["alpha".to_string()]);
761761

762-
let event = receiver.recv().await.expect("ServerConfigUpdated should emit");
762+
let event = receiver
763+
.recv()
764+
.await
765+
.expect("ServerConfigUpdated should emit");
763766
assert_eq!(event.type_name(), "server_config_updated");
764767
assert_eq!(event.server_id(), Some("alpha"));
765768
}

crates/mcpmux-gateway/src/consumers/server_config_handler.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ impl ServerConfigUpdatedHandler {
5555

5656
/// Handle one domain event, evicting the pool instance when applicable.
5757
async fn handle_event(&self, event: DomainEvent) -> anyhow::Result<()> {
58-
let DomainEvent::ServerConfigUpdated { space_id, server_id } = event else {
58+
let DomainEvent::ServerConfigUpdated {
59+
space_id,
60+
server_id,
61+
} = event
62+
else {
5963
return Ok(());
6064
};
6165

crates/mcpmux-gateway/src/mcp/oauth_middleware.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,15 @@ pub async fn mcp_oauth_middleware(
258258
(sid, ws)
259259
};
260260
match (&session_id_header, &workspace_header) {
261+
(_, Some(ws)) if ws.trim().is_empty() => {
262+
warn!(
263+
trace_id = %trace_id,
264+
session_id = session_id_header.as_deref().unwrap_or("<none>"),
265+
"[SessionRoots] X-Mcpmux-Workspace present but empty — pin skipped \
266+
(Cursor Agents window often spawns mcp-remote without resolving \
267+
${{workspaceFolder}}; see docs/manual/cursor-workspace-bridge.md Fallback)",
268+
);
269+
}
261270
(Some(sid), Some(ws)) => {
262271
services.session_roots.set_pinned(sid, ws);
263272
}

crates/mcpmux-gateway/src/pool/features/facade.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
use anyhow::Result;
44
use std::collections::HashSet;
55
use std::sync::Arc;
6+
use tokio::sync::broadcast;
67

78
use crate::pool::instance::McpClient;
89
use crate::services::PrefixCacheService;
9-
use mcpmux_core::{FeatureSetRepository, FeatureType, ServerFeature, ServerFeatureRepository};
10+
use mcpmux_core::{
11+
DomainEvent, FeatureSetRepository, FeatureType, ServerFeature, ServerFeatureRepository,
12+
};
1013

1114
use super::{
1215
CachedFeatures, FeatureDiscoveryService, FeatureResolutionService, FeatureRoutingService,
@@ -47,6 +50,12 @@ impl FeatureService {
4750
}
4851
}
4952

53+
/// Subscribe the resolution cache to DomainEvents so capability changes
54+
/// drop stale `resolve_feature_sets` entries.
55+
pub fn start_resolution_cache_invalidation(&self, event_rx: broadcast::Receiver<DomainEvent>) {
56+
self.resolution.clone().start_cache_invalidation(event_rx);
57+
}
58+
5059
// Delegate to FeatureDiscoveryService
5160
pub async fn discover_and_cache(
5261
&self,

crates/mcpmux-gateway/src/pool/features/resolution.rs

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ use anyhow::Result;
44
use std::collections::{HashMap, HashSet};
55
use std::sync::Arc;
66
use std::time::Instant;
7-
use tracing::{debug, warn};
7+
use tokio::sync::{broadcast, RwLock};
8+
use tracing::{debug, info, warn};
89

910
use crate::services::PrefixCacheService;
1011
use mcpmux_core::{
11-
FeatureSet, FeatureSetRepository, FeatureType, MemberMode, MemberType, ServerFeature,
12-
ServerFeatureRepository,
12+
DomainEvent, FeatureSet, FeatureSetRepository, FeatureType, MemberMode, MemberType,
13+
ServerFeature, ServerFeatureRepository,
1314
};
1415

1516
/// A catalog tool visible in discovery but not invokable until its FeatureSet is bound.
@@ -32,11 +33,18 @@ fn apply_mode_to_set(
3233
}
3334
}
3435

36+
/// Cache key: space + sorted FeatureSet ids. Type filter is applied after
37+
/// the hit so tools/prompts/resources share one entry.
38+
type ResolutionCacheKey = (String, Vec<String>);
39+
3540
/// Handles feature set resolution and permission evaluation
3641
pub struct FeatureResolutionService {
3742
feature_repo: Arc<dyn ServerFeatureRepository>,
3843
feature_set_repo: Arc<dyn FeatureSetRepository>,
3944
prefix_cache: Arc<PrefixCacheService>,
45+
/// Resolved (allow/exclude + prefix) features, invalidated when
46+
/// [`DomainEvent::affects_mcp_capabilities`] is true.
47+
cache: Arc<RwLock<HashMap<ResolutionCacheKey, Vec<ServerFeature>>>>,
4048
}
4149

4250
impl FeatureResolutionService {
@@ -49,9 +57,61 @@ impl FeatureResolutionService {
4957
feature_repo,
5058
feature_set_repo,
5159
prefix_cache,
60+
cache: Arc::new(RwLock::new(HashMap::new())),
5261
}
5362
}
5463

64+
/// Drop cached resolutions when a capability-changing domain event fires.
65+
///
66+
/// Uses the same [`DomainEvent::affects_mcp_capabilities`] predicate as
67+
/// [`crate::consumers::MCPNotifier`] so invalidation stays in lockstep
68+
/// with `list_changed` fanout. Space-scoped when the event carries a
69+
/// `space_id`; whole-cache drop on lag (missed events).
70+
pub fn start_cache_invalidation(
71+
self: Arc<Self>,
72+
mut event_rx: broadcast::Receiver<DomainEvent>,
73+
) {
74+
tokio::spawn(async move {
75+
info!("[FeatureResolution] cache invalidation listener started");
76+
loop {
77+
match event_rx.recv().await {
78+
Ok(event) => {
79+
if !event.affects_mcp_capabilities() {
80+
continue;
81+
}
82+
if let Some(space_id) = event.space_id() {
83+
self.invalidate_space(&space_id.to_string()).await;
84+
} else {
85+
self.invalidate_all().await;
86+
}
87+
}
88+
Err(broadcast::error::RecvError::Lagged(skipped)) => {
89+
warn!(
90+
skipped,
91+
"[FeatureResolution] lagged — dropping resolution cache"
92+
);
93+
self.invalidate_all().await;
94+
}
95+
Err(broadcast::error::RecvError::Closed) => {
96+
warn!("[FeatureResolution] event channel closed, stopping cache listener");
97+
break;
98+
}
99+
}
100+
}
101+
});
102+
}
103+
104+
async fn invalidate_space(&self, space_id: &str) {
105+
self.cache
106+
.write()
107+
.await
108+
.retain(|(cached_space, _), _| cached_space != space_id);
109+
}
110+
111+
async fn invalidate_all(&self) {
112+
self.cache.write().await.clear();
113+
}
114+
55115
/// Get all available features for a space (optionally filtered by type)
56116
pub async fn get_all_features_for_space(
57117
&self,
@@ -87,6 +147,38 @@ impl FeatureResolutionService {
87147
space_id: &str,
88148
feature_set_ids: &[String],
89149
filter_type: Option<FeatureType>,
150+
) -> Result<Vec<ServerFeature>> {
151+
let mut sorted_ids = feature_set_ids.to_vec();
152+
sorted_ids.sort();
153+
let key = (space_id.to_string(), sorted_ids);
154+
155+
if let Some(cached) = self.cache.read().await.get(&key).cloned() {
156+
return Ok(Self::apply_type_filter(cached, filter_type));
157+
}
158+
159+
// ponytail: concurrent misses recompute; single-flight if cold-start
160+
// stampede shows up.
161+
let resolved = self
162+
.resolve_feature_sets_uncached(space_id, feature_set_ids)
163+
.await?;
164+
self.cache.write().await.insert(key, resolved.clone());
165+
Ok(Self::apply_type_filter(resolved, filter_type))
166+
}
167+
168+
fn apply_type_filter(
169+
mut features: Vec<ServerFeature>,
170+
filter_type: Option<FeatureType>,
171+
) -> Vec<ServerFeature> {
172+
if let Some(feature_type) = filter_type {
173+
features.retain(|f| f.feature_type == feature_type);
174+
}
175+
features
176+
}
177+
178+
async fn resolve_feature_sets_uncached(
179+
&self,
180+
space_id: &str,
181+
feature_set_ids: &[String],
90182
) -> Result<Vec<ServerFeature>> {
91183
let mut allowed_feature_ids: HashSet<String> = HashSet::new();
92184
let mut excluded_feature_ids: HashSet<String> = HashSet::new();
@@ -144,33 +236,26 @@ impl FeatureResolutionService {
144236
excluded_feature_ids.len()
145237
);
146238

239+
let mut filtered_out = 0usize;
147240
let mut result: Vec<ServerFeature> = all_features
148241
.into_iter()
149242
.filter(|f| {
150243
let in_allowed = allowed_feature_ids.contains(&f.id.to_string());
151244
let in_excluded = excluded_feature_ids.contains(&f.id.to_string());
152245
let passes = f.is_available && in_allowed && !in_excluded;
153246
if !passes && in_allowed {
154-
debug!(
155-
"[FeatureResolution] Feature {} (server={}) filtered out: is_available={}, in_allowed={}, in_excluded={}",
156-
f.feature_name, f.server_id, f.is_available, in_allowed, in_excluded
157-
);
247+
filtered_out += 1;
158248
}
159249
passes
160250
})
161251
.collect();
162252

163253
debug!(
164-
"[FeatureResolution] After filter: {} features",
165-
result.len()
254+
"[FeatureResolution] After filter: {} features, filtered_out={}",
255+
result.len(),
256+
filtered_out
166257
);
167258

168-
// Apply type filter if specified (OCP)
169-
if let Some(feature_type) = filter_type {
170-
result.retain(|f| f.feature_type == feature_type);
171-
}
172-
173-
// Enrich with prefixes
174259
for feature in &mut result {
175260
let prefix = self
176261
.prefix_cache

crates/mcpmux-gateway/src/pool/service.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,10 @@ mod tests {
684684
Ok(vec![])
685685
}
686686

687-
async fn get(&self, _id: &Uuid) -> mcpmux_core::repository::RepoResult<Option<ServerFeature>> {
687+
async fn get(
688+
&self,
689+
_id: &Uuid,
690+
) -> mcpmux_core::repository::RepoResult<Option<ServerFeature>> {
688691
Ok(None)
689692
}
690693

crates/mcpmux-gateway/src/server/mod.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,8 +389,13 @@ impl GatewayServer {
389389
// Start listening to DomainEvents
390390
{
391391
let gw_state = tokio::task::block_in_place(|| state.blocking_read());
392-
let event_rx = gw_state.subscribe_domain_events();
393-
notification_bridge.clone().start(event_rx);
392+
notification_bridge
393+
.clone()
394+
.start(gw_state.subscribe_domain_events());
395+
self.services
396+
.pool_services
397+
.feature_service
398+
.start_resolution_cache_invalidation(gw_state.subscribe_domain_events());
394399
}
395400

396401
// Create OAuth event handler (updates oauth_connected flag on OAuth success)

crates/mcpmux-gateway/src/services/embedding_warmer.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,7 @@ impl EmbeddingWarmer {
192192
ordered_haystacks.push(haystack);
193193
}
194194

195-
let Some(vectors) = self
196-
.embeddings
197-
.embed_documents(&ordered_haystacks, None)
198-
else {
195+
let Some(vectors) = self.embeddings.embed_documents(&ordered_haystacks, None) else {
199196
info!(
200197
space_id = %space_id,
201198
server_id,

0 commit comments

Comments
 (0)