Skip to content

Commit 5e2e87c

Browse files
committed
fix(gateway): keep one-folder pins and redact probe env
Empty ${workspaceFolder} no longer wipes a same-request single-folder set. Failed config reconnect marks features unavailable, bind active uses the hook root, and the env-probe allowlists study keys only. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 83c3e16 commit 5e2e87c

6 files changed

Lines changed: 123 additions & 27 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ impl ServerConfigUpdatedHandler {
9898
}
9999

100100
let started = Instant::now();
101+
self.drop_stale_features(space_id, server_id).await;
101102
match resolve_auto_connection_context(
102103
self.installed_server_repo.as_ref(),
103104
self.state_dir.as_deref(),
@@ -136,6 +137,27 @@ impl ServerConfigUpdatedHandler {
136137
}
137138
Ok(())
138139
}
140+
141+
/// Flip cached features off and drop the resolution cache before reconnect.
142+
///
143+
/// A successful discover writes `is_available` back. Without this, a failed
144+
/// `reconnect_fresh` evicts the instance while `tools/list` still advertises
145+
/// the pre-save rows.
146+
async fn drop_stale_features(&self, space_id: Uuid, server_id: &str) {
147+
if let Err(error) = self
148+
.pool_service
149+
.feature_service()
150+
.mark_unavailable(&space_id.to_string(), server_id)
151+
.await
152+
{
153+
warn!(
154+
server_id = %server_id,
155+
space_id = %space_id,
156+
error = %error,
157+
"[ServerConfigHandler] mark_unavailable failed before reconnect"
158+
);
159+
}
160+
}
139161
}
140162

141163
#[cfg(test)]

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

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use tracing::{debug, info, warn};
1919
use crate::auth::validate_token;
2020
use crate::logging::TraceContext;
2121
use crate::server::ServiceContainer;
22-
use crate::services::{resolve_window_key, PinSource};
22+
use crate::services::{resolve_window_key, PinSource, SessionRootsRegistry};
2323

2424
/// Synthetic client identity used when system-wide inbound auth is disabled and
2525
/// a connection arrives without a (valid) Bearer token. Routing still prefers
@@ -362,7 +362,13 @@ pub async fn mcp_oauth_middleware(
362362
}
363363

364364
if let Some(sid) = session_id_header.as_deref() {
365-
resolve_window_pin(&services, sid, empty_workspace_header, &trace_id);
365+
resolve_window_pin(
366+
&services,
367+
sid,
368+
empty_workspace_header,
369+
workspace_set_header.as_deref(),
370+
&trace_id,
371+
);
366372
}
367373

368374
// Captured before `request` is consumed below — needed to recognize the
@@ -433,7 +439,13 @@ pub async fn mcp_oauth_middleware(
433439
.session_roots
434440
.set_pinned(sid, ws, PinSource::WorkspaceHeader);
435441
}
436-
resolve_window_pin(&services, sid, empty_workspace_header, &trace_id);
442+
resolve_window_pin(
443+
&services,
444+
sid,
445+
empty_workspace_header,
446+
workspace_set_header.as_deref(),
447+
&trace_id,
448+
);
437449
}
438450

