Skip to main content
Glama
elicitation.js15.1 kB
// src/server/elicitation.js // MCP 2025-11-25 URL Mode Elicitation (SEP-1036) // Secure credential acquisition via browser-based OAuth flows const { v4: uuidv4 } = require('uuid'); const config = require('../../config'); // Error code for elicitation required const ELICITATION_REQUIRED_ERROR_CODE = -32042; class ElicitationHandler { constructor() { this.pendingElicitations = new Map(); this.urlModeEnabled = config.mcp?.features?.elicitation?.url !== false; this.formModeEnabled = config.mcp?.features?.elicitation?.form !== false; // Cleanup old elicitations periodically setInterval(() => this.cleanupExpired(), 300000); // Every 5 minutes } /** * Check if URL mode elicitation is enabled * @returns {boolean} */ isUrlModeEnabled() { return this.urlModeEnabled; } /** * Check if form mode elicitation is enabled * @returns {boolean} */ isFormModeEnabled() { return this.formModeEnabled; } /** * Create a URL mode elicitation request * For OAuth flows, API key collection, or external authorization * * @param {string} url - The URL to open for the user (must be HTTPS) * @param {string} message - Human-readable explanation * @param {Object} options - Additional options * @returns {Object} Elicitation request object */ createUrlElicitation(url, message, options = {}) { if (!this.urlModeEnabled) { throw new Error('URL mode elicitation is disabled'); } // Validate HTTPS (except for localhost in dev) const isLocalhost = url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1'); const isDev = process.env.NODE_ENV === 'development'; if (!url.startsWith('https://') && !(isDev && isLocalhost)) { throw new Error('URL elicitation requires HTTPS URLs'); } const elicitationId = uuidv4(); const expiresAt = Date.now() + (options.ttlMs || 600000); // 10 minutes default const elicitation = { mode: 'url', elicitationId, url, message, status: 'pending', createdAt: new Date().toISOString(), expiresAt: new Date(expiresAt).toISOString(), metadata: options.metadata || {} }; this.pendingElicitations.set(elicitationId, elicitation); process.stderr.write(`[${new Date().toISOString()}] Elicitation: Created URL elicitation ${elicitationId}\n`); return { method: 'elicitation/create', params: { mode: 'url', elicitationId, url, message } }; } /** * Create a form mode elicitation request * For collecting structured input from the user * * @param {Array} schema - JSON Schema for form fields * @param {string} message - Human-readable explanation * @param {Object} options - Additional options * @returns {Object} Elicitation request object */ createFormElicitation(schema, message, options = {}) { if (!this.formModeEnabled) { throw new Error('Form mode elicitation is disabled'); } const elicitationId = uuidv4(); const expiresAt = Date.now() + (options.ttlMs || 600000); // 10 minutes default const elicitation = { mode: 'form', elicitationId, schema, message, status: 'pending', createdAt: new Date().toISOString(), expiresAt: new Date(expiresAt).toISOString(), metadata: options.metadata || {} }; this.pendingElicitations.set(elicitationId, elicitation); process.stderr.write(`[${new Date().toISOString()}] Elicitation: Created form elicitation ${elicitationId}\n`); return { method: 'elicitation/create', params: { mode: 'form', elicitationId, schema, message } }; } /** * Get an elicitation by ID * @param {string} elicitationId - Elicitation ID * @returns {Object|null} Elicitation object or null */ getElicitation(elicitationId) { return this.pendingElicitations.get(elicitationId) || null; } /** * Handle user response to an elicitation * @param {string} elicitationId - Elicitation ID * @param {string} action - User action: 'accept', 'decline', 'cancel' * @param {Object} data - Response data (for form mode) * @returns {Object} Response result */ handleElicitationResponse(elicitationId, action, data = null) { const elicitation = this.pendingElicitations.get(elicitationId); if (!elicitation) { return { success: false, error: 'Unknown elicitation' }; } if (elicitation.status !== 'pending') { return { success: false, error: 'Elicitation already processed' }; } // Check expiration if (new Date(elicitation.expiresAt) < new Date()) { elicitation.status = 'expired'; return { success: false, error: 'Elicitation expired' }; } // Valid actions const validActions = ['accept', 'decline', 'cancel']; if (!validActions.includes(action)) { return { success: false, error: `Invalid action: ${action}` }; } // Update elicitation status elicitation.status = action === 'accept' ? 'completed' : action; elicitation.completedAt = new Date().toISOString(); // Store response data for form mode if (action === 'accept' && data) { elicitation.responseData = data; } process.stderr.write(`[${new Date().toISOString()}] Elicitation: ${elicitationId} responded with ${action}\n`); return { success: true, action, elicitationId }; } /** * Complete an elicitation after external process finishes * (e.g., OAuth callback received) * * @param {string} elicitationId - Elicitation ID * @param {Object} data - Completion data * @returns {Object} Completion result */ completeElicitation(elicitationId, data = {}) { const elicitation = this.pendingElicitations.get(elicitationId); if (!elicitation) { return { success: false, error: 'Unknown elicitation' }; } elicitation.status = 'completed'; elicitation.completedAt = new Date().toISOString(); elicitation.responseData = data; process.stderr.write(`[${new Date().toISOString()}] Elicitation: ${elicitationId} completed externally\n`); // Schedule cleanup setTimeout(() => { this.pendingElicitations.delete(elicitationId); }, 3600000); // Keep for 1 hour for retrieval return { success: true, elicitationId }; } /** * Create an ElicitationRequired error response * Used when an operation requires user input before proceeding * * @param {Array} elicitations - Array of elicitation requests * @returns {Object} JSON-RPC error object */ createElicitationRequiredError(elicitations) { return { code: ELICITATION_REQUIRED_ERROR_CODE, message: 'This request requires additional information from the user.', data: { elicitations: Array.isArray(elicitations) ? elicitations : [elicitations] } }; } /** * Check if an error is an ElicitationRequired error * @param {Object} error - Error object * @returns {boolean} */ isElicitationRequiredError(error) { return error?.code === ELICITATION_REQUIRED_ERROR_CODE; } /** * Get the result data from a completed elicitation * @param {string} elicitationId - Elicitation ID * @returns {Object|null} Response data or null */ getElicitationResult(elicitationId) { const elicitation = this.pendingElicitations.get(elicitationId); if (!elicitation || elicitation.status !== 'completed') { return null; } return elicitation.responseData; } /** * Wait for an elicitation to complete * @param {string} elicitationId - Elicitation ID * @param {number} timeoutMs - Timeout in milliseconds * @returns {Promise<Object>} Elicitation result */ async waitForElicitation(elicitationId, timeoutMs = 300000) { const startTime = Date.now(); const pollInterval = 1000; // 1 second while (Date.now() - startTime < timeoutMs) { const elicitation = this.pendingElicitations.get(elicitationId); if (!elicitation) { throw new Error('Elicitation not found'); } if (elicitation.status === 'completed') { return { success: true, data: elicitation.responseData }; } if (elicitation.status === 'declined' || elicitation.status === 'cancel') { return { success: false, action: elicitation.status }; } if (elicitation.status === 'expired') { return { success: false, error: 'Elicitation expired' }; } await new Promise(resolve => setTimeout(resolve, pollInterval)); } return { success: false, error: 'Timeout waiting for elicitation' }; } /** * Cleanup expired elicitations */ cleanupExpired() { const now = Date.now(); let cleaned = 0; for (const [id, elicitation] of this.pendingElicitations) { const expiresAt = new Date(elicitation.expiresAt).getTime(); const completedAt = elicitation.completedAt ? new Date(elicitation.completedAt).getTime() : null; // Remove if expired or completed more than 1 hour ago if (expiresAt < now || (completedAt && (now - completedAt) > 3600000)) { this.pendingElicitations.delete(id); cleaned++; } } if (cleaned > 0) { process.stderr.write(`[${new Date().toISOString()}] Elicitation: Cleaned up ${cleaned} expired elicitations\n`); } } /** * Get pending elicitation count * @returns {number} */ getPendingCount() { return Array.from(this.pendingElicitations.values()) .filter(e => e.status === 'pending').length; } /** * Get all elicitations (for debugging) * @returns {Array} */ getAllElicitations() { return Array.from(this.pendingElicitations.values()); } // ========================================== // terminals.tech Auth Bridge // ========================================== /** * Create a terminals.tech OAuth elicitation for GitHub * Leverages existing terminals.tech auth infrastructure * * @param {string} provider - Auth provider ('github', 'google', etc.) * @param {Object} options - Additional options * @returns {Object} Elicitation request with auth URL */ createTerminalsAuthElicitation(provider = 'github', options = {}) { if (!this.urlModeEnabled) { throw new Error('URL mode elicitation is disabled'); } const elicitationId = uuidv4(); const state = uuidv4(); // CSRF protection const serverBaseUrl = config.server?.baseUrl || `http://localhost:${config.server?.port || 3001}`; // Build terminals.tech OAuth URL with callback const authParams = new URLSearchParams({ redirect_uri: `${serverBaseUrl}/auth/callback`, scope: options.scope || 'repo', state: state, elicitation_id: elicitationId }); const authUrl = `https://terminals.tech/auth/${provider}?${authParams.toString()}`; const message = options.message || `Sign in with ${provider} via terminals.tech to enable git operations and session sync`; const elicitation = { mode: 'url', elicitationId, url: authUrl, message, status: 'pending', provider, state, // Store state for CSRF validation createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + (options.ttlMs || 600000)).toISOString(), metadata: { ...options.metadata, authProvider: provider, terminalsAuth: true } }; this.pendingElicitations.set(elicitationId, elicitation); process.stderr.write(`[${new Date().toISOString()}] Elicitation: Created terminals.tech ${provider} auth elicitation ${elicitationId}\n`); return { method: 'elicitation/create', params: { mode: 'url', elicitationId, url: authUrl, message }, state // Return state for callback validation }; } /** * Handle OAuth callback from terminals.tech * Called by Express route /auth/callback * * @param {string} code - OAuth authorization code * @param {string} state - CSRF state parameter * @param {string} elicitationId - Elicitation ID from query params * @returns {Object} Result with access token or error */ async handleTerminalsAuthCallback(code, state, elicitationId) { // Find the matching elicitation const elicitation = this.pendingElicitations.get(elicitationId); if (!elicitation) { return { success: false, error: 'Elicitation not found' }; } // Validate CSRF state if (elicitation.state !== state) { return { success: false, error: 'Invalid state parameter (CSRF protection)' }; } // Check expiration if (new Date(elicitation.expiresAt).getTime() < Date.now()) { elicitation.status = 'expired'; return { success: false, error: 'Elicitation expired' }; } try { // Exchange code for token via terminals.tech API const tokenResponse = await fetch('https://terminals.tech/api/auth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, state, elicitation_id: elicitationId }) }); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); throw new Error(`Token exchange failed: ${errorText}`); } const tokenData = await tokenResponse.json(); // Update elicitation with success elicitation.status = 'accepted'; elicitation.completedAt = new Date().toISOString(); elicitation.data = { access_token: tokenData.access_token, token_type: tokenData.token_type || 'Bearer', scope: tokenData.scope, provider: elicitation.provider }; process.stderr.write(`[${new Date().toISOString()}] Elicitation: terminals.tech auth successful for ${elicitationId}\n`); return { success: true, access_token: tokenData.access_token, provider: elicitation.provider }; } catch (error) { elicitation.status = 'failed'; elicitation.error = error.message; process.stderr.write(`[${new Date().toISOString()}] Elicitation: terminals.tech auth failed: ${error.message}\n`); return { success: false, error: error.message }; } } /** * Get stored access token from a completed auth elicitation * * @param {string} elicitationId - Elicitation ID * @returns {string|null} Access token or null */ getAccessToken(elicitationId) { const elicitation = this.pendingElicitations.get(elicitationId); if (elicitation?.status === 'accepted' && elicitation?.data?.access_token) { return elicitation.data.access_token; } return null; } } // Export singleton and error code module.exports = new ElicitationHandler(); module.exports.ELICITATION_REQUIRED_ERROR_CODE = ELICITATION_REQUIRED_ERROR_CODE;

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/wheattoast11/openrouter-deep-research-mcp'

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