Skip to content

Commit d9dff46

Browse files
committed
Small helper to build completion notifications
1 parent 71af07b commit d9dff46

File tree

3 files changed

+130
-41
lines changed

3 files changed

+130
-41
lines changed

src/examples/server/elicitationStreamableHttp.ts

Lines changed: 27 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,7 @@ import { McpServer } from '../../server/mcp.js';
55
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
66
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
77
import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js';
8-
import {
9-
CallToolResult,
10-
UrlElicitationRequiredError,
11-
ElicitRequestURLParams,
12-
ElicitResult,
13-
isInitializeRequest,
14-
ElicitationCompleteNotification
15-
} from '../../types.js';
8+
import { CallToolResult, UrlElicitationRequiredError, ElicitRequestURLParams, ElicitResult, isInitializeRequest } from '../../types.js';
169
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
1710
import { setupAuthServer } from './demoInMemoryOAuthProvider.js';
1811
import { OAuthMetadata } from '../../shared/auth.js';
@@ -51,9 +44,9 @@ const getServer = () => {
5144
}
5245

5346
// Create and track the elicitation
54-
const elicitationId = generateTrackedElicitation(sessionId, async (notification: ElicitationCompleteNotification) => {
55-
await mcpServer.server.notification(notification);
56-
});
47+
const elicitationId = generateTrackedElicitation(sessionId, elicitationId =>
48+
mcpServer.server.createElicitationCompletionNotifier(elicitationId)
49+
);
5750
throw new UrlElicitationRequiredError([
5851
{
5952
mode: 'url',
@@ -87,9 +80,9 @@ const getServer = () => {
8780
}
8881

8982
// Create and track the elicitation
90-
const elicitationId = generateTrackedElicitation(sessionId, async (notification: ElicitationCompleteNotification) => {
91-
await mcpServer.server.notification(notification);
92-
});
83+
const elicitationId = generateTrackedElicitation(sessionId, elicitationId =>
84+
mcpServer.server.createElicitationCompletionNotifier(elicitationId)
85+
);
9386

9487
// Simulate OAuth callback and token exchange after 5 seconds
9588
// In a real app, this would be called from your OAuth callback handler
@@ -122,7 +115,7 @@ interface ElicitationMetadata {
122115
completeResolver: () => void;
123116
createdAt: Date;
124117
sessionId: string;
125-
notificationSender?: (notification: ElicitationCompleteNotification) => Promise<void>;
118+
completionNotifier?: () => Promise<void>;
126119
}
127120

128121
const elicitationsMap = new Map<string, ElicitationMetadata>();
@@ -154,10 +147,7 @@ function generateElicitationId(): string {
154147
/**
155148
* Helper function to create and track a new elicitation.
156149
*/
157-
function generateTrackedElicitation(
158-
sessionId: string,
159-
notificationSender?: (notification: ElicitationCompleteNotification) => Promise<void>
160-
): string {
150+
function generateTrackedElicitation(sessionId: string, createCompletionNotifier?: ElicitationCompletionNotifierFactory): string {
161151
const elicitationId = generateElicitationId();
162152

163153
// Create a Promise and its resolver for tracking completion
@@ -166,14 +156,16 @@ function generateTrackedElicitation(
166156
completeResolver = resolve;
167157
});
168158

159+
const completionNotifier = createCompletionNotifier ? createCompletionNotifier(elicitationId) : undefined;
160+
169161
// Store the elicitation in our map
170162
elicitationsMap.set(elicitationId, {
171163
status: 'pending',
172164
completedPromise,
173165
completeResolver: completeResolver!,
174166
createdAt: new Date(),
175167
sessionId,
176-
notificationSender
168+
completionNotifier
177169
});
178170

179171
return elicitationId;
@@ -198,19 +190,12 @@ function completeURLElicitation(elicitationId: string) {
198190
elicitation.status = 'complete';
199191

200192
// Send completion notification to the client
201-
if (elicitation.notificationSender) {
193+
if (elicitation.completionNotifier) {
202194
console.log(`Sending notifications/elicitation/complete notification for elicitation ${elicitationId}`);
203195

204-
elicitation
205-
.notificationSender({
206-
method: 'notifications/elicitation/complete',
207-
params: {
208-
elicitationId
209-
}
210-
})
211-
.catch(error => {
212-
console.error(`Failed to send completion notification for elicitation ${elicitationId}:`, error);
213-
});
196+
elicitation.completionNotifier().catch(error => {
197+
console.error(`Failed to send completion notification for elicitation ${elicitationId}:`, error);
198+
});
214199
}
215200

216201
// Resolve the promise to unblock any waiting code
@@ -303,14 +288,18 @@ authMiddleware = requireBearerAuth({
303288
* URL-mode elicitation enables the server to host a simple form and get the secret data securely from the user without involving the LLM or client.
304289
**/
305290

306-
async function sendApiKeyElicitation(sessionId: string, sender: ElicitationSender, notificationSender: ElicitationNotificationSender) {
291+
async function sendApiKeyElicitation(
292+
sessionId: string,
293+
sender: ElicitationSender,
294+
createCompletionNotifier: ElicitationCompletionNotifierFactory
295+
) {
307296
if (!sessionId) {
308297
console.error('No session ID provided');
309298
throw new Error('Expected a Session ID to track elicitation');
310299
}
311300

312301
console.log('🔑 URL elicitation demo: Requesting API key from client...');
313-
const elicitationId = generateTrackedElicitation(sessionId, notificationSender);
302+
const elicitationId = generateTrackedElicitation(sessionId, createCompletionNotifier);
314303
try {
315304
const result = await sender({
316305
mode: 'url',
@@ -593,12 +582,12 @@ const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
593582

594583
// Interface for a function that can send an elicitation request
595584
type ElicitationSender = (params: ElicitRequestURLParams) => Promise<ElicitResult>;
596-
type ElicitationNotificationSender = (notification: ElicitationCompleteNotification) => Promise<void>;
585+
type ElicitationCompletionNotifierFactory = (elicitationId: string) => () => Promise<void>;
597586

598587
// Track sessions that need an elicitation request to be sent
599588
interface SessionElicitationInfo {
600589
elicitationSender: ElicitationSender;
601-
notificationSender: ElicitationNotificationSender;
590+
createCompletionNotifier: ElicitationCompletionNotifierFactory;
602591
}
603592
const sessionsNeedingElicitation: { [sessionId: string]: SessionElicitationInfo } = {};
604593

@@ -626,9 +615,7 @@ const mcpPostHandler = async (req: Request, res: Response) => {
626615
transports[sessionId] = transport;
627616
sessionsNeedingElicitation[sessionId] = {
628617
elicitationSender: server.server.elicitUrl.bind(server.server),
629-
notificationSender: async (notification: ElicitationCompleteNotification) => {
630-
await server.server.notification(notification);
631-
}
618+
createCompletionNotifier: elicitationId => server.server.createElicitationCompletionNotifier(elicitationId)
632619
};
633620
}
634621
});
@@ -703,10 +690,10 @@ const mcpGetHandler = async (req: Request, res: Response) => {
703690
await transport.handleRequest(req, res);
704691

705692
if (sessionsNeedingElicitation[sessionId]) {
706-
const { elicitationSender, notificationSender } = sessionsNeedingElicitation[sessionId];
693+
const { elicitationSender, createCompletionNotifier } = sessionsNeedingElicitation[sessionId];
707694

708695
// Send an elicitation request to the client in the background
709-
sendApiKeyElicitation(sessionId, elicitationSender, notificationSender)
696+
sendApiKeyElicitation(sessionId, elicitationSender, createCompletionNotifier)
710697
.then(() => {
711698
// Only delete on successful send for this demo
712699
delete sessionsNeedingElicitation[sessionId];

src/server/index.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { Transport } from '../shared/transport.js';
66
import {
77
CreateMessageRequestSchema,
88
ElicitRequestSchema,
9+
ElicitationCompleteNotificationSchema,
910
ErrorCode,
1011
LATEST_PROTOCOL_VERSION,
1112
ListPromptsRequestSchema,
@@ -346,6 +347,82 @@ test('should respect client elicitation capabilities', async () => {
346347
).rejects.toThrow(/^Client does not support/);
347348
});
348349

350+
test('should create notifier that emits elicitation completion notification', async () => {
351+
const server = new Server(
352+
{
353+
name: 'test server',
354+
version: '1.0'
355+
},
356+
{
357+
capabilities: {}
358+
}
359+
);
360+
361+
const client = new Client(
362+
{
363+
name: 'test client',
364+
version: '1.0'
365+
},
366+
{
367+
capabilities: {
368+
elicitation: {
369+
url: {}
370+
}
371+
}
372+
}
373+
);
374+
375+
const receivedIds: string[] = [];
376+
client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => {
377+
receivedIds.push(notification.params.elicitationId);
378+
});
379+
380+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
381+
382+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
383+
384+
const notifier = server.createElicitationCompletionNotifier('elicitation-123');
385+
await notifier();
386+
387+
await new Promise(resolve => setTimeout(resolve, 0));
388+
389+
expect(receivedIds).toEqual(['elicitation-123']);
390+
});
391+
392+
test('should throw when creating notifier if client lacks URL elicitation support', async () => {
393+
const server = new Server(
394+
{
395+
name: 'test server',
396+
version: '1.0'
397+
},
398+
{
399+
capabilities: {}
400+
}
401+
);
402+
403+
const client = new Client(
404+
{
405+
name: 'test client',
406+
version: '1.0'
407+
},
408+
{
409+
capabilities: {
410+
elicitation: {
411+
form: {}
412+
}
413+
}
414+
}
415+
);
416+
417+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
418+
419+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
420+
421+
expect(() => server.createElicitationCompletionNotifier('elicitation-123')).toThrow(
422+
'Client does not support URL elicitation (required for notifications/elicitation/complete)'
423+
);
424+
});
425+
349426
test('should apply back-compat form capability injection when client sends empty elicitation object', async () => {
350427
const server = new Server(
351428
{

src/server/index.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
1+
import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js';
22
import {
33
type ClientCapabilities,
44
type CreateMessageRequest,
@@ -369,6 +369,31 @@ export class Server<
369369
return result;
370370
}
371371

372+
/**
373+
* Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete`
374+
* notification for the specified elicitation ID.
375+
*
376+
* @param elicitationId The ID of the elicitation to mark as complete.
377+
* @param options Optional notification options. Useful when the completion notification should be related to a prior request.
378+
* @returns A function that emits the completion notification when awaited.
379+
*/
380+
createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions): () => Promise<void> {
381+
if (!this._clientCapabilities?.elicitation?.url) {
382+
throw new Error('Client does not support URL elicitation (required for notifications/elicitation/complete)');
383+
}
384+
385+
return () =>
386+
this.notification(
387+
{
388+
method: 'notifications/elicitation/complete',
389+
params: {
390+
elicitationId
391+
}
392+
},
393+
options
394+
);
395+
}
396+
372397
async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) {
373398
return this.request({ method: 'roots/list', params }, ListRootsResultSchema, options);
374399
}

0 commit comments

Comments
 (0)