Skip to content

Commit 9bdafa9

Browse files
committed
test(e2e): cover list==call, debug auto-approve, empty mappings; fix stale helpers
E2E for the workspace-root routing work, plus repairs to e2e helpers that had drifted out of sync with the Tauri command surface. New coverage: - streamable-http TC-SH-016: the reported bug end-to-end through /mcp — seed a feature with a dotted server_id + hyphenated name (mirrors com.notion-mcp-http_notion-get-users), grant it, then assert the EXACT name returned by tools/list is never rejected by tools/call with "not allowed by the current grants". Seeding keeps it green even if the backend handshake doesn't complete on CI. - meta-tools TC-MT-003: the DEBUG "Auto-approve writes" switch round-trips through get/set_meta_tools_auto_approve and the UI toggle reflects it. - workspaces TC-WS-010: a binding with zero FeatureSets is savable, persists, and still renders a card ("no Space tools"). Helper fixes (these were broken before — wdio runs ts-node transpile-only, so missing exports only surfaced at runtime): - Add getActiveSpace (aliases getDefaultSpace) — streamable-http / server-logs / deeplink-install / comprehensive all call it; it was never exported, so their before() hooks threw at runtime. comprehensive also missing the import. - Add grantOAuthClientFeatureSet + get/setMetaToolsAutoApprove helpers. - Drop the stale grantFeatureSetToClient import from the manual screenshot spec. - WorkspaceBindingInput doc: feature_set_ids MAY be empty (was "non-empty"). Also correct the mcpmux_bind_current_workspace tool description: bindings are EXACT-match, no subdirectory/ancestor inheritance (the old text still claimed "the same root (or a subdirectory)"). Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 2af5d4c commit 9bdafa9

7 files changed

Lines changed: 241 additions & 8 deletions

File tree

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -349,10 +349,11 @@ impl MetaTool for BindCurrentWorkspaceTool {
349349

350350
fn description(&self) -> &'static str {
351351
"Persistently bind the caller's first reported workspace root to the \
352-
given FeatureSet inside the caller's resolved Space. Every future \
353-
connection that reports the same root (or a subdirectory) will \
354-
resolve to this FeatureSet. Requires user approval and the calling \
355-
client MUST have declared MCP roots."
352+
given FeatureSet inside the caller's resolved Space. Only a future \
353+
connection that reports this EXACT root resolves to this FeatureSet — \
354+
bindings are exact-match, with no subdirectory/ancestor inheritance. \
355+
Requires user approval and the calling client MUST have declared MCP \
356+
roots."
356357
}
357358

