Skip to main content
Glama
iceener

Spotify Streamable MCP Server

by iceener
elicitation.ts18.2 kB
/** * Elicitation utilities for servers to request user input from clients. * * ⚠️ NODE.JS ONLY - These utilities require SDK bidirectional support * (server.request()) which is not available in the Cloudflare Workers runtime. * The Workers dispatcher does not support server→client requests. * * Two modes: * - Form: Structured input via a schema (text fields, checkboxes, dropdowns) * - URL: Redirect user to external URL for out-of-band interaction * * Per MCP spec: * - Elicitation is a CLIENT capability * - Servers send elicitation/create requests TO clients * - Clients display the form/URL and return user response */ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { logger } from './logger.js'; // ───────────────────────────────────────────────────────────────────────────── // Schema Types // ───────────────────────────────────────────────────────────────────────────── /** Boolean field schema */ export interface BooleanFieldSchema { type: 'boolean'; title?: string; description?: string; default?: boolean; } /** String field schema */ export interface StringFieldSchema { type: 'string'; title?: string; description?: string; minLength?: number; maxLength?: number; format?: 'email' | 'uri' | 'date' | 'date-time'; default?: string; } /** Number field schema */ export interface NumberFieldSchema { type: 'number' | 'integer'; title?: string; description?: string; minimum?: number; maximum?: number; default?: number; } /** Single-select enum with titles (preferred) */ export interface TitledEnumFieldSchema { type: 'string'; title?: string; description?: string; oneOf: Array<{ const: string; title: string }>; default?: string; } /** Single-select enum without titles */ export interface UntitledEnumFieldSchema { type: 'string'; title?: string; description?: string; enum: string[]; default?: string; } /** Multi-select enum */ export interface MultiSelectFieldSchema { type: 'array'; title?: string; description?: string; minItems?: number; maxItems?: number; items: | { type: 'string'; enum: string[]; } | { anyOf: Array<{ const: string; title: string }>; }; default?: string[]; } /** All supported field schema types */ export type FieldSchema = | BooleanFieldSchema | StringFieldSchema | NumberFieldSchema | TitledEnumFieldSchema | UntitledEnumFieldSchema | MultiSelectFieldSchema; /** Schema for form elicitation (flat object with primitive fields only) */ export interface ElicitationSchema { type: 'object'; properties: Record<string, FieldSchema>; required?: string[]; } // ───────────────────────────────────────────────────────────────────────────── // Request Types // ───────────────────────────────────────────────────────────────────────────── /** Form elicitation request */ export interface FormElicitationRequest { mode?: 'form'; message: string; requestedSchema: ElicitationSchema; } /** URL elicitation request */ export interface UrlElicitationRequest { mode: 'url'; message: string; elicitationId: string; url: string; } export type ElicitationRequest = FormElicitationRequest | UrlElicitationRequest; // ───────────────────────────────────────────────────────────────────────────── // Response Types // ───────────────────────────────────────────────────────────────────────────── /** User's response to elicitation */ export interface ElicitResult { action: 'accept' | 'decline' | 'cancel'; content?: Record<string, string | number | boolean | string[]>; } // ───────────────────────────────────────────────────────────────────────────── // Zod Schemas for validation // ───────────────────────────────────────────────────────────────────────────── export const ElicitResultSchema = z.object({ action: z.enum(['accept', 'decline', 'cancel']), content: z .record(z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])) .optional(), }); // ───────────────────────────────────────────────────────────────────────────── // Schema Validation // ───────────────────────────────────────────────────────────────────────────── /** * Validate that elicitation schema is flat (no nested objects/arrays of objects). * Per MCP spec: requestedSchema must have type: 'object' at root with only * primitive properties (no nesting). * * @throws Error if schema contains nested objects or invalid structure */ export function validateElicitationSchema(schema: ElicitationSchema): void { if (schema.type !== 'object') { throw new Error('Elicitation schema must have type: "object" at root'); } if (!schema.properties || typeof schema.properties !== 'object') { throw new Error('Elicitation schema must have a "properties" object'); } for (const [fieldName, fieldSchema] of Object.entries(schema.properties)) { // Check for nested objects if ('properties' in fieldSchema) { throw new Error( `Nested objects not allowed in elicitation schema (field: "${fieldName}"). ` + 'Only primitive types (string, number, integer, boolean) and enums are supported.', ); } // Check for arrays with object items (only string enums allowed) if (fieldSchema.type === 'array' && 'items' in fieldSchema) { const items = fieldSchema.items as Record<string, unknown>; if (items.type === 'object' || 'properties' in items) { throw new Error( `Array of objects not allowed in elicitation schema (field: "${fieldName}"). ` + 'Only arrays with string enum items are supported for multi-select.', ); } } // Validate allowed types const allowedTypes = ['boolean', 'string', 'number', 'integer', 'array']; if (!allowedTypes.includes(fieldSchema.type)) { throw new Error( `Invalid field type "${fieldSchema.type}" in elicitation schema (field: "${fieldName}"). ` + `Allowed types: ${allowedTypes.join(', ')}`, ); } } } // ───────────────────────────────────────────────────────────────────────────── // Capability Checking // ───────────────────────────────────────────────────────────────────────────── /** * Check if client supports form elicitation. */ export function clientSupportsFormElicitation(server: McpServer): boolean { try { // biome-ignore lint/suspicious/noExplicitAny: accessing private SDK property const lowLevel = (server as any).server ?? server; const clientCapabilities = lowLevel.getClientCapabilities?.() ?? {}; // Empty elicitation object is treated as { form: {} } return Boolean(clientCapabilities.elicitation); } catch { return false; } } /** * Check if client supports URL elicitation. */ export function clientSupportsUrlElicitation(server: McpServer): boolean { try { // biome-ignore lint/suspicious/noExplicitAny: accessing private SDK property const lowLevel = (server as any).server ?? server; const clientCapabilities = lowLevel.getClientCapabilities?.() ?? {}; return Boolean(clientCapabilities.elicitation?.url); } catch { return false; } } // ───────────────────────────────────────────────────────────────────────────── // Main Elicitation Functions // ───────────────────────────────────────────────────────────────────────────── /** * Request user input via form elicitation. * * @example * ```typescript * const result = await elicitForm(server, { * message: 'Configure your preferences:', * requestedSchema: { * type: 'object', * properties: { * apiKey: { type: 'string', title: 'API Key' }, * enabled: { type: 'boolean', title: 'Enable feature', default: true }, * theme: { * type: 'string', * title: 'Theme', * oneOf: [ * { const: 'light', title: 'Light' }, * { const: 'dark', title: 'Dark' } * ] * } * }, * required: ['apiKey'] * } * }); * * if (result.action === 'accept') { * console.log('API Key:', result.content?.apiKey); * } * ``` */ export async function elicitForm( server: McpServer, request: FormElicitationRequest, ): Promise<ElicitResult> { if (!clientSupportsFormElicitation(server)) { logger.warning('elicitation', { message: 'Client does not support form elicitation', }); throw new Error('Client does not support form elicitation'); } // Validate schema is flat (no nested objects) per MCP spec validateElicitationSchema(request.requestedSchema); logger.debug('elicitation', { message: 'Requesting form elicitation', fieldCount: Object.keys(request.requestedSchema.properties).length, }); try { // biome-ignore lint/suspicious/noExplicitAny: accessing private SDK property const lowLevel = (server as any).server ?? server; if (!lowLevel.request) { throw new Error('Server does not support client requests'); } const response = await lowLevel.request( { method: 'elicitation/create', params: { mode: 'form', message: request.message, requestedSchema: request.requestedSchema, }, }, ElicitResultSchema, ); logger.info('elicitation', { message: 'Form elicitation completed', action: response.action, }); return response; } catch (error) { logger.error('elicitation', { message: 'Form elicitation failed', error: (error as Error).message, }); throw error; } } /** * Request user interaction via URL elicitation. * * @example * ```typescript * const elicitationId = crypto.randomUUID(); * * const result = await elicitUrl(server, { * message: 'Please complete authentication:', * elicitationId, * url: 'https://auth.example.com/oauth/authorize?state=xyz' * }); * * // After external callback completes: * await notifyElicitationComplete(server, elicitationId); * ``` */ export async function elicitUrl( server: McpServer, request: Omit<UrlElicitationRequest, 'mode'>, ): Promise<ElicitResult> { if (!clientSupportsUrlElicitation(server)) { logger.warning('elicitation', { message: 'Client does not support URL elicitation', }); throw new Error('Client does not support URL elicitation'); } logger.debug('elicitation', { message: 'Requesting URL elicitation', elicitationId: request.elicitationId, url: request.url, }); try { // biome-ignore lint/suspicious/noExplicitAny: accessing private SDK property const lowLevel = (server as any).server ?? server; if (!lowLevel.request) { throw new Error('Server does not support client requests'); } const response = await lowLevel.request( { method: 'elicitation/create', params: { mode: 'url', message: request.message, elicitationId: request.elicitationId, url: request.url, }, }, ElicitResultSchema, ); logger.info('elicitation', { message: 'URL elicitation completed', action: response.action, elicitationId: request.elicitationId, }); return response; } catch (error) { logger.error('elicitation', { message: 'URL elicitation failed', error: (error as Error).message, elicitationId: request.elicitationId, }); throw error; } } /** * Notify client that URL elicitation has completed (external flow finished). */ export async function notifyElicitationComplete( server: McpServer, elicitationId: string, ): Promise<void> { if (!clientSupportsUrlElicitation(server)) { throw new Error('Client does not support URL elicitation notifications'); } logger.debug('elicitation', { message: 'Sending elicitation complete notification', elicitationId, }); try { // biome-ignore lint/suspicious/noExplicitAny: accessing private SDK property const lowLevel = (server as any).server ?? server; await lowLevel.notification?.({ method: 'notifications/elicitation/complete', params: { elicitationId }, }); logger.info('elicitation', { message: 'Elicitation complete notification sent', elicitationId, }); } catch (error) { logger.error('elicitation', { message: 'Failed to send elicitation complete notification', error: (error as Error).message, elicitationId, }); throw error; } } // ───────────────────────────────────────────────────────────────────────────── // Convenience Helpers // ───────────────────────────────────────────────────────────────────────────── /** * Request a simple confirmation from the user. * * @example * ```typescript * const confirmed = await confirm(server, 'Delete all items?'); * if (confirmed) { * // proceed with deletion * } * ``` */ export async function confirm( server: McpServer, message: string, options?: { confirmLabel?: string; declineLabel?: string }, ): Promise<boolean> { const result = await elicitForm(server, { message, requestedSchema: { type: 'object', properties: { confirmed: { type: 'boolean', title: options?.confirmLabel ?? 'Confirm', default: false, }, }, }, }); return result.action === 'accept' && result.content?.confirmed === true; } /** * Request a single text input from the user. * * @example * ```typescript * const apiKey = await promptText(server, 'Enter your API key:', { * title: 'API Key', * required: true * }); * * if (apiKey) { * // use the API key * } * ``` */ export async function promptText( server: McpServer, message: string, options?: { title?: string; description?: string; defaultValue?: string; required?: boolean; minLength?: number; maxLength?: number; }, ): Promise<string | undefined> { const result = await elicitForm(server, { message, requestedSchema: { type: 'object', properties: { value: { type: 'string', title: options?.title ?? 'Value', description: options?.description, default: options?.defaultValue, minLength: options?.minLength, maxLength: options?.maxLength, }, }, ...(options?.required && { required: ['value'] }), }, }); if (result.action === 'accept') { return result.content?.value as string | undefined; } return undefined; } /** * Request a selection from a list of options. * * @example * ```typescript * const choice = await promptSelect(server, 'Choose a model:', [ * { value: 'gpt-4', label: 'GPT-4 (Best quality)' }, * { value: 'gpt-3.5', label: 'GPT-3.5 (Faster)' }, * { value: 'claude', label: 'Claude (Alternative)' } * ]); * * if (choice) { * console.log('Selected:', choice); * } * ``` */ export async function promptSelect( server: McpServer, message: string, options: Array<{ value: string; label: string }>, config?: { title?: string; defaultValue?: string; required?: boolean }, ): Promise<string | undefined> { const result = await elicitForm(server, { message, requestedSchema: { type: 'object', properties: { selection: { type: 'string', title: config?.title ?? 'Selection', oneOf: options.map((opt) => ({ const: opt.value, title: opt.label })), default: config?.defaultValue, }, }, ...(config?.required && { required: ['selection'] }), }, }); if (result.action === 'accept') { return result.content?.selection as string | undefined; } return undefined; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/iceener/spotify-streamable-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server