Skip to content

Commit 888fa1d

Browse files
github-actions[bot]agentfront[bot]frontegg-david
authored
Cherry-pick: test: add end-to-end tests for elicitation capability round-trip and session state persistence (#297)
Cherry-picked from #296 (merged to release/1.0.x) Original commit: 4f88506 Co-authored-by: agentfront[bot] <agentfront[bot]@users.noreply.github.com> Co-authored-by: frontegg-david <69419539+frontegg-david@users.noreply.github.com>
1 parent 701faa0 commit 888fa1d

6 files changed

Lines changed: 185 additions & 30 deletions

File tree

apps/e2e/demo-e2e-elicitation/e2e/elicitation.e2e.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,4 +531,87 @@ test.describe('Elicitation E2E', () => {
531531
}
532532
});
533533
});
534+
535+
/**
536+
* Tests for elicitation capability round-trip.
537+
*
538+
* These tests verify the full capability negotiation flow that was missing
539+
* from previous tests, causing two production bugs to go undetected:
540+
* - Bug #1: Server not advertising elicitation in capabilities response
541+
* - Bug #2: supportsElicitation flag lost across requests in SSE mode
542+
*/
543+
test.describe('elicitation capability round-trip', () => {
544+
test('server should advertise elicitation in capabilities', async ({ mcp }) => {
545+
const caps = mcp.capabilities as Record<string, unknown>;
546+
expect(caps.elicitation).toBeDefined();
547+
});
548+
549+
test('elicitation should work after prior requests in same session', async ({ mcp }) => {
550+
// Do normal requests first — exercises session state persistence
551+
const tools = await mcp.tools.list();
552+
expect(tools.length).toBeGreaterThan(0);
553+
554+
// Second request in same session
555+
const tools2 = await mcp.tools.list();
556+
expect(tools2.length).toEqual(tools.length);
557+
558+
// Now trigger elicitation — must still work after prior requests
559+
mcp.onElicitation(async () => ({
560+
action: 'accept',
561+
content: { confirmed: true },
562+
}));
563+
564+
const result = await mcp.tools.call('confirm-action', { action: 'test after requests' });
565+
expect(result).toBeSuccessful();
566+
expect(result.text()).toContain('confirmed and executed');
567+
// Must NOT contain fallback instructions — native elicitation should work
568+
expect(result.text()).not.toContain('sendElicitationResult');
569+
});
570+
571+
test('tools/list should NOT include sendElicitationResult for elicitation-capable clients', async ({ mcp }) => {
572+
const tools = await mcp.tools.list();
573+
const toolNames = tools.map((t) => t.name);
574+
expect(toolNames).not.toContain('sendElicitationResult');
575+
});
576+
577+
test('should handle multiple sequential elicitations in same session', async ({ mcp }) => {
578+
// Do a normal request first
579+
await mcp.tools.list();
580+
581+
// First elicitation
582+
mcp.onElicitation(async () => ({
583+
action: 'accept',
584+
content: { confirmed: true },
585+
}));
586+
const result1 = await mcp.tools.call('confirm-action', { action: 'first action' });
587+
expect(result1).toBeSuccessful();
588+
expect(result1.text()).toContain('confirmed and executed');
589+
590+
// Second elicitation in same session — session state must persist
591+
mcp.onElicitation(async () => ({
592+
action: 'accept',
593+
content: { userInput: 'hello' },
594+
}));
595+
const result2 = await mcp.tools.call('get-user-input', { prompt: 'say hi' });
596+
expect(result2).toBeSuccessful();
597+
expect(result2.text()).toContain('hello');
598+
});
599+
600+
test('non-supporting client should get fallback even after prior requests', async ({ server }) => {
601+
const noElicitClient = await server.createClientBuilder().withCapabilities({}).withPublicMode().buildAndConnect();
602+
603+
try {
604+
// Do some normal requests first
605+
const tools = await noElicitClient.tools.list();
606+
expect(tools.map((t) => t.name)).toContain('sendElicitationResult');
607+
608+
// Now trigger elicitation — should get fallback
609+
const result = await noElicitClient.tools.call('confirm-action', { action: 'test' });
610+
expect(result).toBeSuccessful();
611+
expect(result.text()).toContain('sendElicitationResult');
612+
} finally {
613+
await noElicitClient.disconnect();
614+
}
615+
});
616+
});
534617
});

libs/sdk/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@
7474
"cors": "^2.8.5",
7575
"raw-body": "^3.0.0",
7676
"content-type": "^1.0.5",
77-
"vectoriadb": "^2.1.3",
7877
"@vercel/kv": "^3.0.0",
7978
"@frontmcp/storage-sqlite": "0.12.1",
8079
"@enclave-vm/core": "^2.11.1",
@@ -107,6 +106,7 @@
107106
"@frontmcp/auth": "0.12.1",
108107
"@frontmcp/protocol": "0.12.1",
109108
"ioredis": "^5.8.0",
109+
"vectoriadb": "^2.1.3",
110110
"js-yaml": "^4.1.1",
111111
"jose": "^6.1.3",
112112
"reflect-metadata": "^0.2.2",