358359
fn input_schema(&self) -> Value {

tests/e2e/helpers/tauri-api.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ export async function getDefaultSpace(): Promise<Space | null> {
6161
return spaces.find((s) => s.is_default) ?? null;
6262
}
6363

64+
/**
65+
* The Space tests operate against. In the e2e environment this is the default
66+
* Space (the gateway's routing fallback), so it aliases {@link getDefaultSpace}.
67+
* Kept as a distinct name because several specs read it as "the active Space".
68+
*/
69+
export async function getActiveSpace(): Promise<Space | null> {
70+
return getDefaultSpace();
71+
}
72+
6473
// ============================================================================
6574
// Client API
6675
// ============================================================================
@@ -195,6 +204,29 @@ export async function approveOAuthClient(clientId: string): Promise<void> {
195204
return invoke<void>('approve_oauth_client', { clientId });
196205
}
197206

207+
/** Grant a feature set to an OAuth client in a space (rootless-client routing). */
208+
export async function grantOAuthClientFeatureSet(
209+
clientId: string,
210+
spaceId: string,
211+
featureSetId: string
212+
): Promise<void> {
213+
return invoke<void>('grant_oauth_client_feature_set', {
214+
clientId,
215+
spaceId,
216+
featureSetId,
217+
});
218+
}
219+
220+
/** DEBUG: toggle auto-approval of every write meta tool (session-only). */
221+
export async function setMetaToolsAutoApprove(enabled: boolean): Promise<boolean> {
222+
return invoke<boolean>('set_meta_tools_auto_approve', { enabled });
223+
}
224+
225+
/** Read the current DEBUG auto-approve state. */
226+
export async function getMetaToolsAutoApprove(): Promise<boolean> {
227+
return invoke<boolean>('get_meta_tools_auto_approve');
228+
}
229+
198230
// ============================================================================
199231
// Server Feature Seeding API (for E2E / screenshots)
200232
// ============================================================================
@@ -263,7 +295,7 @@ export interface WorkspaceBinding {
263295
id: string;
264296
workspace_root: string;
265297
space_id: string;
266-
/** A binding can map to one or more FeatureSets (order = render order). */
298+
/** A binding maps to zero or more FeatureSets (order = render order). */
267299
feature_set_ids: string[];
268300
created_at: string;
269301
updated_at: string;
@@ -272,7 +304,7 @@ export interface WorkspaceBinding {
272304
export interface WorkspaceBindingInput {
273305
workspace_root: string;
274306
space_id: string;
275-
/** Non-empty: at least one FeatureSet id. */
307+
/** MAY be empty — an empty mapping means "no Space tools" for that root. */
276308
feature_set_ids: string[];
277309
}
278310

tests/e2e/specs/capture-screenshots.manual.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ import {
4949
listServerFeatures,
5050
listClients,
5151
addFeatureToSet,
52-
grantFeatureSetToClient,
5352
grantOAuthClientFeatureSet,
5453
} from '../helpers/tauri-api';
5554
import { PRESEED } from '../mocks/screenshot-preseed';

tests/e2e/specs/comprehensive.wdio.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createSpace,
99
deleteSpace,
1010
getDefaultSpace,
11+
getActiveSpace,
1112
listSpaces,
1213
listFeatureSetsBySpace,
1314
createFeatureSet,

tests/e2e/specs/meta-tools.wdio.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
*/
1414

1515
import { byTestId, TIMEOUT, safeClick } from '../helpers/selectors';
16-
import { emitEvent, getDefaultSpace, invoke } from '../helpers/tauri-api';
16+
import {
17+
emitEvent,
18+
getDefaultSpace,
19+
invoke,
20+
getMetaToolsAutoApprove,
21+
setMetaToolsAutoApprove,
22+
} from '../helpers/tauri-api';
1723

1824
interface BuiltinServerRow {
1925
id: string;
@@ -67,6 +73,35 @@ describe('Built-in Servers - Tool Optimization UI', () => {
6773
await expect(grants).toBeDisplayed();
6874
await expect(audit).toBeDisplayed();
6975
});
76+
77+
it('TC-MT-003: DEBUG auto-approve toggle round-trips and reflects backend state', async () => {
78+
const nav = await byTestId('nav-builtin-servers');
79+
await safeClick(nav);
80+
await browser.pause(1000);
81+
82+
// The amber debug control lives inside the grants panel.
83+
const toggleWrap = await byTestId('meta-tool-auto-approve');
84+
await expect(toggleWrap).toBeDisplayed();
85+
86+
// Start from a known-off baseline (session-only state).
87+
await setMetaToolsAutoApprove(false);
88+
expect(await getMetaToolsAutoApprove()).toBe(false);
89+
90+
// Flip it on via the UI switch and confirm the backend agrees.
91+
const toggle = await byTestId('meta-tool-auto-approve-toggle');
92+
await safeClick(toggle);
93+
await browser.waitUntil(async () => (await getMetaToolsAutoApprove()) === true, {
94+
timeout: TIMEOUT.medium,
95+
timeoutMsg: 'auto-approve did not turn on after clicking the switch',
96+
});
97+
98+
// Flip back off so later tests see the safe default.
99+
await safeClick(toggle);
100+
await browser.waitUntil(async () => (await getMetaToolsAutoApprove()) === false, {
101+
timeout: TIMEOUT.medium,
102+
timeoutMsg: 'auto-approve did not turn off after clicking the switch',
103+
});
104+
});
70105
});
71106

72107
describe('Meta tools - Approval dialog', () => {

tests/e2e/specs/streamable-http.wdio.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
listInstalledServers,
2424
refreshRegistry,
2525
approveOAuthClient,
26+
grantOAuthClientFeatureSet,
27+
createFeatureSet,
28+
addFeatureToSet,
29+
seedServerFeatures,
2630
} from '../helpers/tauri-api';
2731
import {
2832
registerOAuthClient,
@@ -543,4 +547,123 @@ describe('Streamable HTTP: OAuth MCP Client Flow', function () {
543547
console.log('[test] First tool:', toolsBody.result.tools[0].name);
544548
}
545549
});
550+
551+
// --------------------------------------------------------------------------
552+
// TC-SH-016: "if it lists, it calls" — a listed tool is never grant-blocked
553+
//
554+
// Regression for the reported bug: a tool (e.g. notion_notion-get-users)
555+
// appeared in tools/list yet tools/call rejected it with "not allowed by the
556+
// current grants". Root cause was list encoding names via qualified_name()
557+
// while call decoded via a stale prefix-cache reverse lookup. Both paths now
558+
// match qualified_name() against the SAME resolved feature set.
559+
//
560+
// Seed a feature with a dotted server_id + hyphenated name (mirrors the real
561+
// com.notion-mcp-http_notion-get-users shape) so this holds even when the
562+
// backend MCP handshake doesn't complete on CI.
563+
// --------------------------------------------------------------------------
564+
it('TC-SH-016: every listed tool is callable (no listed-but-blocked)', async () => {
565+
// 1. Seed a backend tool feature directly into this space.
566+
const seeded = await seedServerFeatures([
567+
{
568+
space_id: defaultSpaceId,
569+
server_id: 'com.e2e-listcall-http',
570+
feature_type: 'tool',
571+
feature_name: 'list-and-call-me',
572+
display_name: 'List And Call Me',
573+
description: 'E2E tool proving list==call',
574+
},
575+
]);
576+
expect(seeded.length).toBe(1);
577+
const featureId = seeded[0];
578+
579+
// 2. Compose a FeatureSet with exactly that tool, grant it to the client.
580+
const fs = await createFeatureSet({
581+
name: `e2e-listcall-${Date.now()}`,
582+
space_id: defaultSpaceId,
583+
});
584+
await addFeatureToSet(fs.id, featureId, 'include');
585+
await grantOAuthClientFeatureSet(clientId, defaultSpaceId, fs.id);
586+
587+
// 3. Fresh session so the new grant resolves.
588+
const token = await obtainAccessToken(clientId, 'http://localhost:0/callback', gatewayPort);
589+
const initRes = await fetch(`http://localhost:${gatewayPort}/mcp`, {
590+
method: 'POST',
591+
headers: {
592+
'Content-Type': 'application/json',
593+
Accept: 'application/json, text/event-stream',
594+
Authorization: `Bearer ${token}`,
595+
},
596+
body: JSON.stringify({
597+
jsonrpc: '2.0',
598+
id: 1,
599+
method: 'initialize',
600+
params: {
601+
protocolVersion: '2025-03-26',
602+
capabilities: {},
603+
clientInfo: { name: 'e2e-listcall', version: '1.0.0' },
604+
},
605+
}),
606+
});
607+
expect(initRes.status).toBeLessThan(400);
608+
const sessionId = initRes.headers.get('mcp-session-id');
609+
expect(sessionId).toBeTruthy();
610+
await initRes.text();
611+
612+
await fetch(`http://localhost:${gatewayPort}/mcp`, {
613+
method: 'POST',
614+
headers: {
615+
'Content-Type': 'application/json',
616+
Accept: 'application/json, text/event-stream',
617+
Authorization: `Bearer ${token}`,
618+
'Mcp-Session-Id': sessionId!,
619+
},
620+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
621+
});
622+
623+
// 4. tools/list — the seeded tool MUST be listed under the grant.
624+
const listRes = await fetch(`http://localhost:${gatewayPort}/mcp`, {
625+
method: 'POST',
626+
headers: {
627+
'Content-Type': 'application/json',
628+
Accept: 'application/json, text/event-stream',
629+
Authorization: `Bearer ${token}`,
630+
'Mcp-Session-Id': sessionId!,
631+
},
632+
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
633+
});
634+
expect(listRes.ok).toBe(true);
635+
const listBody = parseMcpResponse<{
636+
result?: { tools: Array<{ name: string }> };
637+
}>(listRes.headers.get('content-type'), await listRes.text());
638+
const tools = listBody.result?.tools ?? [];
639+
console.log('[test] TC-SH-016 listed tools:', tools.map((t) => t.name).join(', '));
640+
const target = tools.find((t) => t.name.includes('list-and-call-me'));
641+
expect(target).toBeTruthy();
642+
643+
// 5. tools/call the EXACT listed name. It may fail to execute (no live
644+
// backend behind the seeded feature), but it must NEVER be rejected by
645+
// grants — that is the invariant the fix guarantees.
646+
const callRes = await fetch(`http://localhost:${gatewayPort}/mcp`, {
647+
method: 'POST',
648+
headers: {
649+
'Content-Type': 'application/json',
650+
Accept: 'application/json, text/event-stream',
651+
Authorization: `Bearer ${token}`,
652+
'Mcp-Session-Id': sessionId!,
653+
},
654+
body: JSON.stringify({
655+
jsonrpc: '2.0',
656+
id: 3,
657+
method: 'tools/call',
658+
params: { name: target!.name, arguments: {} },
659+
}),
660+
});
661+
const callBody = parseMcpResponse<{
662+
error?: { message?: string };
663+
result?: unknown;
664+
}>(callRes.headers.get('content-type'), await callRes.text());
665+
const errMsg = callBody.error?.message ?? '';
666+
console.log('[test] TC-SH-016 call error (if any):', errMsg);
667+
expect(errMsg).not.toContain('not allowed by the current grants');
668+
});
546669
});

tests/e2e/specs/workspaces.wdio.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,48 @@ describe('Workspaces - Create, render, delete', () => {
142142
});
143143
});
144144

