Skip to content

Commit 41c8c86

Browse files
committed
feat(workspaces): show effective features after setup + prove authless gateway
- After the setup walkthrough's Finish, land on the new mapping's inspector (which shows its effective features) instead of just closing. The wizard no longer self-closes on create; the page transitions to the created entry. - Prove the gateway is truly authless when inbound auth is disabled: a new HTTP integration test drives the REAL oauth middleware and asserts a tokenless POST to /mcp is accepted (200, anonymous identity injected) when disabled, and rejected (401) when auth is required. Claude-Session: https://claude.ai/code/session_01Baan9JmzR43uxxRUh7CAMF Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent c2c8455 commit 41c8c86

5 files changed

Lines changed: 193 additions & 3 deletions

File tree

apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ export function WorkspaceSetupWizard({
121121
space_id: spaceId,
122122
feature_set_ids: Array.from(fsIds),
123123
});
124-
onClose();
124+
// The parent transitions to the new mapping's inspector (which shows its
125+
// effective features) — don't close here, or that view would be lost.
125126
} catch (e) {
126127
onError(e instanceof Error ? e.message : String(e));
127128
setSaving(false);

apps/desktop/src/features/workspaces/WorkspacesPage.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,13 @@ export function WorkspacesPage() {
464464
reportedRoots={reportedRoots}
465465
existingBindings={bindings}
466466
onClose={() => setSelected(null)}
467-
onCreate={handleCreate}
467+
onCreate={async (input) => {
468+
const created = await handleCreate(input);
469+
// Land on the new mapping's inspector so its effective features
470+
// are shown right after creation.
471+
setSelected({ mode: 'entry', id: created.id });
472+
return created;
473+
}}
468474
onError={(msg) => showError('Could not save', msg)}
469475
/>
470476
) : (
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//! End-to-end proof that the gateway is *truly* authless when the
2+
//! `gateway.auth_disabled` toggle is on.
3+
//!
4+
//! Unlike `gateway_notifications.rs` (which bypasses auth with a test
5+
//! middleware), this drives the **real** `mcp_oauth_middleware` over HTTP and
6+
//! sends requests with **no** `Authorization` header:
7+
//! - auth disabled → the request is accepted and an anonymous client identity
8+
//! is injected (200, not 401),
9+
//! - auth required (default) → the same tokenless request is rejected (401).
10+
11+
use axum::{
12+
body::Body,
13+
http::{Request, StatusCode},
14+
middleware,
15+
response::{IntoResponse, Response},
16+
routing::post,
17+
Router,
18+
};
19+
use mcpmux_core::{DomainEvent, ServerDiscoveryService, ServerLogManager};
20+
use mcpmux_gateway::{
21+
mcp::mcp_oauth_middleware,
22+
server::{DependenciesBuilder, GatewayDependencies, GatewayState, ServiceContainer},
23+
};
24+
use mcpmux_storage::SqliteSpaceRepository;
25+
use std::sync::Arc;
26+
use tokio::sync::broadcast;
27+
use tokio_util::sync::CancellationToken;
28+
use uuid::Uuid;
29+
30+
use tests::db::TestDatabase;
31+
use tests::mocks::*;
32+
33+
/// Minimal `/mcp` handler that echoes the gateway-injected client id so the
34+
/// test can confirm the middleware ran and assigned an identity.
35+
async fn echo_client_id(req: Request<Body>) -> Response {
36+
let cid = req
37+
.headers()
38+
.get("x-mcpmux-client-id")
39+
.and_then(|v| v.to_str().ok())
40+
.unwrap_or("")
41+
.to_string();
42+
(StatusCode::OK, cid).into_response()
43+
}
44+
45+
struct Harness {
46+
url: String,
47+
ct: CancellationToken,
48+
}
49+
50+
impl Harness {
51+
/// Boot a gateway exposing `/mcp` behind the REAL oauth middleware, with the
52+
/// inbound-auth toggle set to `auth_disabled`.
53+
async fn start(auth_disabled: bool) -> Self {
54+
let ct = CancellationToken::new();
55+
let space_id = Uuid::new_v4();
56+
57+
let test_db = TestDatabase::in_memory();
58+
let database = Arc::new(tokio::sync::Mutex::new(test_db.db));
59+
60+
let space_repo = Arc::new(SqliteSpaceRepository::new(database.clone()));
61+
let space = mcpmux_core::domain::Space {
62+
id: space_id,
63+
name: "Test Space".to_string(),
64+
icon: None,
65+
description: None,
66+
is_default: true,
67+
sort_order: 0,
68+
created_at: chrono::Utc::now(),
69+
updated_at: chrono::Utc::now(),
70+
};
71+
mcpmux_core::SpaceRepository::create(&*space_repo, &space)
72+
.await
73+
.expect("create space");
74+
mcpmux_core::SpaceRepository::set_default(&*space_repo, &space_id)
75+
.await
76+
.expect("set default");
77+
78+
let deps = DependenciesBuilder::new()
79+
.with_installed_server_repo(Arc::new(MockInstalledServerRepository::new()))
80+
.with_credential_repo(Arc::new(MockCredentialRepository::new()))
81+
.with_backend_oauth_repo(Arc::new(MockOutboundOAuthRepository::new()))
82+
.with_feature_repo(Arc::new(MockServerFeatureRepository::new())
83+
as Arc<dyn mcpmux_core::ServerFeatureRepository>)
84+
.with_feature_set_repo(Arc::new(MockFeatureSetRepository::new())
85+
as Arc<dyn mcpmux_core::FeatureSetRepository>)
86+
.with_server_discovery(Arc::new(ServerDiscoveryService::new(
87+
std::path::PathBuf::from("test-data"),
88+
std::path::PathBuf::from("test-spaces"),
89+
)))
90+
.with_log_manager(Arc::new(ServerLogManager::new(
91+
mcpmux_core::LogConfig::default(),
92+
)))
93+
.with_database(database)
94+
.build()
95+
.expect("build dependencies");
96+
let deps = GatewayDependencies {
97+
space_repo: space_repo as Arc<dyn mcpmux_core::SpaceRepository>,
98+
..deps
99+
};
100+
101+
let (event_tx, _) = broadcast::channel::<DomainEvent>(64);
102+
let mut gw_state = GatewayState::new(event_tx.clone());
103+
gw_state.set_base_url("http://127.0.0.1:0".to_string());
104+
// No JWT secret needed: these tests send no token, so the auth-required
105+
// path 401s before the secret is ever consulted.
106+
gw_state.set_auth_disabled(auth_disabled);
107+
let gateway_state = Arc::new(tokio::sync::RwLock::new(gw_state));
108+
109+
let services = Arc::new(ServiceContainer::initialize(
110+
&deps,
111+
event_tx.clone(),
112+
gateway_state,
113+
));
114+
115+
let router = Router::new().route("/mcp", post(echo_client_id)).layer(
116+
middleware::from_fn_with_state(services.clone(), mcp_oauth_middleware),
117+
);
118+
119+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
120+
.await
121+
.expect("bind");
122+
let port = listener.local_addr().unwrap().port();
123+
let ct_clone = ct.clone();
124+
tokio::spawn(async move {
125+
axum::serve(listener, router)
126+
.with_graceful_shutdown(async move { ct_clone.cancelled().await })
127+
.await
128+
.unwrap();
129+
});
130+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
131+
132+
Self {
133+
url: format!("http://127.0.0.1:{port}/mcp"),
134+
ct,
135+
}
136+
}
137+
}
138+
139+
impl Drop for Harness {
140+
fn drop(&mut self) {
141+
self.ct.cancel();
142+
}
143+
}
144+
145+
#[tokio::test]
146+
async fn authless_gateway_accepts_request_without_token() {
147+
let h = Harness::start(true).await;
148+
let resp = reqwest::Client::new()
149+
.post(&h.url)
150+
.header("content-type", "application/json")
151+
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)
152+
.send()
153+
.await
154+
.expect("request");
155+
assert_eq!(
156+
resp.status(),
157+
reqwest::StatusCode::OK,
158+
"auth-disabled gateway must accept a tokenless request"
159+
);
160+
// The middleware injected an anonymous identity rather than rejecting.
161+
let body = resp.text().await.unwrap();
162+
assert_eq!(body, "mcpmux-anonymous");
163+
}
164+
165+
#[tokio::test]
166+
async fn auth_required_gateway_rejects_request_without_token() {
167+
let h = Harness::start(false).await;
168+
let resp = reqwest::Client::new()
169+
.post(&h.url)
170+
.header("content-type", "application/json")
171+
.body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)
172+
.send()
173+
.await
174+
.expect("request");
175+
assert_eq!(
176+
resp.status(),
177+
reqwest::StatusCode::UNAUTHORIZED,
178+
"default gateway must reject a tokenless request"
179+
);
180+
}

tests/rust/tests/streamable_http/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
//! - Server-initiated notifications (list_changed via SSE)
66
//! - Proper protocol negotiation
77
8+
mod auth_disable;
89
mod gateway_notifications;
910
mod notifications;

tests/ts/components/WorkspaceSetupWizard.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ describe('WorkspaceSetupWizard', () => {
8282
space_id: 's1',
8383
feature_set_ids: ['fs_starter'],
8484
});
85-
await waitFor(() => expect(p.onClose).toHaveBeenCalled());
85+
// The parent navigates to the new mapping's inspector (effective features);
86+
// the wizard itself does not close.
87+
expect(p.onClose).not.toHaveBeenCalled();
8688
});
8789

8890
it('lets you go Back from a later step', async () => {

0 commit comments

Comments
 (0)