# Directory Structure ``` ├── .github │ ├── CODEOWNERS │ ├── dependabot.yml │ └── workflows │ ├── ci.yml │ ├── codeql.yml │ └── scorecard.yml ├── .gitignore ├── LICENSE ├── package.json ├── README.md ├── SECURITY.md ├── src │ ├── index.ts │ ├── tools │ │ ├── aggregateQuery.ts │ │ ├── describe.ts │ │ ├── dml.ts │ │ ├── executeAnonymous.ts │ │ ├── manageDebugLogs.ts │ │ ├── manageField.ts │ │ ├── manageFieldPermissions.ts │ │ ├── manageObject.ts │ │ ├── query.ts │ │ ├── readApex.ts │ │ ├── readApexTrigger.ts │ │ ├── search.ts │ │ ├── searchAll.ts │ │ ├── writeApex.ts │ │ └── writeApexTrigger.ts │ ├── types │ │ ├── connection.ts │ │ ├── metadata.ts │ │ └── salesforce.ts │ ├── typings.d.ts │ └── utils │ ├── connection.ts │ └── errorHandler.ts ├── test │ ├── package.test.js │ └── repo.guardrails.test.js └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ package-lock.json # Build output dist/ # Environment variables .env # IDE and OS files .DS_Store .vscode/ .idea/ # Logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Salesforce MCP Server [](https://securityscorecards.dev/viewer/?uri=github.com/tsmztech/mcp-server-salesforce) An MCP (Model Context Protocol) server implementation that integrates Claude with Salesforce, enabling natural language interactions with your Salesforce data and metadata. This server allows Claude to query, modify, and manage your Salesforce objects and records using everyday language. <a href="https://glama.ai/mcp/servers/kqeniawbr6"> <img width="380" height="200" src="https://glama.ai/mcp/servers/kqeniawbr6/badge" alt="Salesforce Server MCP server" /> </a> ## Features * **Object and Field Management**: Create and modify custom objects and fields using natural language * **Smart Object Search**: Find Salesforce objects using partial name matches * **Detailed Schema Information**: Get comprehensive field and relationship details for any object * **Flexible Data Queries**: Query records with relationship support and complex filters * **Data Manipulation**: Insert, update, delete, and upsert records with ease * **Cross-Object Search**: Search across multiple objects using SOSL * **Apex Code Management**: Read, create, and update Apex classes and triggers * **Intuitive Error Handling**: Clear feedback with Salesforce-specific error details * **Switchable Authentication**: Supports multiple orgs. Easily switch your active Salesforce org based on the default org configured in your VS Code workspace (use Salesforce_CLI authentication for this feature). ## Installation ```bash npm install -g @tsmztech/mcp-server-salesforce ``` ## Tools ### salesforce_search_objects Search for standard and custom objects: * Search by partial name matches * Finds both standard and custom objects * Example: "Find objects related to Account" will find Account, AccountHistory, etc. ### salesforce_describe_object Get detailed object schema information: * Field definitions and properties * Relationship details * Picklist values * Example: "Show me all fields in the Account object" ### salesforce_query_records Query records with relationship support: * Parent-to-child relationships * Child-to-parent relationships * Complex WHERE conditions * Example: "Get all Accounts with their related Contacts" * Note: For queries with GROUP BY or aggregate functions, use salesforce_aggregate_query ### salesforce_aggregate_query Execute aggregate queries with GROUP BY: * GROUP BY single or multiple fields * Aggregate functions: COUNT, COUNT_DISTINCT, SUM, AVG, MIN, MAX * HAVING clauses for filtering grouped results * Date/time grouping functions * Example: "Count opportunities by stage" or "Find accounts with more than 10 opportunities" ### salesforce_dml_records Perform data operations: * Insert new records * Update existing records * Delete records * Upsert using external IDs * Example: "Update status of multiple accounts" ### salesforce_manage_object Create and modify custom objects: * Create new custom objects * Update object properties * Configure sharing settings * Example: "Create a Customer Feedback object" ### salesforce_manage_field Manage object fields: * Add new custom fields * Modify field properties * Create relationships * Automatically grants Field Level Security to System Administrator by default * Use `grantAccessTo` parameter to specify different profiles * Example: "Add a Rating picklist field to Account" ### salesforce_manage_field_permissions Manage Field Level Security (Field Permissions): * Grant or revoke read/edit access to fields for specific profiles * View current field permissions * Bulk update permissions for multiple profiles * Useful for managing permissions after field creation or for existing fields * Example: "Grant System Administrator access to Custom_Field__c on Account" ### salesforce_search_all Search across multiple objects: * SOSL-based search * Multiple object support * Field snippets * Example: "Search for 'cloud' across Accounts and Opportunities" ### salesforce_read_apex Read Apex classes: * Get full source code of specific classes * List classes matching name patterns * View class metadata (API version, status, etc.) * Support for wildcards (* and ?) in name patterns * Example: "Show me the AccountController class" or "Find all classes matching Account*Cont*" ### salesforce_write_apex Create and update Apex classes: * Create new Apex classes * Update existing class implementations * Specify API versions * Example: "Create a new Apex class for handling account operations" ### salesforce_read_apex_trigger Read Apex triggers: * Get full source code of specific triggers * List triggers matching name patterns * View trigger metadata (API version, object, status, etc.) * Support for wildcards (* and ?) in name patterns * Example: "Show me the AccountTrigger" or "Find all triggers for Contact object" ### salesforce_write_apex_trigger Create and update Apex triggers: * Create new Apex triggers for specific objects * Update existing trigger implementations * Specify API versions and event operations * Example: "Create a new trigger for the Account object" or "Update the Lead trigger" ### salesforce_execute_anonymous Execute anonymous Apex code: * Run Apex code without creating a permanent class * View debug logs and execution results * Useful for data operations not directly supported by other tools * Example: "Execute Apex code to calculate account metrics" or "Run a script to update related records" ### salesforce_manage_debug_logs Manage debug logs for Salesforce users: * Enable debug logs for specific users * Disable active debug log configurations * Retrieve and view debug logs * Configure log levels (NONE, ERROR, WARN, INFO, DEBUG, FINE, FINER, FINEST) * Example: "Enable debug logs for [email protected]" or "Retrieve recent logs for an admin user" ## Setup ### Salesforce Authentication You can connect to Salesforce using one of three authentication methods: #### 1. Username/Password Authentication (Default) 1. Set up your Salesforce credentials 2. Get your security token (Reset from Salesforce Settings) #### 2. OAuth 2.0 Client Credentials Flow 1. Create a Connected App in Salesforce 2. Enable OAuth settings and select "Client Credentials Flow" 3. Set appropriate scopes (typically "api" is sufficient) 4. Save the Client ID and Client Secret 5. **Important**: Note your instance URL (e.g., `https://your-domain.my.salesforce.com`) as it's required for authentication #### 3. Salesforce CLI Authentication (Recommended for local/dev) (contribution by @andrea9293) 1. Install and authenticate Salesforce CLI (`sf`). 2. Make sure your org is authenticated and accessible via `sf org display --json` in the root of your Salesforce project. 3. The server will automatically retrieve the access token and instance url using the CLI. ### Usage with Claude Desktop Add to your `claude_desktop_config.json`: #### For Salesforce CLI Authentication: ```json { "mcpServers": { "salesforce": { "command": "npx", "args": ["-y", "@tsmztech/mcp-server-salesforce"], "env": { "SALESFORCE_CONNECTION_TYPE": "Salesforce_CLI" } } } } ``` #### For Username/Password Authentication: ```json { "mcpServers": { "salesforce": { "command": "npx", "args": ["-y", "@tsmztech/mcp-server-salesforce"], "env": { "SALESFORCE_CONNECTION_TYPE": "User_Password", "SALESFORCE_USERNAME": "your_username", "SALESFORCE_PASSWORD": "your_password", "SALESFORCE_TOKEN": "your_security_token", "SALESFORCE_INSTANCE_URL": "org_url" // Optional. Default value: https://login.salesforce.com } } } } ``` #### For OAuth 2.0 Client Credentials Flow: ```json { "mcpServers": { "salesforce": { "command": "npx", "args": ["-y", "@tsmztech/mcp-server-salesforce"], "env": { "SALESFORCE_CONNECTION_TYPE": "OAuth_2.0_Client_Credentials", "SALESFORCE_CLIENT_ID": "your_client_id", "SALESFORCE_CLIENT_SECRET": "your_client_secret", "SALESFORCE_INSTANCE_URL": "https://your-domain.my.salesforce.com" // REQUIRED: Must be your exact Salesforce instance URL } } } } ``` > **Note**: For OAuth 2.0 Client Credentials Flow, the `SALESFORCE_INSTANCE_URL` must be your exact Salesforce instance URL (e.g., `https://your-domain.my.salesforce.com`). The token endpoint will be constructed as `<instance_url>/services/oauth2/token`. ## Example Usage ### Searching Objects ``` "Find all objects related to Accounts" "Show me objects that handle customer service" "What objects are available for order management?" ``` ### Getting Schema Information ``` "What fields are available in the Account object?" "Show me the picklist values for Case Status" "Describe the relationship fields in Opportunity" ``` ### Querying Records ``` "Get all Accounts created this month" "Show me high-priority Cases with their related Contacts" "Find all Opportunities over $100k" ``` ### Aggregate Queries ``` "Count opportunities by stage" "Show me the total revenue by account" "Find accounts with more than 10 opportunities" "Calculate average deal size by sales rep and quarter" "Get the number of cases by priority and status" ``` ### Managing Custom Objects ``` "Create a Customer Feedback object" "Add a Rating field to the Feedback object" "Update sharing settings for the Service Request object" ``` Examples with Field Level Security: ``` # Default - grants access to System Administrator automatically "Create a Status picklist field on Custom_Object__c" # Custom profiles - grants access to specified profiles "Create a Revenue currency field on Account and grant access to Sales User and Marketing User profiles" ``` ### Managing Field Permissions ``` "Grant System Administrator access to Custom_Field__c on Account" "Give read-only access to Rating__c field for Sales User profile" "View which profiles have access to the Custom_Field__c" "Revoke field access for specific profiles" ``` ### Searching Across Objects ``` "Search for 'cloud' in Accounts and Opportunities" "Find mentions of 'network issue' in Cases and Knowledge Articles" "Search for customer name across all relevant objects" ``` ### Managing Apex Code ``` "Show me all Apex classes with 'Controller' in the name" "Get the full code for the AccountService class" "Create a new Apex utility class for handling date operations" "Update the LeadConverter class to add a new method" ``` ### Managing Apex Triggers ``` "List all triggers for the Account object" "Show me the code for the ContactTrigger" "Create a new trigger for the Opportunity object" "Update the Case trigger to handle after delete events" ``` ### Executing Anonymous Apex Code ``` "Execute Apex code to calculate account metrics" "Run a script to update related records" "Execute a batch job to process large datasets" ``` ### Managing Debug Logs ``` "Enable debug logs for [email protected]" "Retrieve recent logs for an admin user" "Disable debug logs for a specific user" "Configure log level to DEBUG for a user" ``` ## Development ### Building from source ```bash # Clone the repository git clone https://github.com/tsmztech/mcp-server-salesforce.git # Navigate to directory cd mcp-server-salesforce # Install dependencies npm install # Build the project npm run build ``` ## Contributing Contributions are welcome! Feel free to submit a Pull Request. ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## Issues and Support If you encounter any issues or need support, please file an issue on the [GitHub repository](https://github.com/tsmztech/mcp-server-salesforce/issues). ``` -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- ```markdown # Security Policy ## Reporting Security Vulnerabilities We take security seriously. If you discover a security vulnerability, please report it responsibly. ### How to Report Please use GitHub's private vulnerability reporting: - Go to the [Security tab](https://github.com/tsmztech/mcp-server-salesforce/security/advisories) - Click "Report a vulnerability" This ensures the report stays private until a fix is available. ### Response Timeline - Initial response: Within 72 hours - Patch/mitigation: Within 14 days for critical issues ## Important Security Notes ⚠️ **For MCP Server Salesforce users:** - **NEVER** commit credentials or `.env` files - **ALWAYS** use Salesforce Sandbox environments for testing - **NEVER** test with production Salesforce data - All SOQL inputs are sanitized to prevent injection ## Supported Versions | Version | Supported | | ------- | ------------------ | | main | :white_check_mark: | | < 1.0 | :x: | --- *Please do not publicly disclose vulnerabilities until we've had a chance to address them.* ``` -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- ```typescript declare module 'jsforce'; ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- ```yaml # .github/dependabot.yml version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 5 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 5 ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2020", "module": "ES2022", "moduleResolution": "bundler", "outDir": "./dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true, "rootDir": "./src" }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] } ``` -------------------------------------------------------------------------------- /test/package.test.js: -------------------------------------------------------------------------------- ```javascript import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')); test('package.json has name and version', () => { assert.ok(pkg.name && typeof pkg.name === 'string'); assert.ok(pkg.version && typeof pkg.version === 'string'); }); ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml name: ci on: pull_request: permissions: contents: read jobs: build-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 with: node-version: '20' - name: Install dependencies run: | if [ -f package-lock.json ]; then npm ci elif [ -f yarn.lock ]; then yarn install --frozen-lockfile elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile else npm install --no-audit --no-fund; fi - name: Run tests run: npm test ``` -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- ```yaml name: CodeQL on: pull_request: branches: [ "main" ] schedule: - cron: '0 0 * * 1' # weekly baseline workflow_dispatch: {} # optional manual jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write steps: - uses: actions/checkout@v5 - uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 with: languages: javascript - uses: github/codeql-action/autobuild@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 - uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 with: category: "/language:javascript" ``` -------------------------------------------------------------------------------- /src/types/salesforce.ts: -------------------------------------------------------------------------------- ```typescript export interface SalesforceObject { name: string; label: string; custom: boolean; } export interface SalesforceField { name: string; label: string; type: string; nillable: boolean; length?: number; picklistValues: Array<{ value: string }>; defaultValue: string | null; referenceTo: string[]; } export interface SalesforceDescribeResponse { name: string; label: string; fields: SalesforceField[]; custom: boolean; } export interface SalesforceError { statusCode: string; message: string; fields?: string[]; } export interface DMLResult { success: boolean; id?: string; errors?: SalesforceError[] | SalesforceError; } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@tsmztech/mcp-server-salesforce", "version": "0.0.3", "description": "A Salesforce connector MCP Server.", "main": "dist/index.js", "types": "dist/index.d.ts", "type": "module", "bin": { "salesforce-connector": "dist/index.js" }, "files": [ "dist" ], "scripts": { "build": "tsc && shx chmod +x dist/*.js", "prepare": "npm run build", "watch": "tsc --watch", "test": "node --test" }, "keywords": [ "mcp", "salesforce", "claude", "ai" ], "author": "tsmztech", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.19.1", "dotenv": "^17.2.1", "jsforce": "^3.10.3" }, "devDependencies": { "@types/node": "^24.3.0", "typescript": "^5.7.2", "shx": "^0.4.0" } } ``` -------------------------------------------------------------------------------- /src/types/metadata.ts: -------------------------------------------------------------------------------- ```typescript export interface MetadataInfo { fullName: string; label: string; pluralLabel?: string; nameField?: { type: string; label: string; displayFormat?: string; }; deploymentStatus?: 'Deployed' | 'InDevelopment'; sharingModel?: 'ReadWrite' | 'Read' | 'Private' | 'ControlledByParent'; enableActivities?: boolean; description?: string; } export interface ValueSetDefinition { sorted?: boolean; value: Array<{ fullName: string; default?: boolean; label: string; }>; } export interface FieldMetadataInfo { fullName: string; label: string; type: string; required?: boolean; unique?: boolean; externalId?: boolean; length?: number; precision?: number; scale?: number; visibleLines?: number; referenceTo?: string; relationshipLabel?: string; relationshipName?: string; deleteConstraint?: 'Cascade' | 'Restrict' | 'SetNull'; valueSet?: { valueSetDefinition: ValueSetDefinition; }; defaultValue?: string | number | boolean; description?: string; } ``` -------------------------------------------------------------------------------- /src/utils/errorHandler.ts: -------------------------------------------------------------------------------- ```typescript interface ErrorResult { success: boolean; fullName?: string; errors?: Array<{ message: string; statusCode?: string; fields?: string | string[]; }> | { message: string; statusCode?: string; fields?: string | string[]; }; } export function formatMetadataError(result: ErrorResult | ErrorResult[], operation: string): string { let errorMessage = `Failed to ${operation}`; const saveResult = Array.isArray(result) ? result[0] : result; if (saveResult && saveResult.errors) { if (Array.isArray(saveResult.errors)) { errorMessage += ': ' + saveResult.errors.map((e: { message: string }) => e.message).join(', '); } else if (typeof saveResult.errors === 'object') { const error = saveResult.errors; errorMessage += `: ${error.message}`; if (error.fields) { errorMessage += ` (Field: ${error.fields})`; } if (error.statusCode) { errorMessage += ` [${error.statusCode}]`; } } else { errorMessage += ': ' + String(saveResult.errors); } } return errorMessage; } ``` -------------------------------------------------------------------------------- /test/repo.guardrails.test.js: -------------------------------------------------------------------------------- ```javascript import { test } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const root = process.cwd(); const workflowsDir = join(root, '.github', 'workflows'); // helper: does any workflow file look like a scorecard workflow? function hasScorecardWorkflow() { if (!existsSync(workflowsDir)) return false; const files = readdirSync(workflowsDir); return files.some(f => /scorecard/i.test(f)); } test('README exists and is non-empty', () => { const p = join(root, 'README.md'); assert.ok(existsSync(p), 'README.md should exist'); const content = readFileSync(p, 'utf8').trim(); assert.ok(content.length > 20, 'README.md should not be empty'); }); test('Security + automation guardrails present (soft check)', () => { // SECURITY.md is strongly recommended, but don’t hard-fail your first PR if you’re adding it later. const hasSecurity = existsSync(join(root, 'SECURITY.md')); assert.ok(hasSecurity, 'SECURITY.md should exist (add a minimal policy)'); // Accept any workflow file that includes "scorecard" in its name assert.ok( hasScorecardWorkflow(), 'Expected a Scorecard workflow in .github/workflows (e.g., scorecards.yml)' ); }); ``` -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- ```yaml name: Scorecard supply-chain security on: branch_protection_rule: schedule: - cron: '24 0 * * 3' workflow_dispatch: {} permissions: read-all jobs: analysis: name: Scorecard analysis runs-on: ubuntu-latest permissions: security-events: write id-token: write steps: - name: Harden runner uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a with: egress-policy: audit - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 with: persist-credentials: false - name: Run analysis uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a with: results_file: results.sarif results_format: sarif publish_results: true # repo_token: ${{ secrets.SCORECARD_TOKEN }} # optional PAT - name: Upload artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: SARIF file path: results.sarif retention-days: 5 - name: Upload to code-scanning uses: github/codeql-action/upload-sarif@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 with: sarif_file: results.sarif ``` -------------------------------------------------------------------------------- /src/types/connection.ts: -------------------------------------------------------------------------------- ```typescript /** * Enum representing the available Salesforce connection types */ export enum ConnectionType { /** * Standard username/password authentication with security token * Requires SALESFORCE_USERNAME, SALESFORCE_PASSWORD, and optionally SALESFORCE_TOKEN */ User_Password = 'User_Password', /** * OAuth 2.0 Client Credentials Flow using client ID and secret * Requires SALESFORCE_CLIENT_ID and SALESFORCE_CLIENT_SECRET */ OAuth_2_0_Client_Credentials = 'OAuth_2.0_Client_Credentials', /** * Salesforce CLI authentication using sf org display command * Requires Salesforce CLI to be installed and an authenticated org */ Salesforce_CLI = 'Salesforce_CLI' } /** * Configuration options for Salesforce connection */ export interface ConnectionConfig { /** * The type of connection to use * @default ConnectionType.User_Password */ type?: ConnectionType; /** * The login URL for Salesforce instance * @default 'https://login.salesforce.com' */ loginUrl?: string; } /** * Interface for Salesforce CLI org display response */ export interface SalesforceCLIResponse { status: number; result: { id: string; apiVersion: string; accessToken: string; instanceUrl: string; username: string; clientId: string; connectedStatus: string; alias?: string; }; warnings?: string[]; } ``` -------------------------------------------------------------------------------- /src/tools/describe.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { SalesforceField, SalesforceDescribeResponse } from "../types/salesforce"; export const DESCRIBE_OBJECT: Tool = { name: "salesforce_describe_object", description: "Get detailed schema metadata including all fields, relationships, and field properties of any Salesforce object. Examples: 'Account' shows all Account fields including custom fields; 'Case' shows all Case fields including relationships to Account, Contact etc.", inputSchema: { type: "object", properties: { objectName: { type: "string", description: "API name of the object (e.g., 'Account', 'Contact', 'Custom_Object__c')" } }, required: ["objectName"] } }; export async function handleDescribeObject(conn: any, objectName: string) { const describe = await conn.describe(objectName) as SalesforceDescribeResponse; // Format the output const formattedDescription = ` Object: ${describe.name} (${describe.label})${describe.custom ? ' (Custom Object)' : ''} Fields: ${describe.fields.map((field: SalesforceField) => ` - ${field.name} (${field.label}) Type: ${field.type}${field.length ? `, Length: ${field.length}` : ''} Required: ${!field.nillable} ${field.referenceTo && field.referenceTo.length > 0 ? `References: ${field.referenceTo.join(', ')}` : ''} ${field.picklistValues && field.picklistValues.length > 0 ? `Picklist Values: ${field.picklistValues.map((v: { value: string }) => v.value).join(', ')}` : ''}` ).join('\n')}`; return { content: [{ type: "text", text: formattedDescription }], isError: false, }; } ``` -------------------------------------------------------------------------------- /src/tools/search.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { SalesforceObject } from "../types/salesforce"; export const SEARCH_OBJECTS: Tool = { name: "salesforce_search_objects", description: "Search for Salesforce standard and custom objects by name pattern. Examples: 'Account' will find Account, AccountHistory; 'Order' will find WorkOrder, ServiceOrder__c etc.", inputSchema: { type: "object", properties: { searchPattern: { type: "string", description: "Search pattern to find objects (e.g., 'Account Coverage' will find objects like 'AccountCoverage__c')" } }, required: ["searchPattern"] } }; export async function handleSearchObjects(conn: any, searchPattern: string) { // Get list of all objects const describeGlobal = await conn.describeGlobal(); // Process search pattern to create a more flexible search const searchTerms = searchPattern.toLowerCase().split(' ').filter(term => term.length > 0); // Filter objects based on search pattern const matchingObjects = describeGlobal.sobjects.filter((obj: SalesforceObject) => { const objectName = obj.name.toLowerCase(); const objectLabel = obj.label.toLowerCase(); // Check if all search terms are present in either the API name or label return searchTerms.every(term => objectName.includes(term) || objectLabel.includes(term) ); }); if (matchingObjects.length === 0) { return { content: [{ type: "text", text: `No Salesforce objects found matching "${searchPattern}".` }], isError: false, }; } // Format the output const formattedResults = matchingObjects.map((obj: SalesforceObject) => `${obj.name}${obj.custom ? ' (Custom)' : ''}\n Label: ${obj.label}` ).join('\n\n'); return { content: [{ type: "text", text: `Found ${matchingObjects.length} matching objects:\n\n${formattedResults}` }], isError: false, }; } ``` -------------------------------------------------------------------------------- /src/tools/dml.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { DMLResult } from "../types/salesforce"; export const DML_RECORDS: Tool = { name: "salesforce_dml_records", description: `Perform data manipulation operations on Salesforce records: - insert: Create new records - update: Modify existing records (requires Id) - delete: Remove records (requires Id) - upsert: Insert or update based on external ID field Examples: Insert new Accounts, Update Case status, Delete old records, Upsert based on custom external ID`, inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["insert", "update", "delete", "upsert"], description: "Type of DML operation to perform" }, objectName: { type: "string", description: "API name of the object" }, records: { type: "array", items: { type: "object" }, description: "Array of records to process" }, externalIdField: { type: "string", description: "External ID field name for upsert operations", optional: true } }, required: ["operation", "objectName", "records"] } }; export interface DMLArgs { operation: 'insert' | 'update' | 'delete' | 'upsert'; objectName: string; records: Record<string, any>[]; externalIdField?: string; } export async function handleDMLRecords(conn: any, args: DMLArgs) { const { operation, objectName, records, externalIdField } = args; let result: DMLResult | DMLResult[]; switch (operation) { case 'insert': result = await conn.sobject(objectName).create(records); break; case 'update': result = await conn.sobject(objectName).update(records); break; case 'delete': result = await conn.sobject(objectName).destroy(records.map(r => r.Id)); break; case 'upsert': if (!externalIdField) { throw new Error('externalIdField is required for upsert operations'); } result = await conn.sobject(objectName).upsert(records, externalIdField); break; default: throw new Error(`Unsupported operation: ${operation}`); } // Format DML results const results = Array.isArray(result) ? result : [result]; const successCount = results.filter(r => r.success).length; const failureCount = results.length - successCount; let responseText = `${operation.toUpperCase()} operation completed.\n`; responseText += `Processed ${results.length} records:\n`; responseText += `- Successful: ${successCount}\n`; responseText += `- Failed: ${failureCount}\n\n`; if (failureCount > 0) { responseText += 'Errors:\n'; results.forEach((r: DMLResult, idx: number) => { if (!r.success && r.errors) { responseText += `Record ${idx + 1}:\n`; if (Array.isArray(r.errors)) { r.errors.forEach((error) => { responseText += ` - ${error.message}`; if (error.statusCode) { responseText += ` [${error.statusCode}]`; } if (error.fields && error.fields.length > 0) { responseText += `\n Fields: ${error.fields.join(', ')}`; } responseText += '\n'; }); } else { // Single error object const error = r.errors; responseText += ` - ${error.message}`; if (error.statusCode) { responseText += ` [${error.statusCode}]`; } if (error.fields) { const fields = Array.isArray(error.fields) ? error.fields.join(', ') : error.fields; responseText += `\n Fields: ${fields}`; } responseText += '\n'; } } }); } return { content: [{ type: "text", text: responseText }], isError: false, }; } ``` -------------------------------------------------------------------------------- /src/tools/executeAnonymous.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { Connection } from "jsforce"; export const EXECUTE_ANONYMOUS: Tool = { name: "salesforce_execute_anonymous", description: `Execute anonymous Apex code in Salesforce. Examples: 1. Execute simple Apex code: { "apexCode": "System.debug('Hello World');" } 2. Execute Apex code with variables: { "apexCode": "List<Account> accounts = [SELECT Id, Name FROM Account LIMIT 5]; for(Account a : accounts) { System.debug(a.Name); }" } 3. Execute Apex with debug logs: { "apexCode": "System.debug(LoggingLevel.INFO, 'Processing accounts...'); List<Account> accounts = [SELECT Id FROM Account LIMIT 10]; System.debug(LoggingLevel.INFO, 'Found ' + accounts.size() + ' accounts');", "logLevel": "DEBUG" } Notes: - The apexCode parameter is required and must contain valid Apex code - The code is executed in an anonymous context and does not persist - The logLevel parameter is optional (defaults to 'DEBUG') - Execution results include compilation success/failure, execution success/failure, and debug logs - For security reasons, some operations may be restricted based on user permissions - This tool can be used for data operations or updates when there are no other specific tools available - When users request data queries or updates that aren't directly supported by other tools, this tool can be used if the operation is achievable using Apex code `, inputSchema: { type: "object", properties: { apexCode: { type: "string", description: "Apex code to execute anonymously" }, logLevel: { type: "string", enum: ["NONE", "ERROR", "WARN", "INFO", "DEBUG", "FINE", "FINER", "FINEST"], description: "Log level for debug logs (optional, defaults to DEBUG)" } }, required: ["apexCode"] } }; export interface ExecuteAnonymousArgs { apexCode: string; logLevel?: 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'FINE' | 'FINER' | 'FINEST'; } /** * Handles executing anonymous Apex code in Salesforce * @param conn Active Salesforce connection * @param args Arguments for executing anonymous Apex * @returns Tool response with execution results and debug logs */ export async function handleExecuteAnonymous(conn: any, args: ExecuteAnonymousArgs) { try { // Validate inputs if (!args.apexCode || args.apexCode.trim() === '') { throw new Error('apexCode is required and cannot be empty'); } console.error(`Executing anonymous Apex code`); // Set default log level if not provided const logLevel = args.logLevel || 'DEBUG'; // Execute the anonymous Apex code const result = await conn.tooling.executeAnonymous(args.apexCode); // Format the response let responseText = ''; // Add compilation and execution status if (result.compiled) { responseText += `**Compilation:** Success\n`; } else { responseText += `**Compilation:** Failed\n`; responseText += `**Line:** ${result.line}\n`; responseText += `**Column:** ${result.column}\n`; responseText += `**Error:** ${result.compileProblem}\n\n`; } if (result.compiled && result.success) { responseText += `**Execution:** Success\n`; } else if (result.compiled) { responseText += `**Execution:** Failed\n`; responseText += `**Error:** ${result.exceptionMessage}\n`; if (result.exceptionStackTrace) { responseText += `**Stack Trace:**\n\`\`\`\n${result.exceptionStackTrace}\n\`\`\`\n\n`; } } // Get debug logs if available if (result.compiled) { try { // Query for the most recent debug log const logs = await conn.query(` SELECT Id, LogUserId, Operation, Application, Status, LogLength, LastModifiedDate, Request FROM ApexLog ORDER BY LastModifiedDate DESC LIMIT 1 `); if (logs.records.length > 0) { const logId = logs.records[0].Id; // Retrieve the log body const logBody = await conn.tooling.request({ method: 'GET', url: `${conn.instanceUrl}/services/data/v58.0/tooling/sobjects/ApexLog/${logId}/Body` }); responseText += `\n**Debug Log:**\n\`\`\`\n${logBody}\n\`\`\``; } else { responseText += `\n**Debug Log:** No logs available. Ensure debug logs are enabled for your user.`; } } catch (logError) { responseText += `\n**Debug Log:** Unable to retrieve debug logs: ${logError instanceof Error ? logError.message : String(logError)}`; } } return { content: [{ type: "text", text: responseText }] }; } catch (error) { console.error('Error executing anonymous Apex:', error); return { content: [{ type: "text", text: `Error executing anonymous Apex: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/manageObject.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { MetadataInfo } from "../types/metadata"; export const MANAGE_OBJECT: Tool = { name: "salesforce_manage_object", description: `Create new custom objects or modify existing ones in Salesforce: - Create: New custom objects with fields, relationships, and settings - Update: Modify existing object settings, labels, sharing model Examples: Create Customer_Feedback__c object, Update object sharing settings Note: Changes affect metadata and require proper permissions`, inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["create", "update"], description: "Whether to create new object or update existing" }, objectName: { type: "string", description: "API name for the object (without __c suffix)" }, label: { type: "string", description: "Label for the object" }, pluralLabel: { type: "string", description: "Plural label for the object" }, description: { type: "string", description: "Description of the object", optional: true }, nameFieldLabel: { type: "string", description: "Label for the name field", optional: true }, nameFieldType: { type: "string", enum: ["Text", "AutoNumber"], description: "Type of the name field", optional: true }, nameFieldFormat: { type: "string", description: "Display format for AutoNumber field (e.g., 'A-{0000}')", optional: true }, sharingModel: { type: "string", enum: ["ReadWrite", "Read", "Private", "ControlledByParent"], description: "Sharing model for the object", optional: true } }, required: ["operation", "objectName"] } }; export interface ManageObjectArgs { operation: 'create' | 'update'; objectName: string; label?: string; pluralLabel?: string; description?: string; nameFieldLabel?: string; nameFieldType?: 'Text' | 'AutoNumber'; nameFieldFormat?: string; sharingModel?: 'ReadWrite' | 'Read' | 'Private' | 'ControlledByParent'; } export async function handleManageObject(conn: any, args: ManageObjectArgs) { const { operation, objectName, label, pluralLabel, description, nameFieldLabel, nameFieldType, nameFieldFormat, sharingModel } = args; try { if (operation === 'create') { if (!label || !pluralLabel) { throw new Error('Label and pluralLabel are required for object creation'); } // Prepare metadata for the new object const metadata = { fullName: `${objectName}__c`, label, pluralLabel, nameField: { label: nameFieldLabel || `${label} Name`, type: nameFieldType || 'Text', ...(nameFieldType === 'AutoNumber' && nameFieldFormat ? { displayFormat: nameFieldFormat } : {}) }, deploymentStatus: 'Deployed', sharingModel: sharingModel || 'ReadWrite' } as MetadataInfo; if (description) { metadata.description = description; } // Create the object using Metadata API const result = await conn.metadata.create('CustomObject', metadata); if (result && (Array.isArray(result) ? result[0].success : result.success)) { return { content: [{ type: "text", text: `Successfully created custom object ${objectName}__c` }], isError: false, }; } } else { // For update, first get existing metadata const existingMetadata = await conn.metadata.read('CustomObject', [`${objectName}__c`]); const currentMetadata = Array.isArray(existingMetadata) ? existingMetadata[0] : existingMetadata; if (!currentMetadata) { throw new Error(`Object ${objectName}__c not found`); } // Prepare update metadata const metadata = { ...currentMetadata, label: label || currentMetadata.label, pluralLabel: pluralLabel || currentMetadata.pluralLabel, description: description !== undefined ? description : currentMetadata.description, sharingModel: sharingModel || currentMetadata.sharingModel } as MetadataInfo; // Update the object using Metadata API const result = await conn.metadata.update('CustomObject', metadata); if (result && (Array.isArray(result) ? result[0].success : result.success)) { return { content: [{ type: "text", text: `Successfully updated custom object ${objectName}__c` }], isError: false, }; } } return { content: [{ type: "text", text: `Failed to ${operation} custom object ${objectName}__c` }], isError: true, }; } catch (error) { return { content: [{ type: "text", text: `Error ${operation === 'create' ? 'creating' : 'updating'} custom object: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/writeApex.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { Connection } from "jsforce"; export const WRITE_APEX: Tool = { name: "salesforce_write_apex", description: `Create or update Apex classes in Salesforce. Examples: 1. Create a new Apex class: { "operation": "create", "className": "AccountService", "apiVersion": "58.0", "body": "public class AccountService { public static void updateAccounts() { /* implementation */ } }" } 2. Update an existing Apex class: { "operation": "update", "className": "AccountService", "body": "public class AccountService { public static void updateAccounts() { /* updated implementation */ } }" } Notes: - The operation must be either 'create' or 'update' - For 'create' operations, className and body are required - For 'update' operations, className and body are required - apiVersion is optional for 'create' (defaults to the latest version) - The body must be valid Apex code - The className in the body must match the className parameter - Status information is returned after successful operations`, inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["create", "update"], description: "Whether to create a new class or update an existing one" }, className: { type: "string", description: "Name of the Apex class to create or update" }, apiVersion: { type: "string", description: "API version for the Apex class (e.g., '58.0')" }, body: { type: "string", description: "Full body of the Apex class" } }, required: ["operation", "className", "body"] } }; export interface WriteApexArgs { operation: 'create' | 'update'; className: string; apiVersion?: string; body: string; } /** * Handles creating or updating Apex classes in Salesforce * @param conn Active Salesforce connection * @param args Arguments for writing Apex classes * @returns Tool response with operation result */ export async function handleWriteApex(conn: any, args: WriteApexArgs) { try { // Validate inputs if (!args.className) { throw new Error('className is required'); } if (!args.body) { throw new Error('body is required'); } // Check if the class name in the body matches the provided className const classNameRegex = new RegExp(`\\b(class|interface|enum)\\s+${args.className}\\b`); if (!classNameRegex.test(args.body)) { throw new Error(`The class name in the body must match the provided className: ${args.className}`); } // Handle create operation if (args.operation === 'create') { console.error(`Creating new Apex class: ${args.className}`); // Check if class already exists const existingClass = await conn.query(` SELECT Id FROM ApexClass WHERE Name = '${args.className}' `); if (existingClass.records.length > 0) { throw new Error(`Apex class with name '${args.className}' already exists. Use 'update' operation instead.`); } // Create the new class using the Tooling API const createResult = await conn.tooling.sobject('ApexClass').create({ Name: args.className, Body: args.body, ApiVersion: args.apiVersion || '58.0', // Default to latest if not specified Status: 'Active' }); if (!createResult.success) { throw new Error(`Failed to create Apex class: ${createResult.errors.join(', ')}`); } return { content: [{ type: "text", text: `Successfully created Apex class: ${args.className}\n\n` + `**ID:** ${createResult.id}\n` + `**API Version:** ${args.apiVersion || '58.0'}\n` + `**Status:** Active` }] }; } // Handle update operation else if (args.operation === 'update') { console.error(`Updating Apex class: ${args.className}`); // Find the existing class const existingClass = await conn.query(` SELECT Id FROM ApexClass WHERE Name = '${args.className}' `); if (existingClass.records.length === 0) { throw new Error(`No Apex class found with name: ${args.className}. Use 'create' operation instead.`); } const classId = existingClass.records[0].Id; // Update the class using the Tooling API const updateResult = await conn.tooling.sobject('ApexClass').update({ Id: classId, Body: args.body }); if (!updateResult.success) { throw new Error(`Failed to update Apex class: ${updateResult.errors.join(', ')}`); } // Get the updated class details const updatedClass = await conn.query(` SELECT Id, Name, ApiVersion, Status, LastModifiedDate FROM ApexClass WHERE Id = '${classId}' `); const classDetails = updatedClass.records[0]; return { content: [{ type: "text", text: `Successfully updated Apex class: ${args.className}\n\n` + `**ID:** ${classId}\n` + `**API Version:** ${classDetails.ApiVersion}\n` + `**Status:** ${classDetails.Status}\n` + `**Last Modified:** ${new Date(classDetails.LastModifiedDate).toLocaleString()}` }] }; } else { throw new Error(`Invalid operation: ${args.operation}. Must be 'create' or 'update'.`); } } catch (error) { console.error('Error writing Apex class:', error); return { content: [{ type: "text", text: `Error writing Apex class: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/readApex.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; export const READ_APEX: Tool = { name: "salesforce_read_apex", description: `Read Apex classes from Salesforce. Examples: 1. Read a specific Apex class by name: { "className": "AccountController" } 2. List all Apex classes with an optional name pattern: { "namePattern": "Controller" } 3. Get metadata about Apex classes: { "includeMetadata": true, "namePattern": "Trigger" } 4. Use wildcards in name patterns: { "namePattern": "Account*Cont*" } Notes: - When className is provided, the full body of that specific class is returned - When namePattern is provided, all matching class names are returned (without body) - Use includeMetadata to get additional information like API version, length, and last modified date - If neither className nor namePattern is provided, all Apex class names will be listed - Wildcards are supported in namePattern: * (matches any characters) and ? (matches a single character)`, inputSchema: { type: "object", properties: { className: { type: "string", description: "Name of a specific Apex class to read" }, namePattern: { type: "string", description: "Pattern to match Apex class names (supports wildcards * and ?)" }, includeMetadata: { type: "boolean", description: "Whether to include metadata about the Apex classes" } } } }; export interface ReadApexArgs { className?: string; namePattern?: string; includeMetadata?: boolean; } /** * Converts a wildcard pattern to a SQL LIKE pattern * @param pattern Pattern with * and ? wildcards * @returns SQL LIKE compatible pattern */ function wildcardToLikePattern(pattern: string): string { if (!pattern.includes('*') && !pattern.includes('?')) { // If no wildcards, wrap with % for substring match return `%${pattern}%`; } // Replace * with % and ? with _ for SQL LIKE let likePattern = pattern.replace(/\*/g, '%').replace(/\?/g, '_'); return likePattern; } /** * Handles reading Apex classes from Salesforce * @param conn Active Salesforce connection * @param args Arguments for reading Apex classes * @returns Tool response with Apex class information */ export async function handleReadApex(conn: any, args: ReadApexArgs) { try { // If a specific class name is provided, get the full class body if (args.className) { console.error(`Reading Apex class: ${args.className}`); // Query the ApexClass object to get the class body const result = await conn.query(` SELECT Id, Name, Body, ApiVersion, LengthWithoutComments, Status, IsValid, LastModifiedDate, LastModifiedById FROM ApexClass WHERE Name = '${args.className}' `); if (result.records.length === 0) { return { content: [{ type: "text", text: `No Apex class found with name: ${args.className}` }], isError: true, }; } const apexClass = result.records[0]; // Format the response with the class body and metadata return { content: [ { type: "text", text: `# Apex Class: ${apexClass.Name}\n\n` + (args.includeMetadata ? `**API Version:** ${apexClass.ApiVersion}\n` + `**Length:** ${apexClass.LengthWithoutComments} characters\n` + `**Status:** ${apexClass.Status}\n` + `**Valid:** ${apexClass.IsValid ? 'Yes' : 'No'}\n` + `**Last Modified:** ${new Date(apexClass.LastModifiedDate).toLocaleString()}\n\n` : '') + "```apex\n" + apexClass.Body + "\n```" } ] }; } // Otherwise, list classes matching the pattern else { console.error(`Listing Apex classes${args.namePattern ? ` matching: ${args.namePattern}` : ''}`); // Build the query let query = ` SELECT Id, Name${args.includeMetadata ? ', ApiVersion, LengthWithoutComments, Status, IsValid, LastModifiedDate' : ''} FROM ApexClass `; // Add name pattern filter if provided if (args.namePattern) { const likePattern = wildcardToLikePattern(args.namePattern); query += ` WHERE Name LIKE '${likePattern}'`; } // Order by name query += ` ORDER BY Name`; const result = await conn.query(query); if (result.records.length === 0) { return { content: [{ type: "text", text: `No Apex classes found${args.namePattern ? ` matching: ${args.namePattern}` : ''}` }] }; } // Format the response as a list of classes let responseText = `# Found ${result.records.length} Apex Classes\n\n`; if (args.includeMetadata) { // Table format with metadata responseText += "| Name | API Version | Length | Status | Valid | Last Modified |\n"; responseText += "|------|------------|--------|--------|-------|---------------|\n"; for (const cls of result.records) { responseText += `| ${cls.Name} | ${cls.ApiVersion} | ${cls.LengthWithoutComments} | ${cls.Status} | ${cls.IsValid ? 'Yes' : 'No'} | ${new Date(cls.LastModifiedDate).toLocaleString()} |\n`; } } else { // Simple list format for (const cls of result.records) { responseText += `- ${cls.Name}\n`; } } return { content: [{ type: "text", text: responseText }] }; } } catch (error) { console.error('Error reading Apex classes:', error); return { content: [{ type: "text", text: `Error reading Apex classes: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/readApexTrigger.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; export const READ_APEX_TRIGGER: Tool = { name: "salesforce_read_apex_trigger", description: `Read Apex triggers from Salesforce. Examples: 1. Read a specific Apex trigger by name: { "triggerName": "AccountTrigger" } 2. List all Apex triggers with an optional name pattern: { "namePattern": "Account" } 3. Get metadata about Apex triggers: { "includeMetadata": true, "namePattern": "Contact" } 4. Use wildcards in name patterns: { "namePattern": "Account*" } Notes: - When triggerName is provided, the full body of that specific trigger is returned - When namePattern is provided, all matching trigger names are returned (without body) - Use includeMetadata to get additional information like API version, object type, and last modified date - If neither triggerName nor namePattern is provided, all Apex trigger names will be listed - Wildcards are supported in namePattern: * (matches any characters) and ? (matches a single character)`, inputSchema: { type: "object", properties: { triggerName: { type: "string", description: "Name of a specific Apex trigger to read" }, namePattern: { type: "string", description: "Pattern to match Apex trigger names (supports wildcards * and ?)" }, includeMetadata: { type: "boolean", description: "Whether to include metadata about the Apex triggers" } } } }; export interface ReadApexTriggerArgs { triggerName?: string; namePattern?: string; includeMetadata?: boolean; } /** * Converts a wildcard pattern to a SQL LIKE pattern * @param pattern Pattern with * and ? wildcards * @returns SQL LIKE compatible pattern */ function wildcardToLikePattern(pattern: string): string { if (!pattern.includes('*') && !pattern.includes('?')) { // If no wildcards, wrap with % for substring match return `%${pattern}%`; } // Replace * with % and ? with _ for SQL LIKE let likePattern = pattern.replace(/\*/g, '%').replace(/\?/g, '_'); return likePattern; } /** * Handles reading Apex triggers from Salesforce * @param conn Active Salesforce connection * @param args Arguments for reading Apex triggers * @returns Tool response with Apex trigger information */ export async function handleReadApexTrigger(conn: any, args: ReadApexTriggerArgs) { try { // If a specific trigger name is provided, get the full trigger body if (args.triggerName) { console.error(`Reading Apex trigger: ${args.triggerName}`); // Query the ApexTrigger object to get the trigger body const result = await conn.query(` SELECT Id, Name, Body, ApiVersion, TableEnumOrId, Status, IsValid, LastModifiedDate, LastModifiedById FROM ApexTrigger WHERE Name = '${args.triggerName}' `); if (result.records.length === 0) { return { content: [{ type: "text", text: `No Apex trigger found with name: ${args.triggerName}` }], isError: true, }; } const apexTrigger = result.records[0]; // Format the response with the trigger body and metadata return { content: [ { type: "text", text: `# Apex Trigger: ${apexTrigger.Name}\n\n` + (args.includeMetadata ? `**API Version:** ${apexTrigger.ApiVersion}\n` + `**Object:** ${apexTrigger.TableEnumOrId}\n` + `**Status:** ${apexTrigger.Status}\n` + `**Valid:** ${apexTrigger.IsValid ? 'Yes' : 'No'}\n` + `**Last Modified:** ${new Date(apexTrigger.LastModifiedDate).toLocaleString()}\n\n` : '') + "```apex\n" + apexTrigger.Body + "\n```" } ] }; } // Otherwise, list triggers matching the pattern else { console.error(`Listing Apex triggers${args.namePattern ? ` matching: ${args.namePattern}` : ''}`); // Build the query let query = ` SELECT Id, Name${args.includeMetadata ? ', ApiVersion, TableEnumOrId, Status, IsValid, LastModifiedDate' : ''} FROM ApexTrigger `; // Add name pattern filter if provided if (args.namePattern) { const likePattern = wildcardToLikePattern(args.namePattern); query += ` WHERE Name LIKE '${likePattern}'`; } // Order by name query += ` ORDER BY Name`; const result = await conn.query(query); if (result.records.length === 0) { return { content: [{ type: "text", text: `No Apex triggers found${args.namePattern ? ` matching: ${args.namePattern}` : ''}` }] }; } // Format the response as a list of triggers let responseText = `# Found ${result.records.length} Apex Triggers\n\n`; if (args.includeMetadata) { // Table format with metadata responseText += "| Name | API Version | Object | Status | Valid | Last Modified |\n"; responseText += "|------|------------|--------|--------|-------|---------------|\n"; for (const trigger of result.records) { responseText += `| ${trigger.Name} | ${trigger.ApiVersion} | ${trigger.TableEnumOrId} | ${trigger.Status} | ${trigger.IsValid ? 'Yes' : 'No'} | ${new Date(trigger.LastModifiedDate).toLocaleString()} |\n`; } } else { // Simple list format for (const trigger of result.records) { responseText += `- ${trigger.Name}\n`; } } return { content: [{ type: "text", text: responseText }] }; } } catch (error) { console.error('Error reading Apex triggers:', error); return { content: [{ type: "text", text: `Error reading Apex triggers: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/query.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; export const QUERY_RECORDS: Tool = { name: "salesforce_query_records", description: `Query records from any Salesforce object using SOQL, including relationship queries. NOTE: For queries with GROUP BY, aggregate functions (COUNT, SUM, AVG, etc.), or HAVING clauses, use salesforce_aggregate_query instead. Examples: 1. Parent-to-child query (e.g., Account with Contacts): - objectName: "Account" - fields: ["Name", "(SELECT Id, FirstName, LastName FROM Contacts)"] 2. Child-to-parent query (e.g., Contact with Account details): - objectName: "Contact" - fields: ["FirstName", "LastName", "Account.Name", "Account.Industry"] 3. Multiple level query (e.g., Contact -> Account -> Owner): - objectName: "Contact" - fields: ["Name", "Account.Name", "Account.Owner.Name"] 4. Related object filtering: - objectName: "Contact" - fields: ["Name", "Account.Name"] - whereClause: "Account.Industry = 'Technology'" Note: When using relationship fields: - Use dot notation for parent relationships (e.g., "Account.Name") - Use subqueries in parentheses for child relationships (e.g., "(SELECT Id FROM Contacts)") - Custom relationship fields end in "__r" (e.g., "CustomObject__r.Name")`, inputSchema: { type: "object", properties: { objectName: { type: "string", description: "API name of the object to query" }, fields: { type: "array", items: { type: "string" }, description: "List of fields to retrieve, including relationship fields" }, whereClause: { type: "string", description: "WHERE clause, can include conditions on related objects", optional: true }, orderBy: { type: "string", description: "ORDER BY clause, can include fields from related objects", optional: true }, limit: { type: "number", description: "Maximum number of records to return", optional: true } }, required: ["objectName", "fields"] } }; export interface QueryArgs { objectName: string; fields: string[]; whereClause?: string; orderBy?: string; limit?: number; } // Helper function to validate relationship field syntax function validateRelationshipFields(fields: string[]): { isValid: boolean; error?: string } { for (const field of fields) { // Check for parent relationship syntax (dot notation) if (field.includes('.')) { const parts = field.split('.'); // Check for empty parts if (parts.some(part => !part)) { return { isValid: false, error: `Invalid relationship field format: "${field}". Relationship fields should use proper dot notation (e.g., "Account.Name")` }; } // Check for too many levels (Salesforce typically limits to 5) if (parts.length > 5) { return { isValid: false, error: `Relationship field "${field}" exceeds maximum depth of 5 levels` }; } } // Check for child relationship syntax (subqueries) if (field.includes('SELECT') && !field.match(/^\(SELECT.*FROM.*\)$/)) { return { isValid: false, error: `Invalid subquery format: "${field}". Child relationship queries should be wrapped in parentheses` }; } } return { isValid: true }; } // Helper function to format relationship query results function formatRelationshipResults(record: any, field: string, prefix = ''): string { if (field.includes('.')) { const [relationship, ...rest] = field.split('.'); const relatedRecord = record[relationship]; if (relatedRecord === null) { return `${prefix}${field}: null`; } return formatRelationshipResults(relatedRecord, rest.join('.'), `${prefix}${relationship}.`); } const value = record[field]; if (Array.isArray(value)) { // Handle child relationship arrays return `${prefix}${field}: [${value.length} records]`; } return `${prefix}${field}: ${value !== null && value !== undefined ? value : 'null'}`; } export async function handleQueryRecords(conn: any, args: QueryArgs) { const { objectName, fields, whereClause, orderBy, limit } = args; try { // Validate relationship field syntax const validation = validateRelationshipFields(fields); if (!validation.isValid) { return { content: [{ type: "text", text: validation.error! }], isError: true, }; } // Construct SOQL query let soql = `SELECT ${fields.join(', ')} FROM ${objectName}`; if (whereClause) soql += ` WHERE ${whereClause}`; if (orderBy) soql += ` ORDER BY ${orderBy}`; if (limit) soql += ` LIMIT ${limit}`; const result = await conn.query(soql); // Format the output const formattedRecords = result.records.map((record: any, index: number) => { const recordStr = fields.map(field => { // Handle special case for subqueries (child relationships) if (field.startsWith('(SELECT')) { const relationshipName = field.match(/FROM\s+(\w+)/)?.[1]; if (!relationshipName) return ` ${field}: Invalid subquery format`; const childRecords = record[relationshipName]; return ` ${relationshipName}: [${childRecords?.length || 0} records]`; } return ' ' + formatRelationshipResults(record, field); }).join('\n'); return `Record ${index + 1}:\n${recordStr}`; }).join('\n\n'); return { content: [{ type: "text", text: `Query returned ${result.records.length} records:\n\n${formattedRecords}` }], isError: false, }; } catch (error) { // Enhanced error handling for relationship queries const errorMessage = error instanceof Error ? error.message : String(error); let enhancedError = errorMessage; if (errorMessage.includes('INVALID_FIELD')) { // Try to identify which relationship field caused the error const fieldMatch = errorMessage.match(/(?:No such column |Invalid field: )['"]?([^'")\s]+)/); if (fieldMatch) { const invalidField = fieldMatch[1]; if (invalidField.includes('.')) { enhancedError = `Invalid relationship field "${invalidField}". Please check:\n` + `1. The relationship name is correct\n` + `2. The field exists on the related object\n` + `3. You have access to the field\n` + `4. For custom relationships, ensure you're using '__r' suffix`; } } } return { content: [{ type: "text", text: `Error executing query: ${enhancedError}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/utils/connection.ts: -------------------------------------------------------------------------------- ```typescript import jsforce from 'jsforce'; import { ConnectionType, ConnectionConfig, SalesforceCLIResponse } from '../types/connection.js'; import https from 'https'; import querystring from 'querystring'; import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); /** * Executes the Salesforce CLI command to get org information * @returns Parsed response from sf org display --json command */ async function getSalesforceOrgInfo(): Promise<SalesforceCLIResponse> { try { const command = 'sf org display --json'; const cwdLog = process.cwd(); console.log(`Executing Salesforce CLI command: ${command} in directory: ${cwdLog}`); // Use execAsync and handle both success and error cases let stdout = ''; let stderr = ''; let error: Error | { stdout?: string; stderr?: string } | null = null; try { const result = await execAsync(command); stdout = result.stdout; stderr = result.stderr; } catch (err: any) { // If the command fails, capture stdout/stderr for diagnostics error = err; stdout = 'stdout' in err ? err.stdout || '' : ''; stderr = 'stderr' in err ? err.stderr || '' : ''; } // Log always the output for debug console.error('[Salesforce CLI] STDOUT:', stdout); if (stderr) { console.warn('[Salesforce CLI] STDERR:', stderr); } // Try to parse stdout as JSON let response: SalesforceCLIResponse; try { response = JSON.parse(stdout); } catch (parseErr) { throw new Error(`Failed to parse Salesforce CLI JSON output.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); } // If the command failed (non-zero exit code), throw with details if (error || response.status !== 0) { throw new Error(`Salesforce CLI command failed.\nStatus: ${response.status}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); } // Accept any org that returns accessToken and instanceUrl if (!response.result || !response.result.accessToken || !response.result.instanceUrl) { throw new Error(`Salesforce CLI did not return accessToken and instanceUrl.\nResult: ${JSON.stringify(response.result)}`); } return response; } catch (error) { if (error instanceof Error) { if (error.message.includes('sf: command not found') || error.message.includes("'sf' is not recognized")) { throw new Error('Salesforce CLI (sf) is not installed or not in PATH. Please install the Salesforce CLI to use this authentication method.'); } } throw new Error(`Failed to get Salesforce org info: ${error instanceof Error ? error.message : String(error)}`); } } /** * Creates a Salesforce connection using either username/password or OAuth 2.0 Client Credentials Flow * @param config Optional connection configuration * @returns Connected jsforce Connection instance */ export async function createSalesforceConnection(config?: ConnectionConfig) { // Determine connection type from environment variables or config const connectionType = config?.type || (process.env.SALESFORCE_CONNECTION_TYPE as ConnectionType) || ConnectionType.User_Password; // Set login URL from config or environment variable const loginUrl = config?.loginUrl || process.env.SALESFORCE_INSTANCE_URL || 'https://login.salesforce.com'; try { if (connectionType === ConnectionType.OAuth_2_0_Client_Credentials) { // OAuth 2.0 Client Credentials Flow const clientId = process.env.SALESFORCE_CLIENT_ID; const clientSecret = process.env.SALESFORCE_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error('SALESFORCE_CLIENT_ID and SALESFORCE_CLIENT_SECRET are required for OAuth 2.0 Client Credentials Flow'); } console.error('Connecting to Salesforce using OAuth 2.0 Client Credentials Flow'); // Get the instance URL from environment variable or config const instanceUrl = loginUrl; // Create the token URL const tokenUrl = new URL('/services/oauth2/token', instanceUrl); // Prepare the request body const requestBody = querystring.stringify({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret }); // Make the token request const tokenResponse = await new Promise<any>((resolve, reject) => { const req = https.request({ method: 'POST', hostname: tokenUrl.hostname, path: tokenUrl.pathname, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(requestBody) } }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const parsedData = JSON.parse(data); if (res.statusCode !== 200) { reject(new Error(`OAuth token request failed: ${parsedData.error} - ${parsedData.error_description}`)); } else { resolve(parsedData); } } catch (e: unknown) { reject(new Error(`Failed to parse OAuth response: ${e instanceof Error ? e.message : String(e)}`)); } }); }); req.on('error', (e) => { reject(new Error(`OAuth request error: ${e.message}`)); }); req.write(requestBody); req.end(); }); // Create connection with the access token const conn = new jsforce.Connection({ instanceUrl: tokenResponse.instance_url, accessToken: tokenResponse.access_token }); return conn; } else if (connectionType === ConnectionType.Salesforce_CLI) { // Salesforce CLI authentication using sf org display console.log('Connecting to Salesforce using Salesforce CLI authentication'); // Execute sf org display --json command const orgInfo = await getSalesforceOrgInfo(); // Create connection with the access token from CLI const conn = new jsforce.Connection({ instanceUrl: orgInfo.result.instanceUrl, accessToken: orgInfo.result.accessToken }); console.log(`Connected to Salesforce org: ${orgInfo.result.username} (${orgInfo.result.alias || 'No alias'})`); return conn; } else { // Default: Username/Password Flow with Security Token const username = process.env.SALESFORCE_USERNAME; const password = process.env.SALESFORCE_PASSWORD; const token = process.env.SALESFORCE_TOKEN; if (!username || !password) { throw new Error('SALESFORCE_USERNAME and SALESFORCE_PASSWORD are required for Username/Password authentication'); } console.error('Connecting to Salesforce using Username/Password authentication'); // Create connection with login URL const conn = new jsforce.Connection({ loginUrl }); await conn.login( username, password + (token || '') ); return conn; } } catch (error) { console.error('Error connecting to Salesforce:', error); throw error; } } ``` -------------------------------------------------------------------------------- /src/tools/writeApexTrigger.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; export const WRITE_APEX_TRIGGER: Tool = { name: "salesforce_write_apex_trigger", description: `Create or update Apex triggers in Salesforce. Examples: 1. Create a new Apex trigger: { "operation": "create", "triggerName": "AccountTrigger", "objectName": "Account", "apiVersion": "58.0", "body": "trigger AccountTrigger on Account (before insert, before update) { /* implementation */ }" } 2. Update an existing Apex trigger: { "operation": "update", "triggerName": "AccountTrigger", "body": "trigger AccountTrigger on Account (before insert, before update, after update) { /* updated implementation */ }" } Notes: - The operation must be either 'create' or 'update' - For 'create' operations, triggerName, objectName, and body are required - For 'update' operations, triggerName and body are required - apiVersion is optional for 'create' (defaults to the latest version) - The body must be valid Apex trigger code - The triggerName in the body must match the triggerName parameter - The objectName in the body must match the objectName parameter (for 'create') - Status information is returned after successful operations`, inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["create", "update"], description: "Whether to create a new trigger or update an existing one" }, triggerName: { type: "string", description: "Name of the Apex trigger to create or update" }, objectName: { type: "string", description: "Name of the Salesforce object the trigger is for (required for 'create')" }, apiVersion: { type: "string", description: "API version for the Apex trigger (e.g., '58.0')" }, body: { type: "string", description: "Full body of the Apex trigger" } }, required: ["operation", "triggerName", "body"] } }; export interface WriteApexTriggerArgs { operation: 'create' | 'update'; triggerName: string; objectName?: string; apiVersion?: string; body: string; } /** * Handles creating or updating Apex triggers in Salesforce * @param conn Active Salesforce connection * @param args Arguments for writing Apex triggers * @returns Tool response with operation result */ export async function handleWriteApexTrigger(conn: any, args: WriteApexTriggerArgs) { try { // Validate inputs if (!args.triggerName) { throw new Error('triggerName is required'); } if (!args.body) { throw new Error('body is required'); } // Check if the trigger name in the body matches the provided triggerName const triggerNameRegex = new RegExp(`\\btrigger\\s+${args.triggerName}\\b`); if (!triggerNameRegex.test(args.body)) { throw new Error(`The trigger name in the body must match the provided triggerName: ${args.triggerName}`); } // Handle create operation if (args.operation === 'create') { console.error(`Creating new Apex trigger: ${args.triggerName}`); // Validate object name for create operation if (!args.objectName) { throw new Error('objectName is required for creating a new trigger'); } // Check if the object name in the body matches the provided objectName const objectNameRegex = new RegExp(`\\bon\\s+${args.objectName}\\b`); if (!objectNameRegex.test(args.body)) { throw new Error(`The object name in the body must match the provided objectName: ${args.objectName}`); } // Check if trigger already exists const existingTrigger = await conn.query(` SELECT Id FROM ApexTrigger WHERE Name = '${args.triggerName}' `); if (existingTrigger.records.length > 0) { throw new Error(`Apex trigger with name '${args.triggerName}' already exists. Use 'update' operation instead.`); } // Create the new trigger using the Tooling API const createResult = await conn.tooling.sobject('ApexTrigger').create({ Name: args.triggerName, TableEnumOrId: args.objectName, Body: args.body, ApiVersion: args.apiVersion || '58.0', // Default to latest if not specified Status: 'Active' }); if (!createResult.success) { throw new Error(`Failed to create Apex trigger: ${createResult.errors.join(', ')}`); } return { content: [{ type: "text", text: `Successfully created Apex trigger: ${args.triggerName}\n\n` + `**ID:** ${createResult.id}\n` + `**Object:** ${args.objectName}\n` + `**API Version:** ${args.apiVersion || '58.0'}\n` + `**Status:** Active` }] }; } // Handle update operation else if (args.operation === 'update') { console.error(`Updating Apex trigger: ${args.triggerName}`); // Find the existing trigger const existingTrigger = await conn.query(` SELECT Id, TableEnumOrId FROM ApexTrigger WHERE Name = '${args.triggerName}' `); if (existingTrigger.records.length === 0) { throw new Error(`No Apex trigger found with name: ${args.triggerName}. Use 'create' operation instead.`); } const triggerId = existingTrigger.records[0].Id; const objectName = existingTrigger.records[0].TableEnumOrId; // Check if the object name in the body matches the existing object const objectNameRegex = new RegExp(`\\bon\\s+${objectName}\\b`); if (!objectNameRegex.test(args.body)) { throw new Error(`The object name in the body must match the existing object: ${objectName}`); } // Update the trigger using the Tooling API const updateResult = await conn.tooling.sobject('ApexTrigger').update({ Id: triggerId, Body: args.body }); if (!updateResult.success) { throw new Error(`Failed to update Apex trigger: ${updateResult.errors.join(', ')}`); } // Get the updated trigger details const updatedTrigger = await conn.query(` SELECT Id, Name, TableEnumOrId, ApiVersion, Status, LastModifiedDate FROM ApexTrigger WHERE Id = '${triggerId}' `); const triggerDetails = updatedTrigger.records[0]; return { content: [{ type: "text", text: `Successfully updated Apex trigger: ${args.triggerName}\n\n` + `**ID:** ${triggerId}\n` + `**Object:** ${triggerDetails.TableEnumOrId}\n` + `**API Version:** ${triggerDetails.ApiVersion}\n` + `**Status:** ${triggerDetails.Status}\n` + `**Last Modified:** ${new Date(triggerDetails.LastModifiedDate).toLocaleString()}` }] }; } else { throw new Error(`Invalid operation: ${args.operation}. Must be 'create' or 'update'.`); } } catch (error) { console.error('Error writing Apex trigger:', error); return { content: [{ type: "text", text: `Error writing Apex trigger: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/manageFieldPermissions.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; export const MANAGE_FIELD_PERMISSIONS: Tool = { name: "salesforce_manage_field_permissions", description: `Manage Field Level Security (Field Permissions) for custom and standard fields. - Grant or revoke read/edit access to fields for specific profiles or permission sets - View current field permissions - Bulk update permissions for multiple profiles Examples: 1. Grant System Administrator access to a field 2. Give read-only access to a field for specific profiles 3. Check which profiles have access to a field`, inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["grant", "revoke", "view"], description: "Operation to perform on field permissions" }, objectName: { type: "string", description: "API name of the object (e.g., 'Account', 'Custom_Object__c')" }, fieldName: { type: "string", description: "API name of the field (e.g., 'Custom_Field__c')" }, profileNames: { type: "array", items: { type: "string" }, description: "Names of profiles to grant/revoke access (e.g., ['System Administrator', 'Sales User'])", optional: true }, readable: { type: "boolean", description: "Grant/revoke read access (default: true)", optional: true }, editable: { type: "boolean", description: "Grant/revoke edit access (default: true)", optional: true } }, required: ["operation", "objectName", "fieldName"] } }; export interface ManageFieldPermissionsArgs { operation: 'grant' | 'revoke' | 'view'; objectName: string; fieldName: string; profileNames?: string[]; readable?: boolean; editable?: boolean; } export async function handleManageFieldPermissions(conn: any, args: ManageFieldPermissionsArgs) { const { operation, objectName, fieldName, readable = true, editable = true } = args; let { profileNames } = args; try { // Ensure field name has __c suffix if it's a custom field and doesn't already have it const fieldApiName = fieldName.endsWith('__c') || fieldName.includes('.') ? fieldName : `${fieldName}__c`; const fullFieldName = `${objectName}.${fieldApiName}`; if (operation === 'view') { // Query existing field permissions const permissionsQuery = ` SELECT Id, Parent.ProfileId, Parent.Profile.Name, Parent.IsOwnedByProfile, Parent.PermissionSetId, Parent.PermissionSet.Name, Field, PermissionsRead, PermissionsEdit FROM FieldPermissions WHERE SobjectType = '${objectName}' AND Field = '${fullFieldName}' ORDER BY Parent.Profile.Name `; const result = await conn.query(permissionsQuery); if (result.records.length === 0) { return { content: [{ type: "text", text: `No field permissions found for ${fullFieldName}. This field might not have any specific permissions set, or it might be universally accessible.` }], isError: false, }; } let responseText = `Field permissions for ${fullFieldName}:\n\n`; result.records.forEach((perm: any) => { const name = perm.Parent.IsOwnedByProfile ? perm.Parent.Profile?.Name : perm.Parent.PermissionSet?.Name; const type = perm.Parent.IsOwnedByProfile ? 'Profile' : 'Permission Set'; responseText += `${type}: ${name}\n`; responseText += ` - Read Access: ${perm.PermissionsRead ? 'Yes' : 'No'}\n`; responseText += ` - Edit Access: ${perm.PermissionsEdit ? 'Yes' : 'No'}\n\n`; }); return { content: [{ type: "text", text: responseText }], isError: false, }; } // For grant/revoke operations if (!profileNames || profileNames.length === 0) { // If no profiles specified, default to System Administrator profileNames = ['System Administrator']; } // Get profile IDs const profileQuery = await conn.query(` SELECT Id, Name FROM Profile WHERE Name IN (${profileNames.map(name => `'${name}'`).join(', ')}) `); if (profileQuery.records.length === 0) { return { content: [{ type: "text", text: `No profiles found matching: ${profileNames.join(', ')}` }], isError: true, }; } const results: any[] = []; const errors: string[] = []; for (const profile of profileQuery.records) { try { if (operation === 'grant') { // First, check if permission already exists const existingPerm = await conn.query(` SELECT Id, PermissionsRead, PermissionsEdit FROM FieldPermissions WHERE ParentId IN ( SELECT Id FROM PermissionSet WHERE IsOwnedByProfile = true AND ProfileId = '${profile.Id}' ) AND Field = '${fullFieldName}' AND SobjectType = '${objectName}' LIMIT 1 `); if (existingPerm.records.length > 0) { // Update existing permission const updateResult = await conn.sobject('FieldPermissions').update({ Id: existingPerm.records[0].Id, PermissionsRead: readable, PermissionsEdit: editable && readable // Edit requires read }); results.push({ profile: profile.Name, action: 'updated', success: updateResult.success }); } else { // Get the PermissionSet ID for this profile const permSetQuery = await conn.query(` SELECT Id FROM PermissionSet WHERE IsOwnedByProfile = true AND ProfileId = '${profile.Id}' LIMIT 1 `); if (permSetQuery.records.length > 0) { // Create new permission const createResult = await conn.sobject('FieldPermissions').create({ ParentId: permSetQuery.records[0].Id, SobjectType: objectName, Field: fullFieldName, PermissionsRead: readable, PermissionsEdit: editable && readable // Edit requires read }); results.push({ profile: profile.Name, action: 'created', success: createResult.success }); } else { errors.push(`Could not find permission set for profile: ${profile.Name}`); } } } else if (operation === 'revoke') { // Find and delete the permission const existingPerm = await conn.query(` SELECT Id FROM FieldPermissions WHERE ParentId IN ( SELECT Id FROM PermissionSet WHERE IsOwnedByProfile = true AND ProfileId = '${profile.Id}' ) AND Field = '${fullFieldName}' AND SobjectType = '${objectName}' LIMIT 1 `); if (existingPerm.records.length > 0) { const deleteResult = await conn.sobject('FieldPermissions').delete(existingPerm.records[0].Id); results.push({ profile: profile.Name, action: 'revoked', success: true }); } else { results.push({ profile: profile.Name, action: 'no permission found', success: true }); } } } catch (error) { errors.push(`${profile.Name}: ${error instanceof Error ? error.message : String(error)}`); } } // Format response let responseText = `Field permission ${operation} operation completed for ${fullFieldName}:\n\n`; const successful = results.filter(r => r.success); const failed = results.filter(r => !r.success); if (successful.length > 0) { responseText += 'Successful:\n'; successful.forEach(r => { responseText += ` - ${r.profile}: ${r.action}\n`; }); } if (failed.length > 0 || errors.length > 0) { responseText += '\nFailed:\n'; failed.forEach(r => { responseText += ` - ${r.profile}: ${r.action}\n`; }); errors.forEach(e => { responseText += ` - ${e}\n`; }); } if (operation === 'grant') { responseText += `\nPermissions granted:\n - Read: ${readable ? 'Yes' : 'No'}\n - Edit: ${editable ? 'Yes' : 'No'}`; } return { content: [{ type: "text", text: responseText }], isError: false, }; } catch (error) { return { content: [{ type: "text", text: `Error managing field permissions: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/searchAll.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; export const SEARCH_ALL: Tool = { name: "salesforce_search_all", description: `Search across multiple Salesforce objects using SOSL (Salesforce Object Search Language). Examples: 1. Basic search across all objects: { "searchTerm": "John", "objects": [ { "name": "Account", "fields": ["Name"], "limit": 10 }, { "name": "Contact", "fields": ["FirstName", "LastName", "Email"] } ] } 2. Advanced search with filters: { "searchTerm": "Cloud*", "searchIn": "NAME FIELDS", "objects": [ { "name": "Account", "fields": ["Name", "Industry"], "orderBy": "Name DESC", "where": "Industry = 'Technology'" } ], "withClauses": [ { "type": "NETWORK", "value": "ALL NETWORKS" }, { "type": "SNIPPET", "fields": ["Description"] } ] } Notes: - Use * and ? for wildcards in search terms - Each object can have its own WHERE, ORDER BY, and LIMIT clauses - Support for WITH clauses: DATA CATEGORY, DIVISION, METADATA, NETWORK, PRICEBOOKID, SNIPPET, SECURITY_ENFORCED - "updateable" and "viewable" options control record access filtering`, inputSchema: { type: "object", properties: { searchTerm: { type: "string", description: "Text to search for (supports wildcards * and ?)" }, searchIn: { type: "string", enum: ["ALL FIELDS", "NAME FIELDS", "EMAIL FIELDS", "PHONE FIELDS", "SIDEBAR FIELDS"], description: "Which fields to search in", optional: true }, objects: { type: "array", items: { type: "object", properties: { name: { type: "string", description: "API name of the object" }, fields: { type: "array", items: { type: "string" }, description: "Fields to return for this object" }, where: { type: "string", description: "WHERE clause for this object", optional: true }, orderBy: { type: "string", description: "ORDER BY clause for this object", optional: true }, limit: { type: "number", description: "Maximum number of records to return for this object", optional: true } }, required: ["name", "fields"] }, description: "List of objects to search and their return fields" }, withClauses: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["DATA CATEGORY", "DIVISION", "METADATA", "NETWORK", "PRICEBOOKID", "SNIPPET", "SECURITY_ENFORCED"] }, value: { type: "string", description: "Value for the WITH clause", optional: true }, fields: { type: "array", items: { type: "string" }, description: "Fields for SNIPPET clause", optional: true } }, required: ["type"] }, description: "Additional WITH clauses for the search", optional: true }, updateable: { type: "boolean", description: "Return only updateable records", optional: true }, viewable: { type: "boolean", description: "Return only viewable records", optional: true } }, required: ["searchTerm", "objects"] } }; export interface SearchObject { name: string; fields: string[]; where?: string; orderBy?: string; limit?: number; } export interface WithClause { type: "DATA CATEGORY" | "DIVISION" | "METADATA" | "NETWORK" | "PRICEBOOKID" | "SNIPPET" | "SECURITY_ENFORCED"; value?: string; fields?: string[]; } export interface SearchAllArgs { searchTerm: string; searchIn?: "ALL FIELDS" | "NAME FIELDS" | "EMAIL FIELDS" | "PHONE FIELDS" | "SIDEBAR FIELDS"; objects: SearchObject[]; withClauses?: WithClause[]; updateable?: boolean; viewable?: boolean; } function buildWithClause(withClause: WithClause): string { switch (withClause.type) { case "SNIPPET": return `WITH SNIPPET (${withClause.fields?.join(', ')})`; case "DATA CATEGORY": case "DIVISION": case "NETWORK": case "PRICEBOOKID": return `WITH ${withClause.type} = ${withClause.value}`; case "METADATA": case "SECURITY_ENFORCED": return `WITH ${withClause.type}`; default: return ''; } } export async function handleSearchAll(conn: any, args: SearchAllArgs) { const { searchTerm, searchIn = "ALL FIELDS", objects, withClauses, updateable, viewable } = args; try { // Validate the search term if (!searchTerm.trim()) { throw new Error('Search term cannot be empty'); } // Construct the RETURNING clause with object-specific clauses const returningClause = objects .map(obj => { let clause = `${obj.name}(${obj.fields.join(',')}` // Add object-specific clauses if present if (obj.where) clause += ` WHERE ${obj.where}`; if (obj.orderBy) clause += ` ORDER BY ${obj.orderBy}`; if (obj.limit) clause += ` LIMIT ${obj.limit}`; return clause + ')'; }) .join(', '); // Build WITH clauses if present const withClausesStr = withClauses ? withClauses.map(buildWithClause).join(' ') : ''; // Add updateable/viewable flags if specified const accessFlags = []; if (updateable) accessFlags.push('UPDATEABLE'); if (viewable) accessFlags.push('VIEWABLE'); const accessClause = accessFlags.length > 0 ? ` RETURNING ${accessFlags.join(',')}` : ''; // Construct complete SOSL query const soslQuery = `FIND {${searchTerm}} IN ${searchIn} ${withClausesStr} RETURNING ${returningClause} ${accessClause}`.trim(); // Execute search const result = await conn.search(soslQuery); // Format results by object let formattedResults = ''; objects.forEach((obj, index) => { const objectResults = result.searchRecords.filter((record: any) => record.attributes.type === obj.name ); formattedResults += `\n${obj.name} (${objectResults.length} records found):\n`; if (objectResults.length > 0) { objectResults.forEach((record: any, recordIndex: number) => { formattedResults += ` Record ${recordIndex + 1}:\n`; obj.fields.forEach(field => { const value = record[field]; formattedResults += ` ${field}: ${value !== null && value !== undefined ? value : 'null'}\n`; }); // Add metadata or snippet info if requested if (withClauses?.some(w => w.type === "METADATA")) { formattedResults += ` Metadata:\n Last Modified: ${record.attributes.lastModifiedDate}\n`; } if (withClauses?.some(w => w.type === "SNIPPET")) { formattedResults += ` Snippets:\n${record.snippets?.map((s: any) => ` ${s.field}: ${s.snippet}`).join('\n') || ' None'}\n`; } }); } if (index < objects.length - 1) { formattedResults += '\n'; } }); return { content: [{ type: "text", text: `Search Results:${formattedResults}` }], isError: false, }; } catch (error) { // Enhanced error handling for SOSL queries const errorMessage = error instanceof Error ? error.message : String(error); let enhancedError = errorMessage; if (errorMessage.includes('MALFORMED_SEARCH')) { enhancedError = `Invalid search query format. Common issues:\n` + `1. Search term contains invalid characters\n` + `2. Object or field names are incorrect\n` + `3. Missing required SOSL syntax elements\n` + `4. Invalid WITH clause combination\n\n` + `Original error: ${errorMessage}`; } else if (errorMessage.includes('INVALID_FIELD')) { enhancedError = `Invalid field specified in RETURNING clause. Please check:\n` + `1. Field names are correct\n` + `2. Fields exist on the specified objects\n` + `3. You have access to all specified fields\n` + `4. WITH SNIPPET fields are valid\n\n` + `Original error: ${errorMessage}`; } else if (errorMessage.includes('WITH_CLAUSE')) { enhancedError = `Error in WITH clause. Please check:\n` + `1. WITH clause type is supported\n` + `2. WITH clause value is valid\n` + `3. You have permission to use the specified WITH clause\n\n` + `Original error: ${errorMessage}`; } return { content: [{ type: "text", text: `Error executing search: ${enhancedError}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/aggregateQuery.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; export const AGGREGATE_QUERY: Tool = { name: "salesforce_aggregate_query", description: `Execute SOQL queries with GROUP BY, aggregate functions, and statistical analysis. Use this tool for queries that summarize and group data rather than returning individual records. NOTE: For regular queries without GROUP BY or aggregates, use salesforce_query_records instead. This tool handles: 1. GROUP BY queries (single/multiple fields, related objects, date functions) 2. Aggregate functions: COUNT(), COUNT_DISTINCT(), SUM(), AVG(), MIN(), MAX() 3. HAVING clauses for filtering grouped results 4. Date/time grouping: CALENDAR_YEAR(), CALENDAR_MONTH(), CALENDAR_QUARTER(), FISCAL_YEAR(), FISCAL_QUARTER() Examples: 1. Count opportunities by stage: - objectName: "Opportunity" - selectFields: ["StageName", "COUNT(Id) OpportunityCount"] - groupByFields: ["StageName"] 2. Analyze cases by priority and status: - objectName: "Case" - selectFields: ["Priority", "Status", "COUNT(Id) CaseCount", "AVG(Days_Open__c) AvgDaysOpen"] - groupByFields: ["Priority", "Status"] 3. Count contacts by account industry: - objectName: "Contact" - selectFields: ["Account.Industry", "COUNT(Id) ContactCount"] - groupByFields: ["Account.Industry"] 4. Quarterly opportunity analysis: - objectName: "Opportunity" - selectFields: ["CALENDAR_YEAR(CloseDate) Year", "CALENDAR_QUARTER(CloseDate) Quarter", "SUM(Amount) Revenue"] - groupByFields: ["CALENDAR_YEAR(CloseDate)", "CALENDAR_QUARTER(CloseDate)"] 5. Find accounts with more than 10 opportunities: - objectName: "Opportunity" - selectFields: ["Account.Name", "COUNT(Id) OpportunityCount"] - groupByFields: ["Account.Name"] - havingClause: "COUNT(Id) > 10" Important Rules: - All non-aggregate fields in selectFields MUST be included in groupByFields - Use whereClause to filter rows BEFORE grouping - Use havingClause to filter AFTER grouping (for aggregate conditions) - ORDER BY can only use fields from groupByFields or aggregate functions - OFFSET is not supported with GROUP BY in Salesforce`, inputSchema: { type: "object", properties: { objectName: { type: "string", description: "API name of the object to query" }, selectFields: { type: "array", items: { type: "string" }, description: "Fields to select - mix of group fields and aggregates. Format: 'FieldName' or 'COUNT(Id) AliasName'" }, groupByFields: { type: "array", items: { type: "string" }, description: "Fields to group by - must include all non-aggregate fields from selectFields" }, whereClause: { type: "string", description: "WHERE clause to filter rows BEFORE grouping (cannot contain aggregate functions)", optional: true }, havingClause: { type: "string", description: "HAVING clause to filter results AFTER grouping (use for aggregate conditions)", optional: true }, orderBy: { type: "string", description: "ORDER BY clause - can only use grouped fields or aggregate functions", optional: true }, limit: { type: "number", description: "Maximum number of grouped results to return", optional: true } }, required: ["objectName", "selectFields", "groupByFields"] } }; export interface AggregateQueryArgs { objectName: string; selectFields: string[]; groupByFields: string[]; whereClause?: string; havingClause?: string; orderBy?: string; limit?: number; } // Aggregate functions that don't need to be in GROUP BY const AGGREGATE_FUNCTIONS = ['COUNT', 'COUNT_DISTINCT', 'SUM', 'AVG', 'MIN', 'MAX']; const DATE_FUNCTIONS = ['CALENDAR_YEAR', 'CALENDAR_MONTH', 'CALENDAR_QUARTER', 'FISCAL_YEAR', 'FISCAL_QUARTER']; // Helper function to detect if a field contains an aggregate function function isAggregateField(field: string): boolean { const upperField = field.toUpperCase(); return AGGREGATE_FUNCTIONS.some(func => upperField.includes(`${func}(`)); } // Helper function to extract the base field from a select field (removing alias) function extractBaseField(field: string): string { // Remove alias if present (e.g., "COUNT(Id) OpportunityCount" -> "COUNT(Id)") const parts = field.trim().split(/\s+/); return parts[0]; } // Helper function to extract non-aggregate fields from select fields function extractNonAggregateFields(selectFields: string[]): string[] { return selectFields .filter(field => !isAggregateField(field)) .map(field => extractBaseField(field)); } // Helper function to validate that all non-aggregate fields are in GROUP BY function validateGroupByFields(selectFields: string[], groupByFields: string[]): { isValid: boolean; missingFields?: string[] } { const nonAggregateFields = extractNonAggregateFields(selectFields); const groupBySet = new Set(groupByFields.map(f => f.trim())); const missingFields = nonAggregateFields.filter(field => !groupBySet.has(field)); return { isValid: missingFields.length === 0, missingFields }; } // Helper function to validate WHERE clause doesn't contain aggregates function validateWhereClause(whereClause: string | undefined): { isValid: boolean; error?: string } { if (!whereClause) return { isValid: true }; const upperWhere = whereClause.toUpperCase(); for (const func of AGGREGATE_FUNCTIONS) { if (upperWhere.includes(`${func}(`)) { return { isValid: false, error: `WHERE clause cannot contain aggregate functions. Use HAVING clause instead for aggregate conditions like ${func}()` }; } } return { isValid: true }; } // Helper function to validate ORDER BY fields function validateOrderBy(orderBy: string | undefined, groupByFields: string[], selectFields: string[]): { isValid: boolean; error?: string } { if (!orderBy) return { isValid: true }; // Extract fields from ORDER BY (handling DESC/ASC) const orderByParts = orderBy.split(',').map(part => { return part.trim().replace(/ (DESC|ASC)$/i, '').trim(); }); const groupBySet = new Set(groupByFields); const aggregateFields = selectFields.filter(field => isAggregateField(field)).map(field => extractBaseField(field)); for (const orderField of orderByParts) { // Check if it's in GROUP BY or is an aggregate if (!groupBySet.has(orderField) && !aggregateFields.some(agg => agg === orderField) && !isAggregateField(orderField)) { return { isValid: false, error: `ORDER BY field '${orderField}' must be in GROUP BY clause or be an aggregate function` }; } } return { isValid: true }; } export async function handleAggregateQuery(conn: any, args: AggregateQueryArgs) { const { objectName, selectFields, groupByFields, whereClause, havingClause, orderBy, limit } = args; try { // Validate GROUP BY contains all non-aggregate fields const groupByValidation = validateGroupByFields(selectFields, groupByFields); if (!groupByValidation.isValid) { return { content: [{ type: "text", text: `Error: The following non-aggregate fields must be included in GROUP BY clause: ${groupByValidation.missingFields!.join(', ')}\n\n` + `All fields in SELECT that are not aggregate functions (COUNT, SUM, AVG, etc.) must be included in GROUP BY.` }], isError: true, }; } // Validate WHERE clause doesn't contain aggregates const whereValidation = validateWhereClause(whereClause); if (!whereValidation.isValid) { return { content: [{ type: "text", text: whereValidation.error! }], isError: true, }; } // Validate ORDER BY fields const orderByValidation = validateOrderBy(orderBy, groupByFields, selectFields); if (!orderByValidation.isValid) { return { content: [{ type: "text", text: orderByValidation.error! }], isError: true, }; } // Construct SOQL query let soql = `SELECT ${selectFields.join(', ')} FROM ${objectName}`; if (whereClause) soql += ` WHERE ${whereClause}`; soql += ` GROUP BY ${groupByFields.join(', ')}`; if (havingClause) soql += ` HAVING ${havingClause}`; if (orderBy) soql += ` ORDER BY ${orderBy}`; if (limit) soql += ` LIMIT ${limit}`; const result = await conn.query(soql); // Format the output const formattedRecords = result.records.map((record: any, index: number) => { const recordStr = selectFields.map(field => { const baseField = extractBaseField(field); const fieldParts = field.trim().split(/\s+/); const displayName = fieldParts.length > 1 ? fieldParts[fieldParts.length - 1] : baseField; // Handle nested fields in results if (baseField.includes('.')) { const parts = baseField.split('.'); let value = record; for (const part of parts) { value = value?.[part]; } return ` ${displayName}: ${value !== null && value !== undefined ? value : 'null'}`; } const value = record[baseField] || record[displayName]; return ` ${displayName}: ${value !== null && value !== undefined ? value : 'null'}`; }).join('\n'); return `Group ${index + 1}:\n${recordStr}`; }).join('\n\n'); return { content: [{ type: "text", text: `Aggregate query returned ${result.records.length} grouped results:\n\n${formattedRecords}` }], isError: false, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); // Provide more helpful error messages for common issues let enhancedError = errorMessage; if (errorMessage.includes('MALFORMED_QUERY')) { if (errorMessage.includes('GROUP BY')) { enhancedError = `Query error: ${errorMessage}\n\nCommon issues:\n` + `1. Ensure all non-aggregate fields in SELECT are in GROUP BY\n` + `2. Check that date functions match exactly between SELECT and GROUP BY\n` + `3. Verify field names and relationships are correct`; } } return { content: [{ type: "text", text: `Error executing aggregate query: ${enhancedError}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/tools/manageField.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { FieldMetadataInfo } from "../types/metadata"; export const MANAGE_FIELD: Tool = { name: "salesforce_manage_field", description: `Create new custom fields or modify existing fields on any Salesforce object: - Field Types: Text, Number, Date, Lookup, Master-Detail, Picklist etc. - Properties: Required, Unique, External ID, Length, Scale etc. - Relationships: Create lookups and master-detail relationships - Automatically grants Field Level Security to System Administrator (or specified profiles) Examples: Add Rating__c picklist to Account, Create Account lookup on Custom Object Note: Use grantAccessTo parameter to specify profiles, defaults to System Administrator`, inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["create", "update"], description: "Whether to create new field or update existing" }, objectName: { type: "string", description: "API name of the object to add/modify the field" }, fieldName: { type: "string", description: "API name for the field (without __c suffix)" }, label: { type: "string", description: "Label for the field", optional: true }, type: { type: "string", enum: ["Checkbox", "Currency", "Date", "DateTime", "Email", "Number", "Percent", "Phone", "Picklist", "MultiselectPicklist", "Text", "TextArea", "LongTextArea", "Html", "Url", "Lookup", "MasterDetail"], description: "Field type (required for create)", optional: true }, required: { type: "boolean", description: "Whether the field is required", optional: true }, unique: { type: "boolean", description: "Whether the field value must be unique", optional: true }, externalId: { type: "boolean", description: "Whether the field is an external ID", optional: true }, length: { type: "number", description: "Length for text fields", optional: true }, precision: { type: "number", description: "Precision for numeric fields", optional: true }, scale: { type: "number", description: "Scale for numeric fields", optional: true }, referenceTo: { type: "string", description: "API name of the object to reference (for Lookup/MasterDetail)", optional: true }, relationshipLabel: { type: "string", description: "Label for the relationship (for Lookup/MasterDetail)", optional: true }, relationshipName: { type: "string", description: "API name for the relationship (for Lookup/MasterDetail)", optional: true }, deleteConstraint: { type: "string", enum: ["Cascade", "Restrict", "SetNull"], description: "Delete constraint for Lookup fields", optional: true }, picklistValues: { type: "array", items: { type: "object", properties: { label: { type: "string" }, isDefault: { type: "boolean", optional: true } } }, description: "Values for Picklist/MultiselectPicklist fields", optional: true }, description: { type: "string", description: "Description of the field", optional: true }, grantAccessTo: { type: "array", items: { type: "string" }, description: "Profile names to grant field access to (defaults to ['System Administrator'])", optional: true } }, required: ["operation", "objectName", "fieldName"] } }; export interface ManageFieldArgs { operation: 'create' | 'update'; objectName: string; fieldName: string; label?: string; type?: string; required?: boolean; unique?: boolean; externalId?: boolean; length?: number; precision?: number; scale?: number; referenceTo?: string; relationshipLabel?: string; relationshipName?: string; deleteConstraint?: 'Cascade' | 'Restrict' | 'SetNull'; picklistValues?: Array<{ label: string; isDefault?: boolean }>; description?: string; grantAccessTo?: string[]; } // Helper function to set field permissions (simplified version of the one in manageFieldPermissions.ts) async function grantFieldPermissions(conn: any, objectName: string, fieldName: string, profileNames: string[]): Promise<{success: boolean; message: string}> { try { const fieldApiName = fieldName.endsWith('__c') || fieldName.includes('.') ? fieldName : `${fieldName}__c`; const fullFieldName = `${objectName}.${fieldApiName}`; // Get profile IDs const profileQuery = await conn.query(` SELECT Id, Name FROM Profile WHERE Name IN (${profileNames.map(name => `'${name}'`).join(', ')}) `); if (profileQuery.records.length === 0) { return { success: false, message: `No profiles found matching: ${profileNames.join(', ')}` }; } const results: any[] = []; const errors: string[] = []; for (const profile of profileQuery.records) { try { // Check if permission already exists const existingPerm = await conn.query(` SELECT Id, PermissionsRead, PermissionsEdit FROM FieldPermissions WHERE ParentId IN ( SELECT Id FROM PermissionSet WHERE IsOwnedByProfile = true AND ProfileId = '${profile.Id}' ) AND Field = '${fullFieldName}' AND SobjectType = '${objectName}' LIMIT 1 `); if (existingPerm.records.length > 0) { // Update existing permission await conn.sobject('FieldPermissions').update({ Id: existingPerm.records[0].Id, PermissionsRead: true, PermissionsEdit: true }); results.push(profile.Name); } else { // Get the PermissionSet ID for this profile const permSetQuery = await conn.query(` SELECT Id FROM PermissionSet WHERE IsOwnedByProfile = true AND ProfileId = '${profile.Id}' LIMIT 1 `); if (permSetQuery.records.length > 0) { // Create new permission await conn.sobject('FieldPermissions').create({ ParentId: permSetQuery.records[0].Id, SobjectType: objectName, Field: fullFieldName, PermissionsRead: true, PermissionsEdit: true }); results.push(profile.Name); } else { errors.push(profile.Name); } } } catch (error) { errors.push(profile.Name); console.error(`Error granting permission to ${profile.Name}:`, error); } } if (results.length > 0) { return { success: true, message: `Field Level Security granted to: ${results.join(', ')}${errors.length > 0 ? `. Failed for: ${errors.join(', ')}` : ''}` }; } else { return { success: false, message: `Could not grant Field Level Security to any profiles.` }; } } catch (error) { console.error('Error granting field permissions:', error); return { success: false, message: `Field Level Security configuration failed.` }; } } export async function handleManageField(conn: any, args: ManageFieldArgs) { const { operation, objectName, fieldName, type, grantAccessTo, ...fieldProps } = args; try { if (operation === 'create') { if (!type) { throw new Error('Field type is required for field creation'); } // Prepare base metadata for the new field const metadata: FieldMetadataInfo = { fullName: `${objectName}.${fieldName}__c`, label: fieldProps.label || fieldName, type, ...(fieldProps.required && { required: fieldProps.required }), ...(fieldProps.unique && { unique: fieldProps.unique }), ...(fieldProps.externalId && { externalId: fieldProps.externalId }), ...(fieldProps.description && { description: fieldProps.description }) }; // Add type-specific properties switch (type) { case 'MasterDetail': case 'Lookup': if (fieldProps.referenceTo) { metadata.referenceTo = fieldProps.referenceTo; metadata.relationshipName = fieldProps.relationshipName; metadata.relationshipLabel = fieldProps.relationshipLabel || fieldProps.relationshipName; if (type === 'Lookup' && fieldProps.deleteConstraint) { metadata.deleteConstraint = fieldProps.deleteConstraint; } } break; case 'TextArea': metadata.type = 'LongTextArea'; metadata.length = fieldProps.length || 32768; metadata.visibleLines = 3; break; case 'Text': if (fieldProps.length) { metadata.length = fieldProps.length; } break; case 'Number': if (fieldProps.precision) { metadata.precision = fieldProps.precision; metadata.scale = fieldProps.scale || 0; } break; case 'Picklist': case 'MultiselectPicklist': if (fieldProps.picklistValues) { metadata.valueSet = { valueSetDefinition: { sorted: true, value: fieldProps.picklistValues.map(val => ({ fullName: val.label, default: val.isDefault || false, label: val.label })) } }; } break; } // Create the field const result = await conn.metadata.create('CustomField', metadata); if (result && (Array.isArray(result) ? result[0].success : result.success)) { let permissionMessage = ''; // Grant Field Level Security (default to System Administrator if not specified) const profilesToGrant = grantAccessTo && grantAccessTo.length > 0 ? grantAccessTo : ['System Administrator']; // Wait a moment for field to be fully created await new Promise(resolve => setTimeout(resolve, 2000)); const permissionResult = await grantFieldPermissions(conn, objectName, fieldName, profilesToGrant); permissionMessage = `\n${permissionResult.message}`; return { content: [{ type: "text", text: `Successfully created custom field ${fieldName}__c on ${objectName}.${permissionMessage}` }], isError: false, }; } } else { // For update, first get existing metadata const existingMetadata = await conn.metadata.read('CustomField', [`${objectName}.${fieldName}__c`]); const currentMetadata = Array.isArray(existingMetadata) ? existingMetadata[0] : existingMetadata; if (!currentMetadata) { throw new Error(`Field ${fieldName}__c not found on object ${objectName}`); } // Prepare update metadata const metadata: FieldMetadataInfo = { ...currentMetadata, ...(fieldProps.label && { label: fieldProps.label }), ...(fieldProps.required !== undefined && { required: fieldProps.required }), ...(fieldProps.unique !== undefined && { unique: fieldProps.unique }), ...(fieldProps.externalId !== undefined && { externalId: fieldProps.externalId }), ...(fieldProps.description !== undefined && { description: fieldProps.description }), ...(fieldProps.length && { length: fieldProps.length }), ...(fieldProps.precision && { precision: fieldProps.precision, scale: fieldProps.scale || 0 }) }; // Special handling for picklist values if provided if (fieldProps.picklistValues && (currentMetadata.type === 'Picklist' || currentMetadata.type === 'MultiselectPicklist')) { metadata.valueSet = { valueSetDefinition: { sorted: true, value: fieldProps.picklistValues.map(val => ({ fullName: val.label, default: val.isDefault || false, label: val.label })) } }; } // Update the field const result = await conn.metadata.update('CustomField', metadata); if (result && (Array.isArray(result) ? result[0].success : result.success)) { return { content: [{ type: "text", text: `Successfully updated custom field ${fieldName}__c on ${objectName}` }], isError: false, }; } } return { content: [{ type: "text", text: `Failed to ${operation} custom field ${fieldName}__c` }], isError: true, }; } catch (error) { return { content: [{ type: "text", text: `Error ${operation === 'create' ? 'creating' : 'updating'} custom field: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import * as dotenv from "dotenv"; import { createSalesforceConnection } from "./utils/connection.js"; import { SEARCH_OBJECTS, handleSearchObjects } from "./tools/search.js"; import { DESCRIBE_OBJECT, handleDescribeObject } from "./tools/describe.js"; import { QUERY_RECORDS, handleQueryRecords, QueryArgs } from "./tools/query.js"; import { AGGREGATE_QUERY, handleAggregateQuery, AggregateQueryArgs } from "./tools/aggregateQuery.js"; import { DML_RECORDS, handleDMLRecords, DMLArgs } from "./tools/dml.js"; import { MANAGE_OBJECT, handleManageObject, ManageObjectArgs } from "./tools/manageObject.js"; import { MANAGE_FIELD, handleManageField, ManageFieldArgs } from "./tools/manageField.js"; import { MANAGE_FIELD_PERMISSIONS, handleManageFieldPermissions, ManageFieldPermissionsArgs } from "./tools/manageFieldPermissions.js"; import { SEARCH_ALL, handleSearchAll, SearchAllArgs, WithClause } from "./tools/searchAll.js"; import { READ_APEX, handleReadApex, ReadApexArgs } from "./tools/readApex.js"; import { WRITE_APEX, handleWriteApex, WriteApexArgs } from "./tools/writeApex.js"; import { READ_APEX_TRIGGER, handleReadApexTrigger, ReadApexTriggerArgs } from "./tools/readApexTrigger.js"; import { WRITE_APEX_TRIGGER, handleWriteApexTrigger, WriteApexTriggerArgs } from "./tools/writeApexTrigger.js"; import { EXECUTE_ANONYMOUS, handleExecuteAnonymous, ExecuteAnonymousArgs } from "./tools/executeAnonymous.js"; import { MANAGE_DEBUG_LOGS, handleManageDebugLogs, ManageDebugLogsArgs } from "./tools/manageDebugLogs.js"; dotenv.config(); const server = new Server( { name: "salesforce-mcp-server", version: "1.0.0", }, { capabilities: { tools: {}, }, }, ); // Tool handlers server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ SEARCH_OBJECTS, DESCRIBE_OBJECT, QUERY_RECORDS, AGGREGATE_QUERY, DML_RECORDS, MANAGE_OBJECT, MANAGE_FIELD, MANAGE_FIELD_PERMISSIONS, SEARCH_ALL, READ_APEX, WRITE_APEX, READ_APEX_TRIGGER, WRITE_APEX_TRIGGER, EXECUTE_ANONYMOUS, MANAGE_DEBUG_LOGS ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; if (!args) throw new Error('Arguments are required'); const conn = await createSalesforceConnection(); switch (name) { case "salesforce_search_objects": { const { searchPattern } = args as { searchPattern: string }; if (!searchPattern) throw new Error('searchPattern is required'); return await handleSearchObjects(conn, searchPattern); } case "salesforce_describe_object": { const { objectName } = args as { objectName: string }; if (!objectName) throw new Error('objectName is required'); return await handleDescribeObject(conn, objectName); } case "salesforce_query_records": { const queryArgs = args as Record<string, unknown>; if (!queryArgs.objectName || !Array.isArray(queryArgs.fields)) { throw new Error('objectName and fields array are required for query'); } // Type check and conversion const validatedArgs: QueryArgs = { objectName: queryArgs.objectName as string, fields: queryArgs.fields as string[], whereClause: queryArgs.whereClause as string | undefined, orderBy: queryArgs.orderBy as string | undefined, limit: queryArgs.limit as number | undefined }; return await handleQueryRecords(conn, validatedArgs); } case "salesforce_aggregate_query": { const aggregateArgs = args as Record<string, unknown>; if (!aggregateArgs.objectName || !Array.isArray(aggregateArgs.selectFields) || !Array.isArray(aggregateArgs.groupByFields)) { throw new Error('objectName, selectFields array, and groupByFields array are required for aggregate query'); } // Type check and conversion const validatedArgs: AggregateQueryArgs = { objectName: aggregateArgs.objectName as string, selectFields: aggregateArgs.selectFields as string[], groupByFields: aggregateArgs.groupByFields as string[], whereClause: aggregateArgs.whereClause as string | undefined, havingClause: aggregateArgs.havingClause as string | undefined, orderBy: aggregateArgs.orderBy as string | undefined, limit: aggregateArgs.limit as number | undefined }; return await handleAggregateQuery(conn, validatedArgs); } case "salesforce_dml_records": { const dmlArgs = args as Record<string, unknown>; if (!dmlArgs.operation || !dmlArgs.objectName || !Array.isArray(dmlArgs.records)) { throw new Error('operation, objectName, and records array are required for DML'); } const validatedArgs: DMLArgs = { operation: dmlArgs.operation as 'insert' | 'update' | 'delete' | 'upsert', objectName: dmlArgs.objectName as string, records: dmlArgs.records as Record<string, any>[], externalIdField: dmlArgs.externalIdField as string | undefined }; return await handleDMLRecords(conn, validatedArgs); } case "salesforce_manage_object": { const objectArgs = args as Record<string, unknown>; if (!objectArgs.operation || !objectArgs.objectName) { throw new Error('operation and objectName are required for object management'); } const validatedArgs: ManageObjectArgs = { operation: objectArgs.operation as 'create' | 'update', objectName: objectArgs.objectName as string, label: objectArgs.label as string | undefined, pluralLabel: objectArgs.pluralLabel as string | undefined, description: objectArgs.description as string | undefined, nameFieldLabel: objectArgs.nameFieldLabel as string | undefined, nameFieldType: objectArgs.nameFieldType as 'Text' | 'AutoNumber' | undefined, nameFieldFormat: objectArgs.nameFieldFormat as string | undefined, sharingModel: objectArgs.sharingModel as 'ReadWrite' | 'Read' | 'Private' | 'ControlledByParent' | undefined }; return await handleManageObject(conn, validatedArgs); } case "salesforce_manage_field": { const fieldArgs = args as Record<string, unknown>; if (!fieldArgs.operation || !fieldArgs.objectName || !fieldArgs.fieldName) { throw new Error('operation, objectName, and fieldName are required for field management'); } const validatedArgs: ManageFieldArgs = { operation: fieldArgs.operation as 'create' | 'update', objectName: fieldArgs.objectName as string, fieldName: fieldArgs.fieldName as string, label: fieldArgs.label as string | undefined, type: fieldArgs.type as string | undefined, required: fieldArgs.required as boolean | undefined, unique: fieldArgs.unique as boolean | undefined, externalId: fieldArgs.externalId as boolean | undefined, length: fieldArgs.length as number | undefined, precision: fieldArgs.precision as number | undefined, scale: fieldArgs.scale as number | undefined, referenceTo: fieldArgs.referenceTo as string | undefined, relationshipLabel: fieldArgs.relationshipLabel as string | undefined, relationshipName: fieldArgs.relationshipName as string | undefined, deleteConstraint: fieldArgs.deleteConstraint as 'Cascade' | 'Restrict' | 'SetNull' | undefined, picklistValues: fieldArgs.picklistValues as Array<{ label: string; isDefault?: boolean }> | undefined, description: fieldArgs.description as string | undefined, grantAccessTo: fieldArgs.grantAccessTo as string[] | undefined }; return await handleManageField(conn, validatedArgs); } case "salesforce_manage_field_permissions": { const permArgs = args as Record<string, unknown>; if (!permArgs.operation || !permArgs.objectName || !permArgs.fieldName) { throw new Error('operation, objectName, and fieldName are required for field permissions management'); } const validatedArgs: ManageFieldPermissionsArgs = { operation: permArgs.operation as 'grant' | 'revoke' | 'view', objectName: permArgs.objectName as string, fieldName: permArgs.fieldName as string, profileNames: permArgs.profileNames as string[] | undefined, readable: permArgs.readable as boolean | undefined, editable: permArgs.editable as boolean | undefined }; return await handleManageFieldPermissions(conn, validatedArgs); } case "salesforce_search_all": { const searchArgs = args as Record<string, unknown>; if (!searchArgs.searchTerm || !Array.isArray(searchArgs.objects)) { throw new Error('searchTerm and objects array are required for search'); } // Validate objects array const objects = searchArgs.objects as Array<Record<string, unknown>>; if (!objects.every(obj => obj.name && Array.isArray(obj.fields))) { throw new Error('Each object must specify name and fields array'); } // Type check and conversion const validatedArgs: SearchAllArgs = { searchTerm: searchArgs.searchTerm as string, searchIn: searchArgs.searchIn as "ALL FIELDS" | "NAME FIELDS" | "EMAIL FIELDS" | "PHONE FIELDS" | "SIDEBAR FIELDS" | undefined, objects: objects.map(obj => ({ name: obj.name as string, fields: obj.fields as string[], where: obj.where as string | undefined, orderBy: obj.orderBy as string | undefined, limit: obj.limit as number | undefined })), withClauses: searchArgs.withClauses as WithClause[] | undefined, updateable: searchArgs.updateable as boolean | undefined, viewable: searchArgs.viewable as boolean | undefined }; return await handleSearchAll(conn, validatedArgs); } case "salesforce_read_apex": { const apexArgs = args as Record<string, unknown>; // Type check and conversion const validatedArgs: ReadApexArgs = { className: apexArgs.className as string | undefined, namePattern: apexArgs.namePattern as string | undefined, includeMetadata: apexArgs.includeMetadata as boolean | undefined }; return await handleReadApex(conn, validatedArgs); } case "salesforce_write_apex": { const apexArgs = args as Record<string, unknown>; if (!apexArgs.operation || !apexArgs.className || !apexArgs.body) { throw new Error('operation, className, and body are required for writing Apex'); } // Type check and conversion const validatedArgs: WriteApexArgs = { operation: apexArgs.operation as 'create' | 'update', className: apexArgs.className as string, apiVersion: apexArgs.apiVersion as string | undefined, body: apexArgs.body as string }; return await handleWriteApex(conn, validatedArgs); } case "salesforce_read_apex_trigger": { const triggerArgs = args as Record<string, unknown>; // Type check and conversion const validatedArgs: ReadApexTriggerArgs = { triggerName: triggerArgs.triggerName as string | undefined, namePattern: triggerArgs.namePattern as string | undefined, includeMetadata: triggerArgs.includeMetadata as boolean | undefined }; return await handleReadApexTrigger(conn, validatedArgs); } case "salesforce_write_apex_trigger": { const triggerArgs = args as Record<string, unknown>; if (!triggerArgs.operation || !triggerArgs.triggerName || !triggerArgs.body) { throw new Error('operation, triggerName, and body are required for writing Apex trigger'); } // Type check and conversion const validatedArgs: WriteApexTriggerArgs = { operation: triggerArgs.operation as 'create' | 'update', triggerName: triggerArgs.triggerName as string, objectName: triggerArgs.objectName as string | undefined, apiVersion: triggerArgs.apiVersion as string | undefined, body: triggerArgs.body as string }; return await handleWriteApexTrigger(conn, validatedArgs); } case "salesforce_execute_anonymous": { const executeArgs = args as Record<string, unknown>; if (!executeArgs.apexCode) { throw new Error('apexCode is required for executing anonymous Apex'); } // Type check and conversion const validatedArgs: ExecuteAnonymousArgs = { apexCode: executeArgs.apexCode as string, logLevel: executeArgs.logLevel as 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'FINE' | 'FINER' | 'FINEST' | undefined }; return await handleExecuteAnonymous(conn, validatedArgs); } case "salesforce_manage_debug_logs": { const debugLogsArgs = args as Record<string, unknown>; if (!debugLogsArgs.operation || !debugLogsArgs.username) { throw new Error('operation and username are required for managing debug logs'); } // Type check and conversion const validatedArgs: ManageDebugLogsArgs = { operation: debugLogsArgs.operation as 'enable' | 'disable' | 'retrieve', username: debugLogsArgs.username as string, logLevel: debugLogsArgs.logLevel as 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'FINE' | 'FINER' | 'FINEST' | undefined, expirationTime: debugLogsArgs.expirationTime as number | undefined, limit: debugLogsArgs.limit as number | undefined, logId: debugLogsArgs.logId as string | undefined, includeBody: debugLogsArgs.includeBody as boolean | undefined }; return await handleManageDebugLogs(conn, validatedArgs); } default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; } } catch (error) { return { content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}`, }], isError: true, }; } }); async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Salesforce MCP Server running on stdio"); } runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); }); ``` -------------------------------------------------------------------------------- /src/tools/manageDebugLogs.ts: -------------------------------------------------------------------------------- ```typescript import { Tool } from "@modelcontextprotocol/sdk/types.js"; import type { Connection } from "jsforce"; export const MANAGE_DEBUG_LOGS: Tool = { name: "salesforce_manage_debug_logs", description: `Manage debug logs for Salesforce users - enable, disable, or retrieve logs. Examples: 1. Enable debug logs for a user: { "operation": "enable", "username": "[email protected]", "logLevel": "DEBUG", "expirationTime": 30 } 2. Disable debug logs for a user: { "operation": "disable", "username": "[email protected]" } 3. Retrieve debug logs for a user: { "operation": "retrieve", "username": "[email protected]", "limit": 5 } 4. Retrieve a specific log with full content: { "operation": "retrieve", "username": "[email protected]", "logId": "07L1g000000XXXXEAA0", "includeBody": true } Notes: - The operation must be one of: 'enable', 'disable', or 'retrieve' - The username parameter is required for all operations - For 'enable' operation, logLevel is optional (defaults to 'DEBUG') - Log levels: NONE, ERROR, WARN, INFO, DEBUG, FINE, FINER, FINEST - expirationTime is optional for 'enable' operation (minutes until expiration, defaults to 30) - limit is optional for 'retrieve' operation (maximum number of logs to return, defaults to 10) - logId is optional for 'retrieve' operation (to get a specific log) - includeBody is optional for 'retrieve' operation (to include the full log content, defaults to false) - The tool validates that the specified user exists before performing operations - If logLevel is not specified when enabling logs, the tool will ask for clarification`, inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["enable", "disable", "retrieve"], description: "Operation to perform on debug logs" }, username: { type: "string", description: "Username of the Salesforce user" }, logLevel: { type: "string", enum: ["NONE", "ERROR", "WARN", "INFO", "DEBUG", "FINE", "FINER", "FINEST"], description: "Log level for debug logs (required for 'enable' operation)" }, expirationTime: { type: "number", description: "Minutes until the debug log configuration expires (optional, defaults to 30)" }, limit: { type: "number", description: "Maximum number of logs to retrieve (optional, defaults to 10)" }, logId: { type: "string", description: "ID of a specific log to retrieve (optional)" }, includeBody: { type: "boolean", description: "Whether to include the full log content (optional, defaults to false)" } }, required: ["operation", "username"] } }; export interface ManageDebugLogsArgs { operation: 'enable' | 'disable' | 'retrieve'; username: string; logLevel?: 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'FINE' | 'FINER' | 'FINEST'; expirationTime?: number; limit?: number; logId?: string; includeBody?: boolean; } /** * Handles managing debug logs for Salesforce users * @param conn Active Salesforce connection * @param args Arguments for managing debug logs * @returns Tool response with operation results */ export async function handleManageDebugLogs(conn: any, args: ManageDebugLogsArgs) { try { // Validate inputs if (!args.username) { throw new Error('username is required'); } // Determine if the input is likely a username or a full name const isLikelyUsername = args.username.includes('@') || !args.username.includes(' '); // Build the query based on whether the input looks like a username or a full name let userQuery; if (isLikelyUsername) { // Query by username userQuery = await conn.query(` SELECT Id, Username, Name, IsActive FROM User WHERE Username = '${args.username}' `); } else { // Query by full name userQuery = await conn.query(` SELECT Id, Username, Name, IsActive FROM User WHERE Name LIKE '%${args.username}%' ORDER BY LastModifiedDate DESC LIMIT 5 `); } if (userQuery.records.length === 0) { // If no results with the initial query, try a more flexible search userQuery = await conn.query(` SELECT Id, Username, Name, IsActive FROM User WHERE Name LIKE '%${args.username}%' OR Username LIKE '%${args.username}%' ORDER BY LastModifiedDate DESC LIMIT 5 `); if (userQuery.records.length === 0) { return { content: [{ type: "text", text: `Error: No user found matching '${args.username}'. Please verify the username or full name and try again.` }], isError: true, }; } // If multiple users found, ask for clarification if (userQuery.records.length > 1) { let responseText = `Multiple users found matching '${args.username}'. Please specify which user by providing the exact username:\n\n`; userQuery.records.forEach((user: any) => { responseText += `- **${user.Name}** (${user.Username})\n`; }); return { content: [{ type: "text", text: responseText }] }; } } const user = userQuery.records[0]; if (!user.IsActive) { return { content: [{ type: "text", text: `Warning: User '${args.username}' exists but is inactive. Debug logs may not be generated for inactive users.` }] }; } // Handle operations switch (args.operation) { case 'enable': { // If logLevel is not provided, we need to ask for it if (!args.logLevel) { return { content: [{ type: "text", text: `Please specify a log level for enabling debug logs. Valid options are: NONE, ERROR, WARN, INFO, DEBUG, FINE, FINER, FINEST.` }], isError: true, }; } // Set default expiration time if not provided const expirationTime = args.expirationTime || 30; // Check if a trace flag already exists for this user const existingTraceFlag = await conn.tooling.query(` SELECT Id, DebugLevelId FROM TraceFlag WHERE TracedEntityId = '${user.Id}' AND ExpirationDate > ${new Date().toISOString()} `); let traceFlagId; let debugLevelId; let operation; // Calculate expiration date const expirationDate = new Date(); expirationDate.setMinutes(expirationDate.getMinutes() + expirationTime); if (existingTraceFlag.records.length > 0) { // Update existing trace flag traceFlagId = existingTraceFlag.records[0].Id; debugLevelId = existingTraceFlag.records[0].DebugLevelId; await conn.tooling.sobject('TraceFlag').update({ Id: traceFlagId, LogType: 'USER_DEBUG', StartDate: new Date().toISOString(), ExpirationDate: expirationDate.toISOString() }); operation = 'updated'; } else { // Create a new debug level with the correct field names const debugLevelResult = await conn.tooling.sobject('DebugLevel').create({ DeveloperName: `UserDebug_${Date.now()}`, MasterLabel: `User Debug ${user.Username}`, ApexCode: args.logLevel, ApexProfiling: args.logLevel, Callout: args.logLevel, Database: args.logLevel, System: args.logLevel, Validation: args.logLevel, Visualforce: args.logLevel, Workflow: args.logLevel }); debugLevelId = debugLevelResult.id; // Create a new trace flag const traceFlagResult = await conn.tooling.sobject('TraceFlag').create({ TracedEntityId: user.Id, DebugLevelId: debugLevelId, LogType: 'USER_DEBUG', StartDate: new Date().toISOString(), ExpirationDate: expirationDate.toISOString() }); traceFlagId = traceFlagResult.id; operation = 'enabled'; } return { content: [{ type: "text", text: `Successfully ${operation} debug logs for user '${args.username}'.\n\n` + `**Log Level:** ${args.logLevel}\n` + `**Expiration:** ${expirationDate.toLocaleString()} (${expirationTime} minutes from now)\n` + `**Trace Flag ID:** ${traceFlagId}` }] }; } case 'disable': { // Find all active trace flags for this user const traceFlags = await conn.tooling.query(` SELECT Id FROM TraceFlag WHERE TracedEntityId = '${user.Id}' AND ExpirationDate > ${new Date().toISOString()} `); if (traceFlags.records.length === 0) { return { content: [{ type: "text", text: `No active debug logs found for user '${args.username}'.` }] }; } try { // Delete trace flags instead of updating expiration date const traceFlagIds = traceFlags.records.map((tf: any) => tf.Id); const deleteResults = await Promise.all( traceFlagIds.map((id: string) => conn.tooling.sobject('TraceFlag').delete(id) ) ); return { content: [{ type: "text", text: `Successfully disabled ${traceFlagIds.length} debug log configuration(s) for user '${args.username}' by removing them.` }] }; } catch (deleteError) { console.error('Error deleting trace flags:', deleteError); // Fallback to setting a future expiration date if delete fails try { // Set expiration date to 5 minutes in the future to satisfy Salesforce's requirement const nearFutureExpiration = new Date(); nearFutureExpiration.setMinutes(nearFutureExpiration.getMinutes() + 5); const traceFlagIds = traceFlags.records.map((tf: any) => tf.Id); const updateResults = await Promise.all( traceFlagIds.map((id: string) => conn.tooling.sobject('TraceFlag').update({ Id: id, ExpirationDate: nearFutureExpiration.toISOString() }) ) ); return { content: [{ type: "text", text: `Successfully disabled ${traceFlagIds.length} debug log configuration(s) for user '${args.username}'. They will expire in 5 minutes.` }] }; } catch (updateError) { console.error('Error updating trace flags:', updateError); throw new Error(`Could not disable debug logs: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`); } } } case 'retrieve': { // Set default limit if not provided const limit = args.limit || 10; // If a specific log ID is provided, retrieve that log directly if (args.logId) { try { // First check if the log exists const logQuery = await conn.tooling.query(` SELECT Id, LogUserId, Operation, Application, Status, LogLength, LastModifiedDate, Request FROM ApexLog WHERE Id = '${args.logId}' `); if (logQuery.records.length === 0) { return { content: [{ type: "text", text: `No log found with ID '${args.logId}'.` }] }; } const log = logQuery.records[0]; // If includeBody is true, retrieve the log body if (args.includeBody) { try { // Retrieve the log body const logBody = await conn.tooling.request({ method: 'GET', url: `${conn.instanceUrl}/services/data/v58.0/tooling/sobjects/ApexLog/${log.Id}/Body` }); let responseText = `**Log Details:**\n\n`; responseText += `- **ID:** ${log.Id}\n`; responseText += `- **Operation:** ${log.Operation}\n`; responseText += `- **Application:** ${log.Application}\n`; responseText += `- **Status:** ${log.Status}\n`; responseText += `- **Size:** ${log.LogLength} bytes\n`; responseText += `- **Date:** ${new Date(log.LastModifiedDate).toLocaleString()}\n\n`; responseText += `**Log Body:**\n\`\`\`\n${logBody}\n\`\`\`\n`; return { content: [{ type: "text", text: responseText }] }; } catch (logError) { console.error('Error retrieving log body:', logError); return { content: [{ type: "text", text: `Error retrieving log body: ${logError instanceof Error ? logError.message : String(logError)}` }], isError: true }; } } else { // Just return the log metadata let responseText = `**Log Details:**\n\n`; responseText += `- **ID:** ${log.Id}\n`; responseText += `- **Operation:** ${log.Operation}\n`; responseText += `- **Application:** ${log.Application}\n`; responseText += `- **Status:** ${log.Status}\n`; responseText += `- **Size:** ${log.LogLength} bytes\n`; responseText += `- **Date:** ${new Date(log.LastModifiedDate).toLocaleString()}\n\n`; responseText += `To view the full log content, add "includeBody": true to your request.`; return { content: [{ type: "text", text: responseText }] }; } } catch (error) { console.error('Error retrieving log:', error); return { content: [{ type: "text", text: `Error retrieving log: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } // Query for logs const logs = await conn.tooling.query(` SELECT Id, LogUserId, Operation, Application, Status, LogLength, LastModifiedDate, Request FROM ApexLog WHERE LogUserId = '${user.Id}' ORDER BY LastModifiedDate DESC LIMIT ${limit} `); if (logs.records.length === 0) { return { content: [{ type: "text", text: `No debug logs found for user '${args.username}'.` }] }; } // Format log information let responseText = `Found ${logs.records.length} debug logs for user '${args.username}':\n\n`; for (let i = 0; i < logs.records.length; i++) { const log = logs.records[i]; responseText += `**Log ${i + 1}**\n`; responseText += `- **ID:** ${log.Id}\n`; responseText += `- **Operation:** ${log.Operation}\n`; responseText += `- **Application:** ${log.Application}\n`; responseText += `- **Status:** ${log.Status}\n`; responseText += `- **Size:** ${log.LogLength} bytes\n`; responseText += `- **Date:** ${new Date(log.LastModifiedDate).toLocaleString()}\n\n`; } // Add a note about viewing specific logs with full content responseText += `To view a specific log with full content, use:\n\`\`\`\n`; responseText += `{\n`; responseText += ` "operation": "retrieve",\n`; responseText += ` "username": "${args.username}",\n`; responseText += ` "logId": "<LOG_ID>",\n`; responseText += ` "includeBody": true\n`; responseText += `}\n\`\`\`\n`; return { content: [{ type: "text", text: responseText }] }; } default: throw new Error(`Invalid operation: ${args.operation}. Must be 'enable', 'disable', or 'retrieve'.`); } } catch (error) { console.error('Error managing debug logs:', error); return { content: [{ type: "text", text: `Error managing debug logs: ${error instanceof Error ? error.message : String(error)}` }], isError: true, }; } } ```