Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
elicitation-helper.ts15 kB
/** * Elicitation Helper for User Input via MCP Dialogs * * Provides utilities to show UI dialogs (via MCP elicitation) to collect * sensitive information from users without exposing it in chat. */ import { logger } from './logger.js'; import clipboardy from 'clipboardy'; export interface ElicitationServer { elicitInput(params: { message: string; requestedSchema: { type: 'object'; properties: Record<string, any>; required?: string[]; }; }): Promise<{ action: 'accept' | 'decline' | 'cancel'; content?: Record<string, any>; }>; } /** * Collect a single credential value from user via elicitation dialog * * Shows UI dialog asking user to copy value to clipboard, then reads it server-side. * Secrets never appear in chat! * * @param server MCP server instance with elicitInput capability * @param credentialName Human-readable name (e.g., "GitHub Token", "API Key") * @param envVarName Environment variable name (e.g., "GITHUB_TOKEN") * @param example Optional example value to show user * @returns The credential value from clipboard, or null if user cancelled */ export async function collectCredential( server: ElicitationServer, credentialName: string, envVarName: string, example?: string ): Promise<string | null> { const exampleText = example ? `\n\nExample: ${example}` : ''; const result = await server.elicitInput({ message: `Please provide your ${credentialName} 1. Copy the value to clipboard 2. Click Accept below${exampleText} Your credential will be read from clipboard and never exposed in this chat.`, requestedSchema: { type: 'object', properties: { ready: { type: 'boolean', description: 'I have copied the value to clipboard' } }, required: ['ready'] } }); if (result.action !== 'accept') { logger.info(`User ${result.action} providing ${credentialName}`); return null; } // Read raw value from clipboard try { const value = await clipboardy.read(); if (!value || value.trim().length === 0) { logger.warn(`Clipboard was empty when collecting ${credentialName}`); return null; } logger.info(`Successfully collected ${credentialName} from clipboard (${value.length} chars)`); return value.trim(); } catch (error: any) { logger.error(`Failed to read clipboard for ${credentialName}: ${error.message}`); return null; } } /** * Collect multiple credentials from user, one at a time * * For each credential, shows a dialog and collects the value from clipboard. * Returns a map of environment variable names to values. * * @param server MCP server instance * @param credentials Array of credentials to collect * @returns Map of env var names to values, or null if user cancelled */ export async function collectCredentials( server: ElicitationServer, credentials: Array<{ envVarName: string; displayName: string; example?: string; required?: boolean; }> ): Promise<Record<string, string> | null> { const collected: Record<string, string> = {}; for (const cred of credentials) { const value = await collectCredential( server, cred.displayName, cred.envVarName, cred.example ); if (value === null) { if (cred.required !== false) { // User cancelled and this was required logger.info(`User cancelled collecting required credential: ${cred.envVarName}`); return null; } // Optional credential, skip it continue; } collected[cred.envVarName] = value; } return collected; } /** * Collect ALL credentials for multiple MCPs in ONE form * * Shows a single consolidated elicitation dialog with all credential fields. * Much better UX than separate dialogs for each credential. * * @param server MCP server instance * @param bulkCredentials Map of MCP names to their required credentials * @returns Map of credential keys (mcpName:envVar) to values, or null if cancelled */ export async function collectBulkCredentials( server: ElicitationServer, bulkCredentials: Record<string, Array<{ envVarName: string; displayName: string; example?: string; required?: boolean; transport?: 'stdio' | 'http'; }>> ): Promise<Record<string, string> | null> { // Build consolidated schema with all credential fields const properties: Record<string, any> = {}; const fieldKeys: string[] = []; let message = '📋 Credential Collection for Bulk Import\n\n'; message += 'Please provide credentials for the following MCPs:\n\n'; for (const [mcpName, credentials] of Object.entries(bulkCredentials)) { if (credentials.length === 0) continue; message += `**${mcpName}**:\n`; for (const cred of credentials) { // Create unique field key: mcpName:envVarName const fieldKey = `${mcpName}:${cred.envVarName}`; fieldKeys.push(fieldKey); // Add to schema properties[fieldKey] = { type: 'string', description: `${cred.displayName}${cred.example ? ` (e.g., ${cred.example})` : ''}` }; message += ` • ${cred.displayName}${cred.required === false ? ' (optional)' : ''}\n`; } message += '\n'; } message += '💡 Leave fields empty to skip optional credentials.\n'; message += '⚠️ Credentials are stored locally in your NCP config.'; // Show consolidated form const result = await server.elicitInput({ message, requestedSchema: { type: 'object', properties, required: [] // All fields optional to allow skipping } }); if (result.action !== 'accept') { logger.info(`User ${result.action} bulk credential collection`); return null; } // Extract collected values const collected: Record<string, string> = {}; const content = result.content || {}; for (const fieldKey of fieldKeys) { const value = content[fieldKey]; if (value && typeof value === 'string' && value.trim().length > 0) { collected[fieldKey] = value.trim(); } } logger.info(`Collected ${Object.keys(collected).length} credentials from bulk form`); return collected; } /** * Collect HTTP/SSE authentication credentials from user * * Currently supports bearer tokens. Returns auth config for HTTP/SSE servers. * * @param server MCP server instance * @param mcpName Name of the MCP * @param url URL of the HTTP/SSE server * @returns Auth configuration object, or null if user cancelled */ export async function collectHTTPCredentials( server: ElicitationServer, mcpName: string, url: string ): Promise<{ type: string; token?: string } | null> { const credentialRequirements = detectHTTPCredentials(mcpName, url); if (credentialRequirements.length === 0) { // No credentials needed (public endpoint) return null; } // For now, we only support bearer token collection // OAuth and basic auth would require more complex flows const bearerCred = credentialRequirements.find(c => c.credentialType === 'bearer'); if (!bearerCred) { logger.info(`HTTP MCP "${mcpName}" requires non-bearer auth, skipping auto-collection`); return null; } // Collect bearer token via clipboard const token = await collectCredential( server, bearerCred.displayName, 'AUTH_TOKEN', bearerCred.example ); if (token === null) { return null; // User cancelled } return { type: 'bearer', token }; } /** * Format environment variable name for display * Converts: "GITHUB_TOKEN" -> "GitHub Token" */ export function formatEnvVarName(envVar: string): string { return envVar .split('_') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); } /** * Detect required credentials for HTTP/SSE servers * * Returns bearer token requirements for known HTTP/SSE MCPs */ export function detectHTTPCredentials(mcpName: string, url?: string): Array<{ credentialType: 'bearer' | 'apiKey' | 'oauth' | 'basic'; displayName: string; example?: string; }> { // Common patterns for HTTP/SSE MCPs that need authentication const httpPatterns: Record<string, Array<{ credentialType: 'bearer' | 'apiKey' | 'oauth' | 'basic'; displayName: string; example?: string }>> = { 'github': [ { credentialType: 'bearer', displayName: 'GitHub Personal Access Token', example: 'ghp_xxxxxxxxxxxx' } ], 'gitlab': [ { credentialType: 'bearer', displayName: 'GitLab Personal Access Token', example: 'glpat-xxxxxxxxxxxx' } ], 'stripe': [ { credentialType: 'bearer', displayName: 'Stripe API Key', example: 'sk_test_xxxxxxxxxxxx' } ], 'openai': [ { credentialType: 'bearer', displayName: 'OpenAI API Key', example: 'sk-xxxxxxxxxxxx' } ], 'anthropic': [ { credentialType: 'bearer', displayName: 'Anthropic API Key', example: 'sk-ant-xxxxxxxxxxxx' } ], 'slack': [ { credentialType: 'bearer', displayName: 'Slack Bot Token', example: 'xoxb-xxxxxxxxxxxx' } ] }; // Check URL for patterns if (url) { const urlLower = url.toLowerCase(); for (const [pattern, creds] of Object.entries(httpPatterns)) { if (urlLower.includes(pattern)) { return creds; } } } // Check MCP name for patterns const nameLower = mcpName.toLowerCase(); for (const [pattern, creds] of Object.entries(httpPatterns)) { if (nameLower.includes(pattern)) { return creds; } } return []; } /** * Elicit a selection from a list of options (enum) * * Shows a dialog with radio buttons/dropdown for structured selection. * Follows GitHub's MCP elicitation best practice: use schema-driven prompts. * * @param server MCP server instance * @param fieldName Name of the field being selected * @param options Array of {value, label} options to choose from * @param message Optional custom message * @returns Selected option value, or null if cancelled */ export async function elicitSelect( server: ElicitationServer, fieldName: string, options: Array<{ value: string; label: string }>, message?: string ): Promise<string | null> { if (options.length === 0) { logger.warn(`elicitSelect: No options provided for ${fieldName}`); return null; } const defaultMessage = `Select a ${fieldName}:`; const description = options.map(opt => `• ${opt.label}`).join('\n'); try { const result = await server.elicitInput({ message: message || `${defaultMessage}\n\n${description}`, requestedSchema: { type: 'object', properties: { [fieldName]: { type: 'string', enum: options.map(opt => opt.value), description: `Choose from available ${fieldName} options` } }, required: [fieldName] } }); if (result.action !== 'accept' || !result.content) { logger.info(`User ${result.action} elicit selection for ${fieldName}`); return null; } const selected = result.content[fieldName]; if (selected) { logger.info(`User selected: ${selected}`); return selected; } return null; } catch (error: any) { // Elicitation not supported or timed out - return null to allow fallback behavior logger.warn(`Elicitation failed for ${fieldName}: ${error.message}`); return null; } } /** * Elicit multiple selections from a list of options * * Shows a dialog with checkboxes for multi-select. * User can select multiple options. * * @param server MCP server instance * @param fieldName Name of the field being selected * @param options Array of {value, label} options to choose from * @param message Optional custom message * @returns Array of selected option values, empty array if cancelled */ export async function elicitMultiSelect( server: ElicitationServer, fieldName: string, options: Array<{ value: string; label: string }>, message?: string ): Promise<string[]> { if (options.length === 0) { logger.warn(`elicitMultiSelect: No options provided for ${fieldName}`); return []; } const defaultMessage = `Select ${fieldName}s (one or more):`; const description = options.map(opt => `☐ ${opt.label}`).join('\n'); try { const result = await server.elicitInput({ message: message || `${defaultMessage}\n\n${description}`, requestedSchema: { type: 'object', properties: { [fieldName]: { type: 'array', items: { type: 'string', enum: options.map(opt => opt.value) }, description: `Choose one or more ${fieldName} options` } }, required: [fieldName] } }); if (result.action !== 'accept' || !result.content) { logger.info(`User ${result.action} elicit multi-select for ${fieldName}`); return []; } const selected = result.content[fieldName]; if (Array.isArray(selected)) { logger.info(`User selected ${selected.length} items: ${selected.join(', ')}`); return selected; } return []; } catch (error: any) { // Elicitation not supported or timed out - return empty array to allow fallback behavior logger.warn(`Elicitation failed for ${fieldName}: ${error.message}`); return []; } } /** * Detect required environment variables from MCP metadata * * This can be extended to parse from: * - Registry metadata * - Package.json * - README files * - Auto-detection from errors */ export function detectRequiredEnvVars(mcpName: string): Array<{ envVarName: string; displayName: string; example?: string; }> { // Common patterns for well-known stdio MCPs const knownPatterns: Record<string, Array<{ envVarName: string; displayName: string; example?: string }>> = { 'github': [ { envVarName: 'GITHUB_TOKEN', displayName: 'GitHub Personal Access Token', example: 'ghp_xxxxxxxxxxxx' } ], 'gitlab': [ { envVarName: 'GITLAB_TOKEN', displayName: 'GitLab Personal Access Token', example: 'glpat-xxxxxxxxxxxx' } ], 'slack': [ { envVarName: 'SLACK_BOT_TOKEN', displayName: 'Slack Bot Token', example: 'xoxb-xxxxxxxxxxxx' }, { envVarName: 'SLACK_TEAM_ID', displayName: 'Slack Team ID', example: 'T01234567' } ], 'postgres': [ { envVarName: 'POSTGRES_CONNECTION_STRING', displayName: 'PostgreSQL Connection String', example: 'postgresql://user:pass@host:5432/db' } ], 'openai': [ { envVarName: 'OPENAI_API_KEY', displayName: 'OpenAI API Key', example: 'sk-xxxxxxxxxxxx' } ], 'google-drive': [ { envVarName: 'GOOGLE_DRIVE_CREDENTIALS', displayName: 'Google Drive Credentials JSON', example: '{"client_id": "...", ...}' } ] }; // Check if MCP name matches known patterns for (const [pattern, envVars] of Object.entries(knownPatterns)) { if (mcpName.toLowerCase().includes(pattern)) { return envVars; } } return []; }

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/portel-dev/ncp'

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