145+
describe('Workspaces - Empty mapping (no Space tools)', () => {
146+
let bindingId: string | null = null;
147+
const root = uniqueRoot();
148+
149+
it('TC-WS-010: A mapping with zero FeatureSets is savable and persists', async () => {
150+
const space = await getDefaultSpace();
151+
if (!space) throw new Error('No default space — cannot set up test');
152+
153+
// An empty feature_set_ids list is a deliberate "this root gets no Space
154+
// tools" mapping — it must persist, not be rejected.
155+
const created: WorkspaceBinding = await createWorkspaceBinding({
156+
workspace_root: root,
157+
space_id: space.id,
158+
feature_set_ids: [],
159+
});
160+
bindingId = created.id;
161+
expect(created.feature_set_ids.length).toBe(0);
162+
163+
const reloaded = (await listWorkspaceBindings()).find((b) => b.id === created.id);
164+
expect(reloaded).toBeTruthy();
165+
expect(reloaded!.feature_set_ids.length).toBe(0);
166+
167+
// Card renders for an empty mapping too.
168+
const nav = await byTestId('nav-workspaces');
169+
await safeClick(nav);
170+
await browser.pause(1200);
171+
const card = await $(`[data-testid="workspace-entry-${created.id}"]`);
172+
await card.waitForDisplayed({ timeout: TIMEOUT.short });
173+
expect(await card.isDisplayed()).toBe(true);
174+
});
175+
176+
after(async () => {
177+
if (bindingId) {
178+
try {
179+
await deleteWorkspaceBinding(bindingId);
180+
} catch {
181+
/* ignore */
182+
}
183+
}
184+
});
185+
});
186+
145187
describe('Workspaces - Create form flow (UI)', () => {
146188
let bindingId: string | null = null;
147189

0 commit comments

Comments
 (0)