Skip to content

Commit 68e6080

Browse files
its-mashclaude
andcommitted
test: add comprehensive E2E tests for Streamable HTTP & list change notifications
Add two layers of test coverage for the Streamable HTTP transport: Rust integration tests (gateway_notifications.rs): - Gateway capabilities advertisement (listChanged: true) - Tools/prompts/resources list_changed forwarding to clients - Server disconnect notification propagation - Grant change notification delivery - Content-based deduplication preventing spurious notifications - Time-based throttling coalescing rapid notifications - Server features refresh triggering notifications - Full re-fetch cycle after notification Tauri E2E tests (streamable-http.wdio.ts): - Gateway Streamable HTTP endpoint serving - Backend HTTP transport connection - Stub server control endpoint verification - All three notification types from backend - Dynamic tool add/remove with notifications - Rapid successive notification handling - Server disable/re-enable reconnection cycle - OAuth DCR registration and approval - PKCE token exchange flow - Authenticated MCP initialize with capabilities check - Session management via Mcp-Session-Id header Supporting infrastructure: - Stub MCP server control endpoints for triggering notifications - E2E helpers for OAuth flow and stub server control - Enhanced transport-level notification tests (prompts, resources) Signed-off-by: Myko <myko@mcpmux.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent d976689 commit 68e6080

8 files changed

