Skip to main content
Glama

Salesforce CLI MCP Server

sfCommands.ts30.7 kB
import { execSync } from 'child_process'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { formatFlags } from './utils.js'; import fs from 'fs'; import path from 'path'; import os from 'os'; /** * Represents a Salesforce CLI command */ interface SfCommand { id: string; name: string; description: string; fullCommand: string; flags: SfFlag[]; topic?: string; } /** * Represents a flag for an SF command */ interface SfFlag { name: string; char?: string; description: string; required: boolean; type: string; options?: string[]; default?: string | boolean | number; } /** * Interface for the JSON format returned by 'sf commands --json' */ interface SfCommandJsonEntry { id: string; summary: string; description: string; aliases?: string[]; flags: Record< string, { name: string; description: string; type: string; required?: boolean; helpGroup?: string; options?: string[]; default?: string | boolean | number; char?: string; } >; [key: string]: any; } /** * Cache structure for storing discovered SF commands */ interface SfCommandCache { version: string; timestamp: number; commands: SfCommand[]; } /** * List of topics to ignore during command discovery */ const IGNORED_TOPICS = ['help', 'which', 'whatsnew', 'alias']; /** * Path to the cache file */ const CACHE_DIR = path.join(os.homedir(), '.sf-mcp'); const CACHE_FILE = path.join(CACHE_DIR, 'command-cache.json'); const CACHE_MAX_AGE = 86400 * 7 * 1000; // 1 week in milliseconds /** * Clear the command cache */ export function clearCommandCache(): boolean { try { if (fs.existsSync(CACHE_FILE)) { fs.unlinkSync(CACHE_FILE); console.error(`Removed cache file: ${CACHE_FILE}`); return true; } else { console.error(`Cache file does not exist: ${CACHE_FILE}`); return false; } } catch (error) { console.error('Error clearing command cache:', error); return false; } } /** * Manually force the cache to refresh */ export function refreshCommandCache(): boolean { try { // Clear existing cache if (fs.existsSync(CACHE_FILE)) { fs.unlinkSync(CACHE_FILE); } // Create a fresh cache console.error('Refreshing SF command cache...'); // Get all commands directly from sf commands --json const commands = getAllSfCommands(); console.error(`Found ${commands.length} total commands for cache refresh`); // Save the cache saveCommandCache(commands); console.error('Cache refresh complete!'); return true; } catch (error) { console.error('Error refreshing command cache:', error); return false; } } // Get the full path to the sf command const SF_BINARY_PATH = (() => { try { // Try to find the sf binary in common locations const possiblePaths = [ '/Users/kpoorman/.volta/bin/sf', // The path we found earlier '/usr/local/bin/sf', '/usr/bin/sf', '/opt/homebrew/bin/sf', process.env.HOME + '/.npm/bin/sf', process.env.HOME + '/bin/sf', process.env.HOME + '/.nvm/versions/node/*/bin/sf', ]; for (const path of possiblePaths) { try { if ( execSync(`[ -x "${path}" ] && echo "exists"`, { encoding: 'utf8', }).trim() === 'exists' ) { return path; } } catch (e) { // Path doesn't exist or isn't executable, try the next one } } // If we didn't find it in a known location, try to get it from the PATH return 'sf'; } catch (e) { console.error("Unable to locate sf binary, falling back to 'sf'"); return 'sf'; } })(); /** * Execute an sf command and return the results * @param command The sf command to run * @returns The stdout output from the command */ // Store the user-provided project directories (roots) interface ProjectRoot { path: string; name?: string; description?: string; isDefault?: boolean; } const projectRoots: ProjectRoot[] = []; let defaultRootPath: string | null = null; /** * Validate a directory is a valid Salesforce project * @param directory The directory to validate * @returns boolean indicating if valid */ function isValidSalesforceProject(directory: string): boolean { const projectFilePath = path.join(directory, 'sfdx-project.json'); return fs.existsSync(directory) && fs.existsSync(projectFilePath); } /** * Get all configured project roots * @returns Array of project roots */ export function getProjectRoots(): ProjectRoot[] { return [...projectRoots]; } /** * Get the default project directory (for backward compatibility) * @returns The default project directory or null if none set */ export function getDefaultProjectDirectory(): string | null { return defaultRootPath; } /** * Set the Salesforce project directory to use for commands * @param directory The directory containing sfdx-project.json * @param options Optional parameters (name, description, isDefault) * @returns boolean indicating success */ export function setProjectDirectory( directory: string, options: { name?: string; description?: string; isDefault?: boolean } = {} ): boolean { try { // Validate that the directory exists and contains an sfdx-project.json file if (!isValidSalesforceProject(directory)) { console.error(`Invalid Salesforce project: ${directory}`); return false; } // Check if this root already exists const existingIndex = projectRoots.findIndex(root => root.path === directory); if (existingIndex >= 0) { // Update existing root with new options projectRoots[existingIndex] = { ...projectRoots[existingIndex], ...options, path: directory }; // If this is now the default root, update defaultRootPath if (options.isDefault) { // Remove default flag from other roots projectRoots.forEach((root, idx) => { if (idx !== existingIndex) { root.isDefault = false; } }); defaultRootPath = directory; } console.error(`Updated Salesforce project root: ${directory}`); } else { // Add as new root const isDefault = options.isDefault ?? (projectRoots.length === 0); projectRoots.push({ path: directory, name: options.name || path.basename(directory), description: options.description, isDefault }); // If this is now the default root, update defaultRootPath if (isDefault) { // Remove default flag from other roots projectRoots.forEach((root, idx) => { if (idx !== projectRoots.length - 1) { root.isDefault = false; } }); defaultRootPath = directory; } console.error(`Added Salesforce project root: ${directory}`); } // Always ensure we have exactly one default root if any roots exist if (projectRoots.length > 0 && !projectRoots.some(root => root.isDefault)) { projectRoots[0].isDefault = true; defaultRootPath = projectRoots[0].path; } return true; } catch (error) { console.error('Error setting project directory:', error); return false; } } /** * Checks if a command requires a Salesforce project context * @param command The SF command to check * @returns True if the command requires a Salesforce project context */ function requiresSalesforceProjectContext(command: string): boolean { // List of commands or command prefixes that require a Salesforce project context const projectContextCommands = [ 'project deploy', 'project retrieve', 'project delete', 'project convert', 'package version create', 'package1 version create', 'source', 'mdapi', 'apex', 'lightning', 'schema generate' ]; // Check if the command matches any of the project context commands return projectContextCommands.some(contextCmd => command.startsWith(contextCmd)); } /** * Execute an sf command and return the results * @param command The sf command to run * @param rootName Optional specific root name to use for execution * @returns The stdout output from the command */ export function executeSfCommand(command: string, rootName?: string): string { try { console.error(`Executing: ${SF_BINARY_PATH} ${command}`); // Check if target-org parameter is 'default' and replace with the default org if (command.includes('--target-org default') || command.includes('--target-org=default')) { // Get the default org from sf org list const orgListOutput = execSync(`"${SF_BINARY_PATH}" org list --json`, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, }); const orgList = JSON.parse(orgListOutput); let defaultUsername = ''; // Look for the default org across different org types for (const orgType of ['nonScratchOrgs', 'scratchOrgs', 'sandboxes']) { if (orgList.result[orgType]) { const defaultOrg = orgList.result[orgType].find((org: any) => org.isDefaultUsername); if (defaultOrg) { defaultUsername = defaultOrg.username; break; } } } if (defaultUsername) { // Replace 'default' with the actual default org username command = command.replace(/--target-org[= ]default/, `--target-org ${defaultUsername}`); console.error(`Using default org: ${defaultUsername}`); } } // Determine which project directory to use let projectDir: string | null = null; // If rootName specified, find that specific root if (rootName) { const root = projectRoots.find(r => r.name === rootName); if (root) { projectDir = root.path; console.error(`Using specified root "${rootName}" at ${projectDir}`); } else { console.error(`Root "${rootName}" not found, falling back to default root`); // Fall back to default projectDir = defaultRootPath; } } else { // Use default root projectDir = defaultRootPath; } // Check if this command requires a Salesforce project context and we don't have a project directory if (requiresSalesforceProjectContext(command) && !projectDir) { return `This command requires a Salesforce project context (sfdx-project.json). Please specify a project directory using the format: "Execute in <directory_path>" or "Use project in <directory_path>"`; } try { // Always execute in project directory if available if (projectDir) { console.error(`Executing command in Salesforce project directory: ${projectDir}`); // Execute the command within the specified project directory const result = execSync(`"${SF_BINARY_PATH}" ${command}`, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: process.env.PATH, }, cwd: projectDir, stdio: ['pipe', 'pipe', 'pipe'] // Capture stderr too }); console.error('Command execution successful'); return result; } else { // Standard execution for when no project directory is set return execSync(`"${SF_BINARY_PATH}" ${command}`, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: process.env.PATH, }, }); } } catch (execError: any) { console.error(`Error executing command: ${execError.message}`); // Capture both stdout and stderr for better error diagnostics let errorOutput = ''; if (execError.stdout) { errorOutput += execError.stdout; } if (execError.stderr) { errorOutput += `\n\nError details: ${execError.stderr}`; } if (errorOutput) { console.error(`Command output: ${errorOutput}`); return errorOutput; } return `Error executing command: ${execError.message}`; } } catch (error: any) { console.error(`Top-level error executing command: ${error.message}`); // Capture both stdout and stderr let errorOutput = ''; if (error.stdout) { errorOutput += error.stdout; } if (error.stderr) { errorOutput += `\n\nError details: ${error.stderr}`; } if (errorOutput) { console.error(`Command output: ${errorOutput}`); return errorOutput; } return `Error executing command: ${error.message}`; } } /** * Get all Salesforce CLI commands using 'sf commands --json' */ function getAllSfCommands(): SfCommand[] { try { console.error("Fetching all SF CLI commands via 'sf commands --json'..."); // Execute the command to get all commands in JSON format const commandsJson = executeSfCommand('commands --json'); const allCommands: SfCommandJsonEntry[] = JSON.parse(commandsJson); console.error(`Found ${allCommands.length} total commands from 'sf commands --json'`); // Filter out commands from ignored topics const filteredCommands = allCommands.filter((cmd) => { if (!cmd.id) return false; // For commands with colons (topic:command format), check if the topic should be ignored if (cmd.id.includes(':')) { const topic = cmd.id.split(':')[0].toLowerCase(); return !IGNORED_TOPICS.includes(topic); } // For standalone commands, check if the command itself should be ignored return !IGNORED_TOPICS.includes(cmd.id.toLowerCase()); }); console.error(`After filtering ignored topics, ${filteredCommands.length} commands remain`); // Transform JSON commands to SfCommand format const sfCommands: SfCommand[] = filteredCommands.map((jsonCmd) => { // Parse the command structure from its ID const commandParts = jsonCmd.id.split(':'); const isTopicCommand = commandParts.length > 1; // For commands like "apex:run", extract name and topic let commandName = isTopicCommand ? commandParts[commandParts.length - 1] : jsonCmd.id; let topic = isTopicCommand ? commandParts.slice(0, commandParts.length - 1).join(':') : undefined; // The full command with spaces instead of colons for execution const fullCommand = jsonCmd.id.replace(/:/g, ' '); // Convert flags from JSON format to SfFlag format const flags: SfFlag[] = Object.entries(jsonCmd.flags || {}).map(([flagName, flagDetails]) => { return { name: flagName, char: flagDetails.char, description: flagDetails.description || '', required: !!flagDetails.required, type: flagDetails.type || 'string', options: flagDetails.options, default: flagDetails.default, }; }); return { id: jsonCmd.id, name: commandName, description: jsonCmd.summary || jsonCmd.description || jsonCmd.id, fullCommand, flags, topic, }; }); console.error(`Successfully processed ${sfCommands.length} commands`); return sfCommands; } catch (error) { console.error('Error getting SF commands:', error); return []; } } /** * Convert an SF command to a schema object for validation */ function commandToZodSchema(command: SfCommand): Record<string, z.ZodTypeAny> { const schemaObj: Record<string, z.ZodTypeAny> = {}; for (const flag of command.flags) { let flagSchema: z.ZodTypeAny; // Convert flag type to appropriate Zod schema switch (flag.type) { case 'number': case 'integer': case 'int': flagSchema = z.number(); break; case 'boolean': case 'flag': flagSchema = z.boolean(); break; case 'array': case 'string[]': flagSchema = z.array(z.string()); break; case 'json': case 'object': flagSchema = z.union([z.string(), z.record(z.any())]); break; case 'file': case 'directory': case 'filepath': case 'path': case 'email': case 'url': case 'date': case 'datetime': case 'id': default: // For options-based flags, create an enum schema if (flag.options && flag.options.length > 0) { flagSchema = z.enum(flag.options as [string, ...string[]]); } else { flagSchema = z.string(); } } // Add description if (flag.description) { flagSchema = flagSchema.describe(flag.description); } // Make required or optional based on flag definition schemaObj[flag.name] = flag.required ? flagSchema : flagSchema.optional(); } return schemaObj; } /** * Get the SF CLI version to use for cache validation */ function getSfVersion(): string { try { const versionOutput = executeSfCommand('--version'); const versionMatch = versionOutput.match(/sf\/(\d+\.\d+\.\d+)/); return versionMatch ? versionMatch[1] : 'unknown'; } catch (error) { console.error('Error getting SF version:', error); return 'unknown'; } } /** * Saves the SF command data to cache */ function saveCommandCache(commands: SfCommand[]): void { try { // Create cache directory if it doesn't exist if (!fs.existsSync(CACHE_DIR)) { fs.mkdirSync(CACHE_DIR, { recursive: true }); } const sfVersion = getSfVersion(); const cache: SfCommandCache = { version: sfVersion, timestamp: Date.now(), commands, }; fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2)); console.error(`Command cache saved to ${CACHE_FILE} (SF version: ${sfVersion})`); } catch (error) { console.error('Error saving command cache:', error); } } /** * Loads the SF command data from cache * Returns null if cache is missing, invalid, or expired */ function loadCommandCache(): SfCommand[] | null { try { if (!fs.existsSync(CACHE_FILE)) { console.error('Command cache file does not exist'); return null; } const cacheData = fs.readFileSync(CACHE_FILE, 'utf8'); const cache = JSON.parse(cacheData) as SfCommandCache; // Validate cache structure if (!cache.version || !cache.timestamp || !Array.isArray(cache.commands)) { console.error('Invalid cache structure'); return null; } // Check if cache is expired const now = Date.now(); if (now - cache.timestamp > CACHE_MAX_AGE) { console.error('Cache is expired'); return null; } // Verify that SF version matches const currentVersion = getSfVersion(); if (cache.version !== currentVersion) { console.error(`Cache version mismatch. Cache: ${cache.version}, Current: ${currentVersion}`); return null; } console.error( `Using command cache from ${new Date(cache.timestamp).toLocaleString()} (SF version: ${cache.version})` ); console.error(`Found ${cache.commands.length} commands in cache`); return cache.commands; } catch (error) { console.error('Error loading command cache:', error); return null; } } /** * Register all SF commands as MCP tools * @returns The total number of registered tools */ export async function registerSfCommands(server: McpServer): Promise<number> { try { console.error('Starting SF command registration'); // Try to load commands from cache first let sfCommands = loadCommandCache(); // If cache doesn't exist or is invalid, fetch commands directly if (!sfCommands) { console.error('Cache not available or invalid, fetching commands directly'); sfCommands = getAllSfCommands(); // Save to cache for future use saveCommandCache(sfCommands); } // List of manually defined tools to avoid conflicts // Only includes the utility cache management tools const reservedTools = ['sf_cache_clear', 'sf_cache_refresh']; // Keep track of registered tools and aliases to avoid duplicates const registeredTools = new Set<string>(reservedTools); const registeredAliases = new Set<string>(); // Register all commands as tools let toolCount = 0; for (const command of sfCommands) { try { // Create appropriate MCP-valid tool name let toolName: string; if (command.topic) { // For commands with topics, format as "sf_topic_command" toolName = `sf_${command.topic.replace(/:/g, '_')}_${command.name}`.replace(/[^a-zA-Z0-9_-]/g, '_'); } else { // Standalone commands - sf_command toolName = `sf_${command.name}`.replace(/[^a-zA-Z0-9_-]/g, '_'); } // Ensure tool name meets length requirements (1-64 characters) if (toolName.length > 64) { toolName = toolName.substring(0, 64); } // Skip if this tool name conflicts with a manually defined tool or is already registered if (registeredTools.has(toolName)) { console.error(`Skipping ${toolName} because it's already registered`); continue; } const zodSchema = commandToZodSchema(command); // Register the command as a tool with description server.tool(toolName, command.description, zodSchema, async (flags) => { const flagsStr = formatFlags(flags); const commandStr = `${command.fullCommand} ${flagsStr}`; console.error(`Executing: sf ${commandStr}`); try { const output = executeSfCommand(commandStr); // Check if the output indicates an error but was returned as normal output if (output && (output.includes('Error executing command') || output.includes('Error details:'))) { console.error(`Command returned error: ${output}`); return { content: [ { type: 'text', text: output, }, ], isError: true, }; } return { content: [ { type: 'text', text: output, }, ], }; } catch (error: any) { console.error(`Error executing ${commandStr}:`, error); const errorMessage = error.stdout || error.stderr || error.message || 'Unknown error'; return { content: [ { type: 'text', text: `Error: ${errorMessage}`, }, ], isError: true, }; } }); // Add to registered tools set and increment counter registeredTools.add(toolName); toolCount++; // For nested commands, create simplified aliases when possible // (e.g., sf_get for sf_apex_log_get) if (command.topic && command.topic.includes(':') && command.name.length > 2) { const simplifiedName = command.name.toLowerCase(); const simplifiedToolName = `sf_${simplifiedName}`.replace(/[^a-zA-Z0-9_-]/g, '_'); // Skip if the simplified name is already registered as a tool or alias if (registeredTools.has(simplifiedToolName) || registeredAliases.has(simplifiedToolName)) { continue; } // Register simplified alias with description try { server.tool(simplifiedToolName, `Alias for ${command.description}`, zodSchema, async (flags) => { const flagsStr = formatFlags(flags); const commandStr = `${command.fullCommand} ${flagsStr}`; console.error(`Executing (via alias ${simplifiedToolName}): sf ${commandStr}`); try { const output = executeSfCommand(commandStr); // Check if the output indicates an error but was returned as normal output if (output && (output.includes('Error executing command') || output.includes('Error details:'))) { console.error(`Command returned error: ${output}`); return { content: [ { type: 'text', text: output, }, ], isError: true, }; } return { content: [ { type: 'text', text: output, }, ], }; } catch (error: any) { console.error(`Error executing ${commandStr}:`, error); const errorMessage = error.stdout || error.stderr || error.message || 'Unknown error'; return { content: [ { type: 'text', text: `Error: ${errorMessage}`, }, ], isError: true, }; } }); // Add alias to tracking sets and increment counter registeredAliases.add(simplifiedToolName); registeredTools.add(simplifiedToolName); toolCount++; console.error(`Registered alias ${simplifiedToolName} for ${toolName}`); } catch (err) { console.error(`Error registering alias ${simplifiedToolName}:`, err); } } } catch (err) { console.error(`Error registering tool for command ${command.id}:`, err); } } const totalTools = toolCount + registeredAliases.size; console.error( `Registration complete. Registered ${totalTools} tools (${toolCount} commands and ${registeredAliases.size} aliases).` ); // Return the count for the main server to use return totalTools; } catch (error) { console.error('Error registering SF commands:', error); return 0; } }

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/codefriar/sf-mcp'

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