439451
// Log errors only — except two rmcp spec-correct shapes that are not
@@ -484,13 +496,18 @@ fn attach_window_identity(services: &ServiceContainer, session_id: &str, peer: O
484496
/// An empty header is a failed substitution, not "reuse the last pin." Drop
485497
/// the session claim and skip window inherit so a sibling window on the
486498
/// shared `mcp-session-id` cannot keep resolving through the previous root.
499+
/// A one-member set on *this* request is a stronger claim than the empty
500+
/// active-folder header and must keep the SingleCandidate pin.
487501
fn resolve_window_pin(
488502
services: &ServiceContainer,
489503
session_id: &str,
490504
empty_workspace_header: bool,
505+
workspace_set_header: Option<&str>,
491506
trace_id: &str,
492507
) {
493-
if empty_workspace_header {
508+
let same_request_single_set = workspace_set_header
509+
.is_some_and(SessionRootsRegistry::set_header_is_single_folder);
510+
if empty_workspace_header && !same_request_single_set {
494511
services.session_roots.forget_empty_header_claim(session_id);
495512
warn!(
496513
trace_id = %trace_id,
@@ -509,13 +526,7 @@ fn resolve_window_pin(
509526
services.session_roots.promote_pin_to_window(session_id);
510527
return;
511528
}
512-
if services
513-
.session_roots
514-
.inherit_window_pin(session_id)
515-
.is_some()
516-
{
517-
return;
518-
}
529+
services.session_roots.inherit_window_pin(session_id);
519530
}
520531

521532
/// Generate unauthorized response with RFC 9728 protected-resource discovery.

crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ struct BindToolResultInput<'a> {
5252
session_id: Option<&'a str>,
5353
client_id: &'a str,
5454
request_machine_id: Option<Uuid>,
55+
explicit_workspace_root: Option<&'a str>,
5556
binding_id: Uuid,
5657
workspace_root: &'a str,
5758
feature_set_id: Uuid,
@@ -60,17 +61,25 @@ struct BindToolResultInput<'a> {
6061
machine_id: Option<Uuid>,
6162
}
6263

64+
/// Build the bind JSON body, scoring `active` against the hook root when present.
6365
async fn bind_tool_result(input: BindToolResultInput<'_>) -> Result<CallToolResult, MetaToolError> {
6466
let fs_id_str = input.feature_set_id.to_string();
65-
let resolved = input
66-
.resolver
67-
.resolve(
68-
input.session_id,
69-
Some(input.client_id),
70-
input.request_machine_id,
71-
)
72-
.await
73-
.map_err(|e| MetaToolError::Internal(e.to_string()))?;
67+
let resolved = if let Some(root) = input.explicit_workspace_root {
68+
input
69+
.resolver
70+
.resolve_for_workspace_root(root, Some(input.client_id), input.request_machine_id)
71+
.await
72+
} else {
73+
input
74+
.resolver
75+
.resolve(
76+
input.session_id,
77+
Some(input.client_id),
78+
input.request_machine_id,
79+
)
80+
.await
81+
}
82+
.map_err(|e| MetaToolError::Internal(e.to_string()))?;
7483
let active = resolved.feature_set_ids.iter().any(|id| id == &fs_id_str);
7584

7685
let mut body = json!({
@@ -230,12 +239,13 @@ impl MetaTool for BindCurrentWorkspaceTool {
230239
session_id: call.session_id,
231240
client_id: call.client_id,
232241
request_machine_id: call.request_machine_id,
242+
explicit_workspace_root: call.explicit_workspace_root.as_deref(),
233243
binding_id: existing.id,
234244
workspace_root: &normalized,
235245
feature_set_id: fs_id,
236-
feature_set_ids: existing.feature_set_ids,
237246
already_bound: true,
238247
machine_id,
248+
feature_set_ids: existing.feature_set_ids,
239249
})
240250
.await;
241251
}
@@ -257,6 +267,7 @@ impl MetaTool for BindCurrentWorkspaceTool {
257267
let session_id_owned = call.session_id.map(str::to_owned);
258268
let caller_client_id_for_response = caller_client_id.clone();
259269
let request_machine_id = call.request_machine_id;
270+
let explicit_root = call.explicit_workspace_root.clone();
260271
info!(
261272
session_id = ?call.session_id,
262273
client_id = %call.client_id,
@@ -359,6 +370,7 @@ impl MetaTool for BindCurrentWorkspaceTool {
359370
session_id: session_id_owned.as_deref(),
360371
client_id: &caller_client_id_for_response,
361372
request_machine_id,
373+
explicit_workspace_root: explicit_root.as_deref(),
362374
binding_id,
363375
workspace_root: &normalized,
364376
feature_set_id: fs_id,

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,15 @@ impl SessionRootsRegistry {
588588
self.store_candidates(session_id, parse_candidate_set(raw_set));
589589
}
590590

591+
/// Whether this request's `X-Mcpmux-Workspace-Set` names exactly one folder.
592+
///
593+
/// Unexpanded `${...}` values and blanks are dropped first, same as
594+
/// [`Self::set_candidates`]. A one-member set is unambiguous and may keep
595+
/// a pin even when the active-folder header arrived empty.
596+
pub fn set_header_is_single_folder(raw_set: &str) -> bool {
597+
parse_candidate_set(raw_set).len() == 1
598+
}
599+
591600
fn store_candidates(&self, session_id: &str, parsed: Vec<String>) {
592601
if parsed.is_empty() {
593602
return;
@@ -1238,6 +1247,23 @@ mod tests {
12381247
);
12391248
}
12401249

1250+
#[test]
1251+
fn empty_header_plus_one_folder_set_keeps_pin_across_repeat_requests() {
1252+
let reg = SessionRootsRegistry::default();
1253+
for _ in 0..3 {
1254+
reg.set_candidates("sess", CANDIDATES[0]);
1255+
if !SessionRootsRegistry::set_header_is_single_folder(CANDIDATES[0]) {
1256+
reg.forget_empty_header_claim("sess");
1257+
}
1258+
}
1259+
assert_eq!(
1260+
reg.session_pin("sess").as_deref(),
1261+
Some(CANDIDATES[0]),
1262+
"empty ${{workspaceFolder}} must not wipe a same-request one-folder set"
1263+
);
1264+
assert_eq!(reg.get("sess"), Some(vec![CANDIDATES[0].to_string()]));
1265+
}
1266+
12411267
/// Regression test for the field-confirmed cross-window leak (see
12421268
/// `docs/planning/window-scoped-workspace-pin.md`, "second incident"):
12431269
/// Cursor's global bridge shares one `mcp-remote` process, and therefore

scripts/cursor-env-probe.mjs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,22 @@ export function redactArg(arg) {
8282
}
8383

8484
/**
85-
* Whether an environment variable name looks like a secret.
85+
* Whether this env key is part of the substitution study (log the value).
86+
* Everything else is treated as a secret.
87+
* @param {string} key
88+
* @returns {boolean}
89+
*/
90+
export function isLoggedEnvKey(key) {
91+
return /^(WORKSPACE_FOLDER_PATHS|PWD|VSCODE_PID|CURSOR_.*)$/i.test(key);
92+
}
93+
94+
/**
95+
* Whether an environment variable should be redacted in the probe log.
8696
* @param {string} key
8797
* @returns {boolean}
8898
*/
8999
export function isSecretEnvKey(key) {
90-
return /token|secret|password|passwd|api[_-]?key|authorization|credential|bearer|mcpk/i.test(
91-
key
92-
);
100+
return !isLoggedEnvKey(key);
93101
}
94102

95103
/**

tests/ts/scripts/cursor-env-probe.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,35 @@ describe('cursor-env-probe', () => {
2929
'Authorization:Bearer <redacted>'
3030
);
3131
expect(isSecretEnvKey('GITHUB_TOKEN')).toBe(true);
32+
expect(isSecretEnvKey('OPENAI_KEY')).toBe(true);
33+
expect(isSecretEnvKey('AWS_ACCESS_KEY_ID')).toBe(true);
34+
expect(isSecretEnvKey('GITHUB_PAT')).toBe(true);
35+
expect(isSecretEnvKey('DATABASE_URL')).toBe(true);
36+
expect(isSecretEnvKey('PATH')).toBe(true);
3237
expect(isSecretEnvKey('CURSOR_TRACE_ID')).toBe(false);
38+
expect(isSecretEnvKey('WORKSPACE_FOLDER_PATHS')).toBe(false);
39+
expect(isSecretEnvKey('VSCODE_PID')).toBe(false);
3340

3441
const record = formatProbeRecord(
3542
['npx', '-y', 'mcp-remote', '--header', 'Authorization:Bearer mcpk_abc'],
36-
{ PATH: '/usr/bin', OPENAI_API_KEY: 'sk-test', CURSOR_TRACE_ID: 't1' },
43+
{
44+
PATH: '/usr/bin',
45+
OPENAI_KEY: 'sk-test',
46+
AWS_ACCESS_KEY_ID: 'AKIATEST',
47+
CURSOR_TRACE_ID: 't1',
48+
WORKSPACE_FOLDER_PATHS: '/repos/alpha',
49+
},
3750
'/tmp',
3851
new Date('2026-08-21T00:00:00.000Z')
3952
);
4053
expect(record).toContain('Authorization:Bearer <redacted>');
4154
expect(record).not.toContain('mcpk_abc');
42-
expect(record).toContain('OPENAI_API_KEY=<redacted>');
55+
expect(record).toContain('OPENAI_KEY=<redacted>');
56+
expect(record).toContain('AWS_ACCESS_KEY_ID=<redacted>');
57+
expect(record).toContain('PATH=<redacted>');
4358
expect(record).not.toContain('sk-test');
59+
expect(record).not.toContain('AKIATEST');
4460
expect(record).toContain('CURSOR_TRACE_ID=t1');
61+
expect(record).toContain('WORKSPACE_FOLDER_PATHS=/repos/alpha');
4562
});
4663
});

0 commit comments

Comments
 (0)