libs/sdk/src/auth/session/__tests__/session-id.utils.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ describe('session-id.utils', () => {
237237
clientVersion: '1.0.0',
238238
});
239239

240-
expect(updated).toBe(true);
240+
expect(updated).not.toBeNull();
241241
});
242242

243243
it('should merge partial updates correctly', () => {
@@ -285,7 +285,7 @@ describe('session-id.utils', () => {
285285
clientName: 'Test',
286286
});
287287

288-
expect(result).toBe(false);
288+
expect(result).toBeNull();
289289
});
290290

291291
it('should decrypt and update if not in cache', () => {

libs/sdk/src/auth/session/utils/session-id.utils.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -162,34 +162,43 @@ export function extractSessionFromCookie(cookie?: string): string | undefined {
162162
}
163163

164164
/**
165-
* Update a cached session payload with new data.
166-
* This is used to persist changes like platformType detection that happen
167-
* after the initial session creation.
165+
* Update a cached session payload with new data and re-encrypt to produce
166+
* a new session ID that carries the updated payload.
168167
*
169-
* @param sessionId - The session ID to update
168+
* This ensures that any node in a distributed system (including Vercel Edge
169+
* and Cloudflare Workers) can decrypt the session ID and get the full
170+
* payload with initialization data (e.g., supportsElicitation, platformType).
171+
*
172+
* @param sessionId - The current session ID to update
170173
* @param updates - Partial payload updates to merge
171-
* @returns true if the session was found and updated, false otherwise
174+
* @returns The new session ID with the updated payload baked in, or null if session was invalid
172175
*/
173-
export function updateSessionPayload(sessionId: string, updates: Partial<SessionIdPayload>): boolean {
176+
export function updateSessionPayload(sessionId: string, updates: Partial<SessionIdPayload>): string | null {
177+
let payload: SessionIdPayload | undefined;
178+
174179
const existing = cache.get(sessionId);
175180
if (existing) {
176-
// Merge updates into existing payload
177-
Object.assign(existing, updates);
178-
// Re-set to refresh TTL
179-
cache.set(sessionId, existing);
180-
return true;
181+
payload = existing;
182+
} else {
183+
const decrypted = safeDecrypt(sessionId);
184+
if (hasValidSessionStructure(decrypted) || isValidPublicSessionPayload(decrypted)) {
185+
payload = decrypted as SessionIdPayload;
186+
}
181187
}
182188

183-
// Try to decrypt and update if not in cache
184-
const decrypted = safeDecrypt(sessionId);
185-
if (hasValidSessionStructure(decrypted) || isValidPublicSessionPayload(decrypted)) {
186-
const payload = decrypted as SessionIdPayload;
187-
Object.assign(payload, updates);
188-
cache.set(sessionId, payload);
189-
return true;
190-
}
189+
if (!payload) return null;
190+
191+
// Merge updates into payload
192+
Object.assign(payload, updates);
193+
194+
// Re-encrypt to produce a new session ID carrying the updated payload
195+
const newSessionId = encryptJson(payload);
196+
197+
// Cache under BOTH old and new session IDs so lookups by either key work
198+
cache.set(sessionId, payload);
199+
cache.set(newSessionId, payload);
191200

192-
return false;
201+
return newSessionId;
193202
}
194203

195204
/**

libs/sdk/src/transport/adapters/transport.local.adapter.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,28 @@ export abstract class LocalTransportAdapter<T extends SupportedTransport> {
3434
*/
3535
protected pendingElicit?: PendingElicit;
3636

37+
/**
38+
* Session payload fields set during MCP initialize.
39+
* Stored on the adapter instance so they survive across requests in SSE mode
40+
* (where each POST creates a fresh anonymous HTTP session) and are available
41+
* in distributed environments without relying on local in-memory caches.
42+
*/
43+
private initSessionPayload?: Partial<{
44+
supportsElicitation: boolean;
45+
platformType: string;
46+
clientName: string;
47+
clientVersion: string;
48+
}>;
49+
50+
/**
51+
* Called by the initialize request handler to persist initialization data
52+
* on the transport adapter instance. This data is merged into the session
53+
* payload on every subsequent request via ensureAuthInfo().
54+
*/
55+
setInitSessionPayload(payload: NonNullable<LocalTransportAdapter<T>['initSessionPayload']>): void {
56+
this.initSessionPayload = payload;
57+
}
58+
3759
#requestId = 1;
3860
ready: Promise<void>;
3961
server: McpServer;
@@ -99,6 +121,8 @@ export abstract class LocalTransportAdapter<T extends SupportedTransport> {
99121
// load asynchronously after connection and may emit change notifications
100122
const remoteCapabilities = hasRemoteApps ? this.buildRemoteCapabilities() : {};
101123

124+
const elicitationCapability = this.scope.metadata.elicitation?.enabled ? { elicitation: {} } : {};
125+
102126
const serverOptions = {
103127
instructions: '',
104128
capabilities: {
@@ -110,6 +134,7 @@ export abstract class LocalTransportAdapter<T extends SupportedTransport> {
110134
...completionsCapability,
111135
// MCP logging protocol support - allows clients to set log level via logging/setLevel
112136
logging: {},
137+
...elicitationCapability,
113138
},
114139
serverInfo: info,
115140
};
@@ -202,6 +227,28 @@ export abstract class LocalTransportAdapter<T extends SupportedTransport> {
202227
const sessionId = session?.id ?? `fallback:${Date.now()}`;
203228
const sessionPayload = session?.payload ?? { protocol: 'streamable-http' as const };
204229

230+
// Enrich session payload with initialization data stored on the adapter.
231+
// In SSE mode, each HTTP request creates a fresh anonymous session, so
232+
// fields set during MCP initialize (supportsElicitation, platformType, etc.)
233+
// are lost. The adapter instance persists for the transport's lifetime, so
234+
// we merge the initialization payload here. This works in distributed mode
235+
// too: the adapter is always on the node that owns the transport session.
236+
if (this.initSessionPayload) {
237+
const init = this.initSessionPayload;
238+
if (init.supportsElicitation !== undefined && sessionPayload.supportsElicitation === undefined) {
239+
sessionPayload.supportsElicitation = init.supportsElicitation;
240+
}
241+
if (init.platformType !== undefined && sessionPayload.platformType === undefined) {
242+
(sessionPayload as Record<string, unknown>)['platformType'] = init.platformType;
243+
}
244+
if (init.clientName !== undefined && sessionPayload.clientName === undefined) {
245+
sessionPayload.clientName = init.clientName;
246+
}
247+
if (init.clientVersion !== undefined && sessionPayload.clientVersion === undefined) {
248+
sessionPayload.clientVersion = init.clientVersion;
249+
}
250+
}
251+
205252
const authInfo: SdkAuthInfo = {
206253
token,
207254
user,

libs/sdk/src/transport/mcp-handlers/initialize-request.handler.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { UnsupportedClientVersionError } from '../../errors';
55
import type { ClientCapabilities } from '../../notification';
66
import { detectPlatformFromCapabilities, detectAIPlatform, supportsElicitation } from '../../notification';
77
import { updateSessionPayload } from '../../auth/session/utils/session-id.utils';
8+
import type { SdkAuthInfo } from '../../server/server.types';
89

910
/**
1011
* Validates that the client's protocol version is a valid date string format.
@@ -102,14 +103,23 @@ export default function initializeRequestHandler({
102103
ctx.authInfo.sessionIdPayload.platformType = finalPlatform;
103104
}
104105

105-
// Persist to session cache so subsequent requests can access client info
106-
// This is critical for HTTP transports where sessions are parsed from encrypted headers
107-
updateSessionPayload(sessionId, {
106+
const initPayload = {
108107
clientName,
109108
clientVersion,
110109
supportsElicitation: clientSupportsElicitation,
111110
...(finalPlatform && { platformType: finalPlatform }),
112-
});
111+
};
112+
113+
// Persist to session cache so subsequent requests can access client info
114+
// This is critical for HTTP transports where sessions are parsed from encrypted headers
115+
updateSessionPayload(sessionId, initPayload);
116+
117+
// Persist initialization data on the transport adapter instance.
118+
// This ensures the data survives across SSE requests (fresh HTTP sessions)
119+
// and works in distributed environments (Vercel Edge, Cloudflare Workers)
120+
// where the encrypted session payload carries the correct initialization state.
121+
const transport = (ctx.authInfo as SdkAuthInfo)?.transport;
122+
transport?.setInitSessionPayload(initPayload);
113123
}
114124
} else if (ctx.authInfo?.sessionIdPayload) {
115125
// Update platform and elicitation support even without client info
@@ -118,11 +128,17 @@ export default function initializeRequestHandler({
118128
ctx.authInfo.sessionIdPayload.platformType = detectedPlatform;
119129
}
120130

121-
// Persist to session cache
122-
updateSessionPayload(sessionId, {
131+
const initPayload = {
123132
supportsElicitation: clientSupportsElicitation,
124133
...(detectedPlatform && { platformType: detectedPlatform }),
125-
});
134+
};
135+
136+
// Persist to session cache
137+
updateSessionPayload(sessionId, initPayload);
138+
139+
// Persist on transport adapter instance
140+
const transport = (ctx.authInfo as SdkAuthInfo)?.transport;
141+
transport?.setInitSessionPayload(initPayload);
126142
}
127143
}
128144

0 commit comments

Comments
 (0)