Lines changed: 2017 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/e2e/helpers/mcp-client.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* MCP Client Helper for E2E Tests
3+
*
4+
* Provides functions to perform the full OAuth 2.1 + PKCE flow
5+
* against the McpMux gateway programmatically and obtain an access token.
6+
*
7+
* This enables tests to connect an MCP client to the gateway
8+
* without going through the browser-based consent flow.
9+
*/
10+
11+
import crypto from 'node:crypto';
12+
13+
const DEFAULT_GATEWAY_PORT = 45818;
14+
15+
function gatewayUrl(path: string, port?: number): string {
16+
return `http://localhost:${port ?? DEFAULT_GATEWAY_PORT}${path}`;
17+
}
18+
19+
/** Generate PKCE code verifier + challenge pair (S256) */
20+
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
21+
const codeVerifier = crypto.randomBytes(32).toString('base64url');
22+
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
23+
const codeChallenge = hash.toString('base64url');
24+
return { codeVerifier, codeChallenge };
25+
}
26+
27+
/**
28+
* Register a new OAuth client via Dynamic Client Registration (DCR).
29+
* Returns the client_id assigned by the gateway.
30+
*/
31+
export async function registerOAuthClient(
32+
clientName: string,
33+
redirectUri: string = 'http://localhost:0/callback',
34+
port?: number,
35+
): Promise<string> {
36+
const res = await fetch(gatewayUrl('/oauth/register', port), {
37+
method: 'POST',
38+
headers: { 'Content-Type': 'application/json' },
39+
body: JSON.stringify({
40+
client_name: clientName,
41+
redirect_uris: [redirectUri],
42+
grant_types: ['authorization_code'],
43+
response_types: ['code'],
44+
token_endpoint_auth_method: 'none',
45+
}),
46+
});
47+
48+
if (!res.ok) {
49+
throw new Error(`DCR failed: ${res.status} ${await res.text()}`);
50+
}
51+
52+
const data = (await res.json()) as { client_id: string };
53+
return data.client_id;
54+
}
55+
56+
/**
57+
* Perform the full OAuth 2.1 + PKCE flow to obtain a JWT access token.
58+
*
59+
* Prerequisites:
60+
* - Client must be registered (via registerOAuthClient or DCR)
61+
* - Client must be approved (via Tauri API approveOAuthClient)
62+
*
63+
* Steps:
64+
* 1. GET /oauth/authorize with PKCE challenge → extracts request_id from deep link
65+
* 2. POST /oauth/consent/approve with request_id → extracts auth code from redirect
66+
* 3. POST /oauth/token with auth code + code verifier → returns JWT
67+
*
68+
* @returns JWT access token string
69+
*/
70+
export async function obtainAccessToken(
71+
clientId: string,
72+
redirectUri: string = 'http://localhost:0/callback',
73+
port?: number,
74+
): Promise<string> {
75+
const { codeVerifier, codeChallenge } = generatePkce();
76+
const state = crypto.randomUUID();
77+
78+
// Step 1: Authorization request
79+
const authorizeUrl = new URL(gatewayUrl('/oauth/authorize', port));
80+
authorizeUrl.searchParams.set('client_id', clientId);
81+
authorizeUrl.searchParams.set('response_type', 'code');
82+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
83+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
84+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
85+
authorizeUrl.searchParams.set('state', state);
86+
87+
// The authorize endpoint returns an HTML page with a deep link.
88+
// We need to extract the request_id from the HTML content.
89+
const authorizeRes = await fetch(authorizeUrl.toString(), { redirect: 'manual' });
90+
const html = await authorizeRes.text();
91+
92+
// Extract request_id from the deep link in the HTML
93+
// Format: mcpmux://authorize?request_id=<id>
94+
const requestIdMatch = html.match(/request_id=([^"&\s]+)/);
95+
if (!requestIdMatch) {
96+
throw new Error(`Could not extract request_id from authorize response. HTML: ${html.substring(0, 500)}`);
97+
}
98+
const requestId = decodeURIComponent(requestIdMatch[1]);
99+
100+
// Step 2: Consent approval
101+
const consentRes = await fetch(gatewayUrl('/oauth/consent/approve', port), {
102+
method: 'POST',
103+
headers: { 'Content-Type': 'application/json' },
104+
body: JSON.stringify({
105+
request_id: requestId,
106+
approved: true,
107+
}),
108+
});
109+
110+
if (!consentRes.ok) {
111+
throw new Error(`Consent approval failed: ${consentRes.status} ${await consentRes.text()}`);
112+
}
113+
114+
const consentData = (await consentRes.json()) as {
115+
success: boolean;
116+
redirect_url: string;
117+
error?: string;
118+
};
119+
120+
if (!consentData.success) {
121+
throw new Error(`Consent not successful: ${consentData.error}`);
122+
}
123+
124+
// Extract auth code from redirect URL
125+
const redirectUrl = new URL(consentData.redirect_url);
126+
const code = redirectUrl.searchParams.get('code');
127+
if (!code) {
128+
throw new Error(`No auth code in redirect: ${consentData.redirect_url}`);
129+
}
130+
131+
// Step 3: Token exchange
132+
const tokenRes = await fetch(gatewayUrl('/oauth/token', port), {
133+
method: 'POST',
134+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
135+
body: new URLSearchParams({
136+
grant_type: 'authorization_code',
137+
code,
138+
client_id: clientId,
139+
redirect_uri: redirectUri,
140+
code_verifier: codeVerifier,
141+
}).toString(),
142+
});
143+
144+
if (!tokenRes.ok) {
145+
throw new Error(`Token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
146+
}
147+
148+
const tokenData = (await tokenRes.json()) as {
149+
access_token: string;
150+
token_type: string;
151+
expires_in?: number;
152+
};
153+
154+
return tokenData.access_token;
155+
}
156+
157+
/**
158+
* Wait for the gateway to be ready by polling /health.
159+
*/
160+
export async function waitForGateway(port?: number, timeoutMs: number = 10000): Promise<void> {
161+
const start = Date.now();
162+
while (Date.now() - start < timeoutMs) {
163+
try {
164+
const res = await fetch(gatewayUrl('/health', port));
165+
if (res.ok) return;
166+
} catch {
167+
// Not ready yet
168+
}
169+
await new Promise((r) => setTimeout(r, 200));
170+
}
171+
throw new Error(`Gateway not ready after ${timeoutMs}ms`);
172+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Stub MCP Server Control Helper
3+
*
4+
* Provides functions to trigger list_changed notifications and manage
5+
* dynamic tools on the stub MCP HTTP server via its control endpoints.
6+
*/
7+
8+
const DEFAULT_STUB_PORT = 3457;
9+
10+
function controlUrl(path: string, port?: number): string {
11+
return `http://localhost:${port ?? DEFAULT_STUB_PORT}${path}`;
12+
}
13+
14+
/** Trigger tools/list_changed notification on all connected sessions */
15+
export async function triggerToolsChanged(port?: number): Promise<{ ok: boolean; sessions_notified: number }> {
16+
const res = await fetch(controlUrl('/control/notify-tools-changed', port), {
17+
method: 'POST',
18+
headers: { 'Content-Type': 'application/json' },
19+
});
20+
return res.json() as Promise<{ ok: boolean; sessions_notified: number }>;
21+
}
22+
23+
/** Trigger prompts/list_changed notification on all connected sessions */
24+
export async function triggerPromptsChanged(port?: number): Promise<{ ok: boolean; sessions_notified: number }> {
25+
const res = await fetch(controlUrl('/control/notify-prompts-changed', port), {
26+
method: 'POST',
27+
headers: { 'Content-Type': 'application/json' },
28+
});
29+
return res.json() as Promise<{ ok: boolean; sessions_notified: number }>;
30+
}
31+
32+
/** Trigger resources/list_changed notification on all connected sessions */
33+
export async function triggerResourcesChanged(port?: number): Promise<{ ok: boolean; sessions_notified: number }> {
34+
const res = await fetch(controlUrl('/control/notify-resources-changed', port), {
35+
method: 'POST',
36+
headers: { 'Content-Type': 'application/json' },
37+
});
38+
return res.json() as Promise<{ ok: boolean; sessions_notified: number }>;
39+
}
40+
41+
/** Dynamically add a tool to the stub server and notify connected sessions */
42+
export async function addDynamicTool(
43+
name: string,
44+
description?: string,
45+
port?: number,
46+
): Promise<{ ok: boolean; tool: string; sessions_updated: number }> {
47+
const res = await fetch(controlUrl('/control/add-tool', port), {
48+
method: 'POST',
49+
headers: { 'Content-Type': 'application/json' },
50+
body: JSON.stringify({ name, description }),
51+
});
52+
return res.json() as Promise<{ ok: boolean; tool: string; sessions_updated: number }>;
53+
}
54+
55+
/** Dynamically remove a tool from the stub server and notify connected sessions */
56+
export async function removeDynamicTool(
57+
name: string,
58+
port?: number,
59+
): Promise<{ ok: boolean; tool: string; sessions_notified: number }> {
60+
const res = await fetch(controlUrl('/control/remove-tool', port), {
61+
method: 'POST',
62+
headers: { 'Content-Type': 'application/json' },
63+
body: JSON.stringify({ name }),
64+
});
65+
return res.json() as Promise<{ ok: boolean; tool: string; sessions_notified: number }>;
66+
}

0 commit comments

Comments
 (0)