├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── 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 └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | package-lock.json 4 | 5 | # Build output 6 | dist/ 7 | 8 | # Environment variables 9 | .env 10 | 11 | # IDE and OS files 12 | .DS_Store 13 | .vscode/ 14 | .idea/ 15 | 16 | # Logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tapas Mukherjee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Salesforce MCP Server 2 | 3 | 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. 4 | 5 | 6 | Salesforce Server MCP server 7 | 8 | 9 | ## Features 10 | 11 | * **Object and Field Management**: Create and modify custom objects and fields using natural language 12 | * **Smart Object Search**: Find Salesforce objects using partial name matches 13 | * **Detailed Schema Information**: Get comprehensive field and relationship details for any object 14 | * **Flexible Data Queries**: Query records with relationship support and complex filters 15 | * **Data Manipulation**: Insert, update, delete, and upsert records with ease 16 | * **Cross-Object Search**: Search across multiple objects using SOSL 17 | * **Apex Code Management**: Read, create, and update Apex classes and triggers 18 | * **Intuitive Error Handling**: Clear feedback with Salesforce-specific error details 19 | 20 | ## Installation 21 | 22 | ```bash 23 | npm install -g @tsmztech/mcp-server-salesforce 24 | ``` 25 | 26 | ## Tools 27 | 28 | ### salesforce_search_objects 29 | Search for standard and custom objects: 30 | * Search by partial name matches 31 | * Finds both standard and custom objects 32 | * Example: "Find objects related to Account" will find Account, AccountHistory, etc. 33 | 34 | ### salesforce_describe_object 35 | Get detailed object schema information: 36 | * Field definitions and properties 37 | * Relationship details 38 | * Picklist values 39 | * Example: "Show me all fields in the Account object" 40 | 41 | ### salesforce_query_records 42 | Query records with relationship support: 43 | * Parent-to-child relationships 44 | * Child-to-parent relationships 45 | * Complex WHERE conditions 46 | * Example: "Get all Accounts with their related Contacts" 47 | * Note: For queries with GROUP BY or aggregate functions, use salesforce_aggregate_query 48 | 49 | ### salesforce_aggregate_query 50 | Execute aggregate queries with GROUP BY: 51 | * GROUP BY single or multiple fields 52 | * Aggregate functions: COUNT, COUNT_DISTINCT, SUM, AVG, MIN, MAX 53 | * HAVING clauses for filtering grouped results 54 | * Date/time grouping functions 55 | * Example: "Count opportunities by stage" or "Find accounts with more than 10 opportunities" 56 | 57 | ### salesforce_dml_records 58 | Perform data operations: 59 | * Insert new records 60 | * Update existing records 61 | * Delete records 62 | * Upsert using external IDs 63 | * Example: "Update status of multiple accounts" 64 | 65 | ### salesforce_manage_object 66 | Create and modify custom objects: 67 | * Create new custom objects 68 | * Update object properties 69 | * Configure sharing settings 70 | * Example: "Create a Customer Feedback object" 71 | 72 | ### salesforce_manage_field 73 | Manage object fields: 74 | * Add new custom fields 75 | * Modify field properties 76 | * Create relationships 77 | * Automatically grants Field Level Security to System Administrator by default 78 | * Use `grantAccessTo` parameter to specify different profiles 79 | * Example: "Add a Rating picklist field to Account" 80 | 81 | ### salesforce_manage_field_permissions 82 | Manage Field Level Security (Field Permissions): 83 | * Grant or revoke read/edit access to fields for specific profiles 84 | * View current field permissions 85 | * Bulk update permissions for multiple profiles 86 | * Useful for managing permissions after field creation or for existing fields 87 | * Example: "Grant System Administrator access to Custom_Field__c on Account" 88 | 89 | ### salesforce_search_all 90 | Search across multiple objects: 91 | * SOSL-based search 92 | * Multiple object support 93 | * Field snippets 94 | * Example: "Search for 'cloud' across Accounts and Opportunities" 95 | 96 | ### salesforce_read_apex 97 | Read Apex classes: 98 | * Get full source code of specific classes 99 | * List classes matching name patterns 100 | * View class metadata (API version, status, etc.) 101 | * Support for wildcards (* and ?) in name patterns 102 | * Example: "Show me the AccountController class" or "Find all classes matching Account*Cont*" 103 | 104 | ### salesforce_write_apex 105 | Create and update Apex classes: 106 | * Create new Apex classes 107 | * Update existing class implementations 108 | * Specify API versions 109 | * Example: "Create a new Apex class for handling account operations" 110 | 111 | ### salesforce_read_apex_trigger 112 | Read Apex triggers: 113 | * Get full source code of specific triggers 114 | * List triggers matching name patterns 115 | * View trigger metadata (API version, object, status, etc.) 116 | * Support for wildcards (* and ?) in name patterns 117 | * Example: "Show me the AccountTrigger" or "Find all triggers for Contact object" 118 | 119 | ### salesforce_write_apex_trigger 120 | Create and update Apex triggers: 121 | * Create new Apex triggers for specific objects 122 | * Update existing trigger implementations 123 | * Specify API versions and event operations 124 | * Example: "Create a new trigger for the Account object" or "Update the Lead trigger" 125 | 126 | ### salesforce_execute_anonymous 127 | Execute anonymous Apex code: 128 | * Run Apex code without creating a permanent class 129 | * View debug logs and execution results 130 | * Useful for data operations not directly supported by other tools 131 | * Example: "Execute Apex code to calculate account metrics" or "Run a script to update related records" 132 | 133 | ### salesforce_manage_debug_logs 134 | Manage debug logs for Salesforce users: 135 | * Enable debug logs for specific users 136 | * Disable active debug log configurations 137 | * Retrieve and view debug logs 138 | * Configure log levels (NONE, ERROR, WARN, INFO, DEBUG, FINE, FINER, FINEST) 139 | * Example: "Enable debug logs for user@example.com" or "Retrieve recent logs for an admin user" 140 | 141 | ## Setup 142 | 143 | ### Salesforce Authentication 144 | You can connect to Salesforce using one of two authentication methods: 145 | 146 | #### 1. Username/Password Authentication (Default) 147 | 1. Set up your Salesforce credentials 148 | 2. Get your security token (Reset from Salesforce Settings) 149 | 150 | #### 2. OAuth 2.0 Client Credentials Flow 151 | 1. Create a Connected App in Salesforce 152 | 2. Enable OAuth settings and select "Client Credentials Flow" 153 | 3. Set appropriate scopes (typically "api" is sufficient) 154 | 4. Save the Client ID and Client Secret 155 | 5. **Important**: Note your instance URL (e.g., `https://your-domain.my.salesforce.com`) as it's required for authentication 156 | 157 | ### Usage with Claude Desktop 158 | 159 | Add to your `claude_desktop_config.json`: 160 | 161 | #### For Username/Password Authentication: 162 | ```json 163 | { 164 | "mcpServers": { 165 | "salesforce": { 166 | "command": "npx", 167 | "args": ["-y", "@tsmztech/mcp-server-salesforce"], 168 | "env": { 169 | "SALESFORCE_CONNECTION_TYPE": "User_Password", 170 | "SALESFORCE_USERNAME": "your_username", 171 | "SALESFORCE_PASSWORD": "your_password", 172 | "SALESFORCE_TOKEN": "your_security_token", 173 | "SALESFORCE_INSTANCE_URL": "org_url" // Optional. Default value: https://login.salesforce.com 174 | } 175 | } 176 | } 177 | } 178 | ``` 179 | 180 | #### For OAuth 2.0 Client Credentials Flow: 181 | ```json 182 | { 183 | "mcpServers": { 184 | "salesforce": { 185 | "command": "npx", 186 | "args": ["-y", "@tsmztech/mcp-server-salesforce"], 187 | "env": { 188 | "SALESFORCE_CONNECTION_TYPE": "OAuth_2.0_Client_Credentials", 189 | "SALESFORCE_CLIENT_ID": "your_client_id", 190 | "SALESFORCE_CLIENT_SECRET": "your_client_secret", 191 | "SALESFORCE_INSTANCE_URL": "https://your-domain.my.salesforce.com" // REQUIRED: Must be your exact Salesforce instance URL 192 | } 193 | } 194 | } 195 | } 196 | ``` 197 | 198 | > **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 `/services/oauth2/token`. 199 | 200 | ## Example Usage 201 | 202 | ### Searching Objects 203 | ``` 204 | "Find all objects related to Accounts" 205 | "Show me objects that handle customer service" 206 | "What objects are available for order management?" 207 | ``` 208 | 209 | ### Getting Schema Information 210 | ``` 211 | "What fields are available in the Account object?" 212 | "Show me the picklist values for Case Status" 213 | "Describe the relationship fields in Opportunity" 214 | ``` 215 | 216 | ### Querying Records 217 | ``` 218 | "Get all Accounts created this month" 219 | "Show me high-priority Cases with their related Contacts" 220 | "Find all Opportunities over $100k" 221 | ``` 222 | 223 | ### Aggregate Queries 224 | ``` 225 | "Count opportunities by stage" 226 | "Show me the total revenue by account" 227 | "Find accounts with more than 10 opportunities" 228 | "Calculate average deal size by sales rep and quarter" 229 | "Get the number of cases by priority and status" 230 | ``` 231 | 232 | ### Managing Custom Objects 233 | ``` 234 | "Create a Customer Feedback object" 235 | "Add a Rating field to the Feedback object" 236 | "Update sharing settings for the Service Request object" 237 | ``` 238 | Examples with Field Level Security: 239 | ``` 240 | # Default - grants access to System Administrator automatically 241 | "Create a Status picklist field on Custom_Object__c" 242 | 243 | # Custom profiles - grants access to specified profiles 244 | "Create a Revenue currency field on Account and grant access to Sales User and Marketing User profiles" 245 | ``` 246 | 247 | ### Managing Field Permissions 248 | ``` 249 | "Grant System Administrator access to Custom_Field__c on Account" 250 | "Give read-only access to Rating__c field for Sales User profile" 251 | "View which profiles have access to the Custom_Field__c" 252 | "Revoke field access for specific profiles" 253 | ``` 254 | 255 | ### Searching Across Objects 256 | ``` 257 | "Search for 'cloud' in Accounts and Opportunities" 258 | "Find mentions of 'network issue' in Cases and Knowledge Articles" 259 | "Search for customer name across all relevant objects" 260 | ``` 261 | 262 | ### Managing Apex Code 263 | ``` 264 | "Show me all Apex classes with 'Controller' in the name" 265 | "Get the full code for the AccountService class" 266 | "Create a new Apex utility class for handling date operations" 267 | "Update the LeadConverter class to add a new method" 268 | ``` 269 | 270 | ### Managing Apex Triggers 271 | ``` 272 | "List all triggers for the Account object" 273 | "Show me the code for the ContactTrigger" 274 | "Create a new trigger for the Opportunity object" 275 | "Update the Case trigger to handle after delete events" 276 | ``` 277 | 278 | ### Executing Anonymous Apex Code 279 | ``` 280 | "Execute Apex code to calculate account metrics" 281 | "Run a script to update related records" 282 | "Execute a batch job to process large datasets" 283 | ``` 284 | 285 | ### Managing Debug Logs 286 | ``` 287 | "Enable debug logs for user@example.com" 288 | "Retrieve recent logs for an admin user" 289 | "Disable debug logs for a specific user" 290 | "Configure log level to DEBUG for a user" 291 | ``` 292 | 293 | ## Development 294 | 295 | ### Building from source 296 | ```bash 297 | # Clone the repository 298 | git clone https://github.com/tsmztech/mcp-server-salesforce.git 299 | 300 | # Navigate to directory 301 | cd mcp-server-salesforce 302 | 303 | # Install dependencies 304 | npm install 305 | 306 | # Build the project 307 | npm run build 308 | ``` 309 | 310 | ## Contributing 311 | Contributions are welcome! Feel free to submit a Pull Request. 312 | 313 | ## License 314 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 315 | 316 | ## Issues and Support 317 | If you encounter any issues or need support, please file an issue on the [GitHub repository](https://github.com/tsmztech/mcp-server-salesforce/issues). -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tsmztech/mcp-server-salesforce", 3 | "version": "0.0.3", 4 | "description": "A Salesforce connector MCP Server.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "type": "module", 8 | "bin": { 9 | "salesforce-connector": "dist/index.js" 10 | }, 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "build": "tsc && shx chmod +x dist/*.js", 16 | "prepare": "npm run build", 17 | "watch": "tsc --watch" 18 | }, 19 | "keywords": [ 20 | "mcp", 21 | "salesforce", 22 | "claude", 23 | "ai" 24 | ], 25 | "author": "tsmztech", 26 | "license": "MIT", 27 | "dependencies": { 28 | "@modelcontextprotocol/sdk": "0.5.0", 29 | "dotenv": "^16.3.1", 30 | "jsforce": "^1.11.0" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^22.10.1", 34 | "typescript": "^5.7.2", 35 | "shx": "^0.3.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | } from "@modelcontextprotocol/sdk/types.js"; 9 | import * as dotenv from "dotenv"; 10 | 11 | import { createSalesforceConnection } from "./utils/connection.js"; 12 | import { SEARCH_OBJECTS, handleSearchObjects } from "./tools/search.js"; 13 | import { DESCRIBE_OBJECT, handleDescribeObject } from "./tools/describe.js"; 14 | import { QUERY_RECORDS, handleQueryRecords, QueryArgs } from "./tools/query.js"; 15 | import { AGGREGATE_QUERY, handleAggregateQuery, AggregateQueryArgs } from "./tools/aggregateQuery.js"; 16 | import { DML_RECORDS, handleDMLRecords, DMLArgs } from "./tools/dml.js"; 17 | import { MANAGE_OBJECT, handleManageObject, ManageObjectArgs } from "./tools/manageObject.js"; 18 | import { MANAGE_FIELD, handleManageField, ManageFieldArgs } from "./tools/manageField.js"; 19 | import { MANAGE_FIELD_PERMISSIONS, handleManageFieldPermissions, ManageFieldPermissionsArgs } from "./tools/manageFieldPermissions.js"; 20 | import { SEARCH_ALL, handleSearchAll, SearchAllArgs, WithClause } from "./tools/searchAll.js"; 21 | import { READ_APEX, handleReadApex, ReadApexArgs } from "./tools/readApex.js"; 22 | import { WRITE_APEX, handleWriteApex, WriteApexArgs } from "./tools/writeApex.js"; 23 | import { READ_APEX_TRIGGER, handleReadApexTrigger, ReadApexTriggerArgs } from "./tools/readApexTrigger.js"; 24 | import { WRITE_APEX_TRIGGER, handleWriteApexTrigger, WriteApexTriggerArgs } from "./tools/writeApexTrigger.js"; 25 | import { EXECUTE_ANONYMOUS, handleExecuteAnonymous, ExecuteAnonymousArgs } from "./tools/executeAnonymous.js"; 26 | import { MANAGE_DEBUG_LOGS, handleManageDebugLogs, ManageDebugLogsArgs } from "./tools/manageDebugLogs.js"; 27 | 28 | dotenv.config(); 29 | 30 | const server = new Server( 31 | { 32 | name: "salesforce-mcp-server", 33 | version: "1.0.0", 34 | }, 35 | { 36 | capabilities: { 37 | tools: {}, 38 | }, 39 | }, 40 | ); 41 | 42 | // Tool handlers 43 | server.setRequestHandler(ListToolsRequestSchema, async () => ({ 44 | tools: [ 45 | SEARCH_OBJECTS, 46 | DESCRIBE_OBJECT, 47 | QUERY_RECORDS, 48 | AGGREGATE_QUERY, 49 | DML_RECORDS, 50 | MANAGE_OBJECT, 51 | MANAGE_FIELD, 52 | MANAGE_FIELD_PERMISSIONS, 53 | SEARCH_ALL, 54 | READ_APEX, 55 | WRITE_APEX, 56 | READ_APEX_TRIGGER, 57 | WRITE_APEX_TRIGGER, 58 | EXECUTE_ANONYMOUS, 59 | MANAGE_DEBUG_LOGS 60 | ], 61 | })); 62 | 63 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 64 | try { 65 | const { name, arguments: args } = request.params; 66 | if (!args) throw new Error('Arguments are required'); 67 | 68 | const conn = await createSalesforceConnection(); 69 | 70 | switch (name) { 71 | case "salesforce_search_objects": { 72 | const { searchPattern } = args as { searchPattern: string }; 73 | if (!searchPattern) throw new Error('searchPattern is required'); 74 | return await handleSearchObjects(conn, searchPattern); 75 | } 76 | 77 | case "salesforce_describe_object": { 78 | const { objectName } = args as { objectName: string }; 79 | if (!objectName) throw new Error('objectName is required'); 80 | return await handleDescribeObject(conn, objectName); 81 | } 82 | 83 | case "salesforce_query_records": { 84 | const queryArgs = args as Record; 85 | if (!queryArgs.objectName || !Array.isArray(queryArgs.fields)) { 86 | throw new Error('objectName and fields array are required for query'); 87 | } 88 | // Type check and conversion 89 | const validatedArgs: QueryArgs = { 90 | objectName: queryArgs.objectName as string, 91 | fields: queryArgs.fields as string[], 92 | whereClause: queryArgs.whereClause as string | undefined, 93 | orderBy: queryArgs.orderBy as string | undefined, 94 | limit: queryArgs.limit as number | undefined 95 | }; 96 | return await handleQueryRecords(conn, validatedArgs); 97 | } 98 | 99 | case "salesforce_aggregate_query": { 100 | const aggregateArgs = args as Record; 101 | if (!aggregateArgs.objectName || !Array.isArray(aggregateArgs.selectFields) || !Array.isArray(aggregateArgs.groupByFields)) { 102 | throw new Error('objectName, selectFields array, and groupByFields array are required for aggregate query'); 103 | } 104 | // Type check and conversion 105 | const validatedArgs: AggregateQueryArgs = { 106 | objectName: aggregateArgs.objectName as string, 107 | selectFields: aggregateArgs.selectFields as string[], 108 | groupByFields: aggregateArgs.groupByFields as string[], 109 | whereClause: aggregateArgs.whereClause as string | undefined, 110 | havingClause: aggregateArgs.havingClause as string | undefined, 111 | orderBy: aggregateArgs.orderBy as string | undefined, 112 | limit: aggregateArgs.limit as number | undefined 113 | }; 114 | return await handleAggregateQuery(conn, validatedArgs); 115 | } 116 | 117 | case "salesforce_dml_records": { 118 | const dmlArgs = args as Record; 119 | if (!dmlArgs.operation || !dmlArgs.objectName || !Array.isArray(dmlArgs.records)) { 120 | throw new Error('operation, objectName, and records array are required for DML'); 121 | } 122 | const validatedArgs: DMLArgs = { 123 | operation: dmlArgs.operation as 'insert' | 'update' | 'delete' | 'upsert', 124 | objectName: dmlArgs.objectName as string, 125 | records: dmlArgs.records as Record[], 126 | externalIdField: dmlArgs.externalIdField as string | undefined 127 | }; 128 | return await handleDMLRecords(conn, validatedArgs); 129 | } 130 | 131 | case "salesforce_manage_object": { 132 | const objectArgs = args as Record; 133 | if (!objectArgs.operation || !objectArgs.objectName) { 134 | throw new Error('operation and objectName are required for object management'); 135 | } 136 | const validatedArgs: ManageObjectArgs = { 137 | operation: objectArgs.operation as 'create' | 'update', 138 | objectName: objectArgs.objectName as string, 139 | label: objectArgs.label as string | undefined, 140 | pluralLabel: objectArgs.pluralLabel as string | undefined, 141 | description: objectArgs.description as string | undefined, 142 | nameFieldLabel: objectArgs.nameFieldLabel as string | undefined, 143 | nameFieldType: objectArgs.nameFieldType as 'Text' | 'AutoNumber' | undefined, 144 | nameFieldFormat: objectArgs.nameFieldFormat as string | undefined, 145 | sharingModel: objectArgs.sharingModel as 'ReadWrite' | 'Read' | 'Private' | 'ControlledByParent' | undefined 146 | }; 147 | return await handleManageObject(conn, validatedArgs); 148 | } 149 | 150 | case "salesforce_manage_field": { 151 | const fieldArgs = args as Record; 152 | if (!fieldArgs.operation || !fieldArgs.objectName || !fieldArgs.fieldName) { 153 | throw new Error('operation, objectName, and fieldName are required for field management'); 154 | } 155 | const validatedArgs: ManageFieldArgs = { 156 | operation: fieldArgs.operation as 'create' | 'update', 157 | objectName: fieldArgs.objectName as string, 158 | fieldName: fieldArgs.fieldName as string, 159 | label: fieldArgs.label as string | undefined, 160 | type: fieldArgs.type as string | undefined, 161 | required: fieldArgs.required as boolean | undefined, 162 | unique: fieldArgs.unique as boolean | undefined, 163 | externalId: fieldArgs.externalId as boolean | undefined, 164 | length: fieldArgs.length as number | undefined, 165 | precision: fieldArgs.precision as number | undefined, 166 | scale: fieldArgs.scale as number | undefined, 167 | referenceTo: fieldArgs.referenceTo as string | undefined, 168 | relationshipLabel: fieldArgs.relationshipLabel as string | undefined, 169 | relationshipName: fieldArgs.relationshipName as string | undefined, 170 | deleteConstraint: fieldArgs.deleteConstraint as 'Cascade' | 'Restrict' | 'SetNull' | undefined, 171 | picklistValues: fieldArgs.picklistValues as Array<{ label: string; isDefault?: boolean }> | undefined, 172 | description: fieldArgs.description as string | undefined, 173 | grantAccessTo: fieldArgs.grantAccessTo as string[] | undefined 174 | }; 175 | return await handleManageField(conn, validatedArgs); 176 | } 177 | 178 | case "salesforce_manage_field_permissions": { 179 | const permArgs = args as Record; 180 | if (!permArgs.operation || !permArgs.objectName || !permArgs.fieldName) { 181 | throw new Error('operation, objectName, and fieldName are required for field permissions management'); 182 | } 183 | const validatedArgs: ManageFieldPermissionsArgs = { 184 | operation: permArgs.operation as 'grant' | 'revoke' | 'view', 185 | objectName: permArgs.objectName as string, 186 | fieldName: permArgs.fieldName as string, 187 | profileNames: permArgs.profileNames as string[] | undefined, 188 | readable: permArgs.readable as boolean | undefined, 189 | editable: permArgs.editable as boolean | undefined 190 | }; 191 | return await handleManageFieldPermissions(conn, validatedArgs); 192 | } 193 | 194 | case "salesforce_search_all": { 195 | const searchArgs = args as Record; 196 | if (!searchArgs.searchTerm || !Array.isArray(searchArgs.objects)) { 197 | throw new Error('searchTerm and objects array are required for search'); 198 | } 199 | 200 | // Validate objects array 201 | const objects = searchArgs.objects as Array>; 202 | if (!objects.every(obj => obj.name && Array.isArray(obj.fields))) { 203 | throw new Error('Each object must specify name and fields array'); 204 | } 205 | 206 | // Type check and conversion 207 | const validatedArgs: SearchAllArgs = { 208 | searchTerm: searchArgs.searchTerm as string, 209 | searchIn: searchArgs.searchIn as "ALL FIELDS" | "NAME FIELDS" | "EMAIL FIELDS" | "PHONE FIELDS" | "SIDEBAR FIELDS" | undefined, 210 | objects: objects.map(obj => ({ 211 | name: obj.name as string, 212 | fields: obj.fields as string[], 213 | where: obj.where as string | undefined, 214 | orderBy: obj.orderBy as string | undefined, 215 | limit: obj.limit as number | undefined 216 | })), 217 | withClauses: searchArgs.withClauses as WithClause[] | undefined, 218 | updateable: searchArgs.updateable as boolean | undefined, 219 | viewable: searchArgs.viewable as boolean | undefined 220 | }; 221 | 222 | return await handleSearchAll(conn, validatedArgs); 223 | } 224 | 225 | case "salesforce_read_apex": { 226 | const apexArgs = args as Record; 227 | 228 | // Type check and conversion 229 | const validatedArgs: ReadApexArgs = { 230 | className: apexArgs.className as string | undefined, 231 | namePattern: apexArgs.namePattern as string | undefined, 232 | includeMetadata: apexArgs.includeMetadata as boolean | undefined 233 | }; 234 | 235 | return await handleReadApex(conn, validatedArgs); 236 | } 237 | 238 | case "salesforce_write_apex": { 239 | const apexArgs = args as Record; 240 | if (!apexArgs.operation || !apexArgs.className || !apexArgs.body) { 241 | throw new Error('operation, className, and body are required for writing Apex'); 242 | } 243 | 244 | // Type check and conversion 245 | const validatedArgs: WriteApexArgs = { 246 | operation: apexArgs.operation as 'create' | 'update', 247 | className: apexArgs.className as string, 248 | apiVersion: apexArgs.apiVersion as string | undefined, 249 | body: apexArgs.body as string 250 | }; 251 | 252 | return await handleWriteApex(conn, validatedArgs); 253 | } 254 | 255 | case "salesforce_read_apex_trigger": { 256 | const triggerArgs = args as Record; 257 | 258 | // Type check and conversion 259 | const validatedArgs: ReadApexTriggerArgs = { 260 | triggerName: triggerArgs.triggerName as string | undefined, 261 | namePattern: triggerArgs.namePattern as string | undefined, 262 | includeMetadata: triggerArgs.includeMetadata as boolean | undefined 263 | }; 264 | 265 | return await handleReadApexTrigger(conn, validatedArgs); 266 | } 267 | 268 | case "salesforce_write_apex_trigger": { 269 | const triggerArgs = args as Record; 270 | if (!triggerArgs.operation || !triggerArgs.triggerName || !triggerArgs.body) { 271 | throw new Error('operation, triggerName, and body are required for writing Apex trigger'); 272 | } 273 | 274 | // Type check and conversion 275 | const validatedArgs: WriteApexTriggerArgs = { 276 | operation: triggerArgs.operation as 'create' | 'update', 277 | triggerName: triggerArgs.triggerName as string, 278 | objectName: triggerArgs.objectName as string | undefined, 279 | apiVersion: triggerArgs.apiVersion as string | undefined, 280 | body: triggerArgs.body as string 281 | }; 282 | 283 | return await handleWriteApexTrigger(conn, validatedArgs); 284 | } 285 | 286 | case "salesforce_execute_anonymous": { 287 | const executeArgs = args as Record; 288 | if (!executeArgs.apexCode) { 289 | throw new Error('apexCode is required for executing anonymous Apex'); 290 | } 291 | 292 | // Type check and conversion 293 | const validatedArgs: ExecuteAnonymousArgs = { 294 | apexCode: executeArgs.apexCode as string, 295 | logLevel: executeArgs.logLevel as 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'FINE' | 'FINER' | 'FINEST' | undefined 296 | }; 297 | 298 | return await handleExecuteAnonymous(conn, validatedArgs); 299 | } 300 | 301 | case "salesforce_manage_debug_logs": { 302 | const debugLogsArgs = args as Record; 303 | if (!debugLogsArgs.operation || !debugLogsArgs.username) { 304 | throw new Error('operation and username are required for managing debug logs'); 305 | } 306 | 307 | // Type check and conversion 308 | const validatedArgs: ManageDebugLogsArgs = { 309 | operation: debugLogsArgs.operation as 'enable' | 'disable' | 'retrieve', 310 | username: debugLogsArgs.username as string, 311 | logLevel: debugLogsArgs.logLevel as 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'FINE' | 'FINER' | 'FINEST' | undefined, 312 | expirationTime: debugLogsArgs.expirationTime as number | undefined, 313 | limit: debugLogsArgs.limit as number | undefined, 314 | logId: debugLogsArgs.logId as string | undefined, 315 | includeBody: debugLogsArgs.includeBody as boolean | undefined 316 | }; 317 | 318 | return await handleManageDebugLogs(conn, validatedArgs); 319 | } 320 | 321 | default: 322 | return { 323 | content: [{ type: "text", text: `Unknown tool: ${name}` }], 324 | isError: true, 325 | }; 326 | } 327 | } catch (error) { 328 | return { 329 | content: [{ 330 | type: "text", 331 | text: `Error: ${error instanceof Error ? error.message : String(error)}`, 332 | }], 333 | isError: true, 334 | }; 335 | } 336 | }); 337 | 338 | async function runServer() { 339 | const transport = new StdioServerTransport(); 340 | await server.connect(transport); 341 | console.error("Salesforce MCP Server running on stdio"); 342 | } 343 | 344 | runServer().catch((error) => { 345 | console.error("Fatal error running server:", error); 346 | process.exit(1); 347 | }); -------------------------------------------------------------------------------- /src/tools/aggregateQuery.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | export const AGGREGATE_QUERY: Tool = { 4 | name: "salesforce_aggregate_query", 5 | 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. 6 | 7 | NOTE: For regular queries without GROUP BY or aggregates, use salesforce_query_records instead. 8 | 9 | This tool handles: 10 | 1. GROUP BY queries (single/multiple fields, related objects, date functions) 11 | 2. Aggregate functions: COUNT(), COUNT_DISTINCT(), SUM(), AVG(), MIN(), MAX() 12 | 3. HAVING clauses for filtering grouped results 13 | 4. Date/time grouping: CALENDAR_YEAR(), CALENDAR_MONTH(), CALENDAR_QUARTER(), FISCAL_YEAR(), FISCAL_QUARTER() 14 | 15 | Examples: 16 | 1. Count opportunities by stage: 17 | - objectName: "Opportunity" 18 | - selectFields: ["StageName", "COUNT(Id) OpportunityCount"] 19 | - groupByFields: ["StageName"] 20 | 21 | 2. Analyze cases by priority and status: 22 | - objectName: "Case" 23 | - selectFields: ["Priority", "Status", "COUNT(Id) CaseCount", "AVG(Days_Open__c) AvgDaysOpen"] 24 | - groupByFields: ["Priority", "Status"] 25 | 26 | 3. Count contacts by account industry: 27 | - objectName: "Contact" 28 | - selectFields: ["Account.Industry", "COUNT(Id) ContactCount"] 29 | - groupByFields: ["Account.Industry"] 30 | 31 | 4. Quarterly opportunity analysis: 32 | - objectName: "Opportunity" 33 | - selectFields: ["CALENDAR_YEAR(CloseDate) Year", "CALENDAR_QUARTER(CloseDate) Quarter", "SUM(Amount) Revenue"] 34 | - groupByFields: ["CALENDAR_YEAR(CloseDate)", "CALENDAR_QUARTER(CloseDate)"] 35 | 36 | 5. Find accounts with more than 10 opportunities: 37 | - objectName: "Opportunity" 38 | - selectFields: ["Account.Name", "COUNT(Id) OpportunityCount"] 39 | - groupByFields: ["Account.Name"] 40 | - havingClause: "COUNT(Id) > 10" 41 | 42 | Important Rules: 43 | - All non-aggregate fields in selectFields MUST be included in groupByFields 44 | - Use whereClause to filter rows BEFORE grouping 45 | - Use havingClause to filter AFTER grouping (for aggregate conditions) 46 | - ORDER BY can only use fields from groupByFields or aggregate functions 47 | - OFFSET is not supported with GROUP BY in Salesforce`, 48 | inputSchema: { 49 | type: "object", 50 | properties: { 51 | objectName: { 52 | type: "string", 53 | description: "API name of the object to query" 54 | }, 55 | selectFields: { 56 | type: "array", 57 | items: { type: "string" }, 58 | description: "Fields to select - mix of group fields and aggregates. Format: 'FieldName' or 'COUNT(Id) AliasName'" 59 | }, 60 | groupByFields: { 61 | type: "array", 62 | items: { type: "string" }, 63 | description: "Fields to group by - must include all non-aggregate fields from selectFields" 64 | }, 65 | whereClause: { 66 | type: "string", 67 | description: "WHERE clause to filter rows BEFORE grouping (cannot contain aggregate functions)", 68 | optional: true 69 | }, 70 | havingClause: { 71 | type: "string", 72 | description: "HAVING clause to filter results AFTER grouping (use for aggregate conditions)", 73 | optional: true 74 | }, 75 | orderBy: { 76 | type: "string", 77 | description: "ORDER BY clause - can only use grouped fields or aggregate functions", 78 | optional: true 79 | }, 80 | limit: { 81 | type: "number", 82 | description: "Maximum number of grouped results to return", 83 | optional: true 84 | } 85 | }, 86 | required: ["objectName", "selectFields", "groupByFields"] 87 | } 88 | }; 89 | 90 | export interface AggregateQueryArgs { 91 | objectName: string; 92 | selectFields: string[]; 93 | groupByFields: string[]; 94 | whereClause?: string; 95 | havingClause?: string; 96 | orderBy?: string; 97 | limit?: number; 98 | } 99 | 100 | // Aggregate functions that don't need to be in GROUP BY 101 | const AGGREGATE_FUNCTIONS = ['COUNT', 'COUNT_DISTINCT', 'SUM', 'AVG', 'MIN', 'MAX']; 102 | const DATE_FUNCTIONS = ['CALENDAR_YEAR', 'CALENDAR_MONTH', 'CALENDAR_QUARTER', 'FISCAL_YEAR', 'FISCAL_QUARTER']; 103 | 104 | // Helper function to detect if a field contains an aggregate function 105 | function isAggregateField(field: string): boolean { 106 | const upperField = field.toUpperCase(); 107 | return AGGREGATE_FUNCTIONS.some(func => upperField.includes(`${func}(`)); 108 | } 109 | 110 | // Helper function to extract the base field from a select field (removing alias) 111 | function extractBaseField(field: string): string { 112 | // Remove alias if present (e.g., "COUNT(Id) OpportunityCount" -> "COUNT(Id)") 113 | const parts = field.trim().split(/\s+/); 114 | return parts[0]; 115 | } 116 | 117 | // Helper function to extract non-aggregate fields from select fields 118 | function extractNonAggregateFields(selectFields: string[]): string[] { 119 | return selectFields 120 | .filter(field => !isAggregateField(field)) 121 | .map(field => extractBaseField(field)); 122 | } 123 | 124 | // Helper function to validate that all non-aggregate fields are in GROUP BY 125 | function validateGroupByFields(selectFields: string[], groupByFields: string[]): { isValid: boolean; missingFields?: string[] } { 126 | const nonAggregateFields = extractNonAggregateFields(selectFields); 127 | const groupBySet = new Set(groupByFields.map(f => f.trim())); 128 | 129 | const missingFields = nonAggregateFields.filter(field => !groupBySet.has(field)); 130 | 131 | return { 132 | isValid: missingFields.length === 0, 133 | missingFields 134 | }; 135 | } 136 | 137 | // Helper function to validate WHERE clause doesn't contain aggregates 138 | function validateWhereClause(whereClause: string | undefined): { isValid: boolean; error?: string } { 139 | if (!whereClause) return { isValid: true }; 140 | 141 | const upperWhere = whereClause.toUpperCase(); 142 | for (const func of AGGREGATE_FUNCTIONS) { 143 | if (upperWhere.includes(`${func}(`)) { 144 | return { 145 | isValid: false, 146 | error: `WHERE clause cannot contain aggregate functions. Use HAVING clause instead for aggregate conditions like ${func}()` 147 | }; 148 | } 149 | } 150 | 151 | return { isValid: true }; 152 | } 153 | 154 | // Helper function to validate ORDER BY fields 155 | function validateOrderBy(orderBy: string | undefined, groupByFields: string[], selectFields: string[]): { isValid: boolean; error?: string } { 156 | if (!orderBy) return { isValid: true }; 157 | 158 | // Extract fields from ORDER BY (handling DESC/ASC) 159 | const orderByParts = orderBy.split(',').map(part => { 160 | return part.trim().replace(/ (DESC|ASC)$/i, '').trim(); 161 | }); 162 | 163 | const groupBySet = new Set(groupByFields); 164 | const aggregateFields = selectFields.filter(field => isAggregateField(field)).map(field => extractBaseField(field)); 165 | 166 | for (const orderField of orderByParts) { 167 | // Check if it's in GROUP BY or is an aggregate 168 | if (!groupBySet.has(orderField) && !aggregateFields.some(agg => agg === orderField) && !isAggregateField(orderField)) { 169 | return { 170 | isValid: false, 171 | error: `ORDER BY field '${orderField}' must be in GROUP BY clause or be an aggregate function` 172 | }; 173 | } 174 | } 175 | 176 | return { isValid: true }; 177 | } 178 | 179 | export async function handleAggregateQuery(conn: any, args: AggregateQueryArgs) { 180 | const { objectName, selectFields, groupByFields, whereClause, havingClause, orderBy, limit } = args; 181 | 182 | try { 183 | // Validate GROUP BY contains all non-aggregate fields 184 | const groupByValidation = validateGroupByFields(selectFields, groupByFields); 185 | if (!groupByValidation.isValid) { 186 | return { 187 | content: [{ 188 | type: "text", 189 | text: `Error: The following non-aggregate fields must be included in GROUP BY clause: ${groupByValidation.missingFields!.join(', ')}\n\n` + 190 | `All fields in SELECT that are not aggregate functions (COUNT, SUM, AVG, etc.) must be included in GROUP BY.` 191 | }], 192 | isError: true, 193 | }; 194 | } 195 | 196 | // Validate WHERE clause doesn't contain aggregates 197 | const whereValidation = validateWhereClause(whereClause); 198 | if (!whereValidation.isValid) { 199 | return { 200 | content: [{ 201 | type: "text", 202 | text: whereValidation.error! 203 | }], 204 | isError: true, 205 | }; 206 | } 207 | 208 | // Validate ORDER BY fields 209 | const orderByValidation = validateOrderBy(orderBy, groupByFields, selectFields); 210 | if (!orderByValidation.isValid) { 211 | return { 212 | content: [{ 213 | type: "text", 214 | text: orderByValidation.error! 215 | }], 216 | isError: true, 217 | }; 218 | } 219 | 220 | // Construct SOQL query 221 | let soql = `SELECT ${selectFields.join(', ')} FROM ${objectName}`; 222 | if (whereClause) soql += ` WHERE ${whereClause}`; 223 | soql += ` GROUP BY ${groupByFields.join(', ')}`; 224 | if (havingClause) soql += ` HAVING ${havingClause}`; 225 | if (orderBy) soql += ` ORDER BY ${orderBy}`; 226 | if (limit) soql += ` LIMIT ${limit}`; 227 | 228 | const result = await conn.query(soql); 229 | 230 | // Format the output 231 | const formattedRecords = result.records.map((record: any, index: number) => { 232 | const recordStr = selectFields.map(field => { 233 | const baseField = extractBaseField(field); 234 | const fieldParts = field.trim().split(/\s+/); 235 | const displayName = fieldParts.length > 1 ? fieldParts[fieldParts.length - 1] : baseField; 236 | 237 | // Handle nested fields in results 238 | if (baseField.includes('.')) { 239 | const parts = baseField.split('.'); 240 | let value = record; 241 | for (const part of parts) { 242 | value = value?.[part]; 243 | } 244 | return ` ${displayName}: ${value !== null && value !== undefined ? value : 'null'}`; 245 | } 246 | 247 | const value = record[baseField] || record[displayName]; 248 | return ` ${displayName}: ${value !== null && value !== undefined ? value : 'null'}`; 249 | }).join('\n'); 250 | return `Group ${index + 1}:\n${recordStr}`; 251 | }).join('\n\n'); 252 | 253 | return { 254 | content: [{ 255 | type: "text", 256 | text: `Aggregate query returned ${result.records.length} grouped results:\n\n${formattedRecords}` 257 | }], 258 | isError: false, 259 | }; 260 | } catch (error) { 261 | const errorMessage = error instanceof Error ? error.message : String(error); 262 | 263 | // Provide more helpful error messages for common issues 264 | let enhancedError = errorMessage; 265 | if (errorMessage.includes('MALFORMED_QUERY')) { 266 | if (errorMessage.includes('GROUP BY')) { 267 | enhancedError = `Query error: ${errorMessage}\n\nCommon issues:\n` + 268 | `1. Ensure all non-aggregate fields in SELECT are in GROUP BY\n` + 269 | `2. Check that date functions match exactly between SELECT and GROUP BY\n` + 270 | `3. Verify field names and relationships are correct`; 271 | } 272 | } 273 | 274 | return { 275 | content: [{ 276 | type: "text", 277 | text: `Error executing aggregate query: ${enhancedError}` 278 | }], 279 | isError: true, 280 | }; 281 | } 282 | } -------------------------------------------------------------------------------- /src/tools/describe.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { SalesforceField, SalesforceDescribeResponse } from "../types/salesforce"; 3 | 4 | export const DESCRIBE_OBJECT: Tool = { 5 | name: "salesforce_describe_object", 6 | 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.", 7 | inputSchema: { 8 | type: "object", 9 | properties: { 10 | objectName: { 11 | type: "string", 12 | description: "API name of the object (e.g., 'Account', 'Contact', 'Custom_Object__c')" 13 | } 14 | }, 15 | required: ["objectName"] 16 | } 17 | }; 18 | 19 | export async function handleDescribeObject(conn: any, objectName: string) { 20 | const describe = await conn.describe(objectName) as SalesforceDescribeResponse; 21 | 22 | // Format the output 23 | const formattedDescription = ` 24 | Object: ${describe.name} (${describe.label})${describe.custom ? ' (Custom Object)' : ''} 25 | Fields: 26 | ${describe.fields.map((field: SalesforceField) => ` - ${field.name} (${field.label}) 27 | Type: ${field.type}${field.length ? `, Length: ${field.length}` : ''} 28 | Required: ${!field.nillable} 29 | ${field.referenceTo && field.referenceTo.length > 0 ? `References: ${field.referenceTo.join(', ')}` : ''} 30 | ${field.picklistValues && field.picklistValues.length > 0 ? `Picklist Values: ${field.picklistValues.map((v: { value: string }) => v.value).join(', ')}` : ''}` 31 | ).join('\n')}`; 32 | 33 | return { 34 | content: [{ 35 | type: "text", 36 | text: formattedDescription 37 | }], 38 | isError: false, 39 | }; 40 | } -------------------------------------------------------------------------------- /src/tools/dml.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { DMLResult } from "../types/salesforce"; 3 | 4 | export const DML_RECORDS: Tool = { 5 | name: "salesforce_dml_records", 6 | description: `Perform data manipulation operations on Salesforce records: 7 | - insert: Create new records 8 | - update: Modify existing records (requires Id) 9 | - delete: Remove records (requires Id) 10 | - upsert: Insert or update based on external ID field 11 | Examples: Insert new Accounts, Update Case status, Delete old records, Upsert based on custom external ID`, 12 | inputSchema: { 13 | type: "object", 14 | properties: { 15 | operation: { 16 | type: "string", 17 | enum: ["insert", "update", "delete", "upsert"], 18 | description: "Type of DML operation to perform" 19 | }, 20 | objectName: { 21 | type: "string", 22 | description: "API name of the object" 23 | }, 24 | records: { 25 | type: "array", 26 | items: { type: "object" }, 27 | description: "Array of records to process" 28 | }, 29 | externalIdField: { 30 | type: "string", 31 | description: "External ID field name for upsert operations", 32 | optional: true 33 | } 34 | }, 35 | required: ["operation", "objectName", "records"] 36 | } 37 | }; 38 | 39 | export interface DMLArgs { 40 | operation: 'insert' | 'update' | 'delete' | 'upsert'; 41 | objectName: string; 42 | records: Record[]; 43 | externalIdField?: string; 44 | } 45 | 46 | export async function handleDMLRecords(conn: any, args: DMLArgs) { 47 | const { operation, objectName, records, externalIdField } = args; 48 | 49 | let result: DMLResult | DMLResult[]; 50 | 51 | switch (operation) { 52 | case 'insert': 53 | result = await conn.sobject(objectName).create(records); 54 | break; 55 | case 'update': 56 | result = await conn.sobject(objectName).update(records); 57 | break; 58 | case 'delete': 59 | result = await conn.sobject(objectName).destroy(records.map(r => r.Id)); 60 | break; 61 | case 'upsert': 62 | if (!externalIdField) { 63 | throw new Error('externalIdField is required for upsert operations'); 64 | } 65 | result = await conn.sobject(objectName).upsert(records, externalIdField); 66 | break; 67 | default: 68 | throw new Error(`Unsupported operation: ${operation}`); 69 | } 70 | 71 | // Format DML results 72 | const results = Array.isArray(result) ? result : [result]; 73 | const successCount = results.filter(r => r.success).length; 74 | const failureCount = results.length - successCount; 75 | 76 | let responseText = `${operation.toUpperCase()} operation completed.\n`; 77 | responseText += `Processed ${results.length} records:\n`; 78 | responseText += `- Successful: ${successCount}\n`; 79 | responseText += `- Failed: ${failureCount}\n\n`; 80 | 81 | if (failureCount > 0) { 82 | responseText += 'Errors:\n'; 83 | results.forEach((r: DMLResult, idx: number) => { 84 | if (!r.success && r.errors) { 85 | responseText += `Record ${idx + 1}:\n`; 86 | if (Array.isArray(r.errors)) { 87 | r.errors.forEach((error) => { 88 | responseText += ` - ${error.message}`; 89 | if (error.statusCode) { 90 | responseText += ` [${error.statusCode}]`; 91 | } 92 | if (error.fields && error.fields.length > 0) { 93 | responseText += `\n Fields: ${error.fields.join(', ')}`; 94 | } 95 | responseText += '\n'; 96 | }); 97 | } else { 98 | // Single error object 99 | const error = r.errors; 100 | responseText += ` - ${error.message}`; 101 | if (error.statusCode) { 102 | responseText += ` [${error.statusCode}]`; 103 | } 104 | if (error.fields) { 105 | const fields = Array.isArray(error.fields) ? error.fields.join(', ') : error.fields; 106 | responseText += `\n Fields: ${fields}`; 107 | } 108 | responseText += '\n'; 109 | } 110 | } 111 | }); 112 | } 113 | 114 | return { 115 | content: [{ 116 | type: "text", 117 | text: responseText 118 | }], 119 | isError: false, 120 | }; 121 | } -------------------------------------------------------------------------------- /src/tools/executeAnonymous.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import type { Connection } from "jsforce"; 3 | 4 | export const EXECUTE_ANONYMOUS: Tool = { 5 | name: "salesforce_execute_anonymous", 6 | description: `Execute anonymous Apex code in Salesforce. 7 | 8 | Examples: 9 | 1. Execute simple Apex code: 10 | { 11 | "apexCode": "System.debug('Hello World');" 12 | } 13 | 14 | 2. Execute Apex code with variables: 15 | { 16 | "apexCode": "List accounts = [SELECT Id, Name FROM Account LIMIT 5]; for(Account a : accounts) { System.debug(a.Name); }" 17 | } 18 | 19 | 3. Execute Apex with debug logs: 20 | { 21 | "apexCode": "System.debug(LoggingLevel.INFO, 'Processing accounts...'); List accounts = [SELECT Id FROM Account LIMIT 10]; System.debug(LoggingLevel.INFO, 'Found ' + accounts.size() + ' accounts');", 22 | "logLevel": "DEBUG" 23 | } 24 | 25 | Notes: 26 | - The apexCode parameter is required and must contain valid Apex code 27 | - The code is executed in an anonymous context and does not persist 28 | - The logLevel parameter is optional (defaults to 'DEBUG') 29 | - Execution results include compilation success/failure, execution success/failure, and debug logs 30 | - For security reasons, some operations may be restricted based on user permissions 31 | - This tool can be used for data operations or updates when there are no other specific tools available 32 | - 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 33 | `, 34 | inputSchema: { 35 | type: "object", 36 | properties: { 37 | apexCode: { 38 | type: "string", 39 | description: "Apex code to execute anonymously" 40 | }, 41 | logLevel: { 42 | type: "string", 43 | enum: ["NONE", "ERROR", "WARN", "INFO", "DEBUG", "FINE", "FINER", "FINEST"], 44 | description: "Log level for debug logs (optional, defaults to DEBUG)" 45 | } 46 | }, 47 | required: ["apexCode"] 48 | } 49 | }; 50 | 51 | export interface ExecuteAnonymousArgs { 52 | apexCode: string; 53 | logLevel?: 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'FINE' | 'FINER' | 'FINEST'; 54 | } 55 | 56 | /** 57 | * Handles executing anonymous Apex code in Salesforce 58 | * @param conn Active Salesforce connection 59 | * @param args Arguments for executing anonymous Apex 60 | * @returns Tool response with execution results and debug logs 61 | */ 62 | export async function handleExecuteAnonymous(conn: any, args: ExecuteAnonymousArgs) { 63 | try { 64 | // Validate inputs 65 | if (!args.apexCode || args.apexCode.trim() === '') { 66 | throw new Error('apexCode is required and cannot be empty'); 67 | } 68 | 69 | console.error(`Executing anonymous Apex code`); 70 | 71 | // Set default log level if not provided 72 | const logLevel = args.logLevel || 'DEBUG'; 73 | 74 | // Execute the anonymous Apex code 75 | const result = await conn.tooling.executeAnonymous(args.apexCode); 76 | 77 | // Format the response 78 | let responseText = ''; 79 | 80 | // Add compilation and execution status 81 | if (result.compiled) { 82 | responseText += `**Compilation:** Success\n`; 83 | } else { 84 | responseText += `**Compilation:** Failed\n`; 85 | responseText += `**Line:** ${result.line}\n`; 86 | responseText += `**Column:** ${result.column}\n`; 87 | responseText += `**Error:** ${result.compileProblem}\n\n`; 88 | } 89 | 90 | if (result.compiled && result.success) { 91 | responseText += `**Execution:** Success\n`; 92 | } else if (result.compiled) { 93 | responseText += `**Execution:** Failed\n`; 94 | responseText += `**Error:** ${result.exceptionMessage}\n`; 95 | if (result.exceptionStackTrace) { 96 | responseText += `**Stack Trace:**\n\`\`\`\n${result.exceptionStackTrace}\n\`\`\`\n\n`; 97 | } 98 | } 99 | 100 | // Get debug logs if available 101 | if (result.compiled) { 102 | try { 103 | // Query for the most recent debug log 104 | const logs = await conn.query(` 105 | SELECT Id, LogUserId, Operation, Application, Status, LogLength, LastModifiedDate, Request 106 | FROM ApexLog 107 | ORDER BY LastModifiedDate DESC 108 | LIMIT 1 109 | `); 110 | 111 | if (logs.records.length > 0) { 112 | const logId = logs.records[0].Id; 113 | 114 | // Retrieve the log body 115 | const logBody = await conn.tooling.request({ 116 | method: 'GET', 117 | url: `${conn.instanceUrl}/services/data/v58.0/tooling/sobjects/ApexLog/${logId}/Body` 118 | }); 119 | 120 | responseText += `\n**Debug Log:**\n\`\`\`\n${logBody}\n\`\`\``; 121 | } else { 122 | responseText += `\n**Debug Log:** No logs available. Ensure debug logs are enabled for your user.`; 123 | } 124 | } catch (logError) { 125 | responseText += `\n**Debug Log:** Unable to retrieve debug logs: ${logError instanceof Error ? logError.message : String(logError)}`; 126 | } 127 | } 128 | 129 | return { 130 | content: [{ 131 | type: "text", 132 | text: responseText 133 | }] 134 | }; 135 | } catch (error) { 136 | console.error('Error executing anonymous Apex:', error); 137 | return { 138 | content: [{ 139 | type: "text", 140 | text: `Error executing anonymous Apex: ${error instanceof Error ? error.message : String(error)}` 141 | }], 142 | isError: true, 143 | }; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/tools/manageDebugLogs.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import type { Connection } from "jsforce"; 3 | 4 | export const MANAGE_DEBUG_LOGS: Tool = { 5 | name: "salesforce_manage_debug_logs", 6 | description: `Manage debug logs for Salesforce users - enable, disable, or retrieve logs. 7 | 8 | Examples: 9 | 1. Enable debug logs for a user: 10 | { 11 | "operation": "enable", 12 | "username": "user@example.com", 13 | "logLevel": "DEBUG", 14 | "expirationTime": 30 15 | } 16 | 17 | 2. Disable debug logs for a user: 18 | { 19 | "operation": "disable", 20 | "username": "user@example.com" 21 | } 22 | 23 | 3. Retrieve debug logs for a user: 24 | { 25 | "operation": "retrieve", 26 | "username": "user@example.com", 27 | "limit": 5 28 | } 29 | 30 | 4. Retrieve a specific log with full content: 31 | { 32 | "operation": "retrieve", 33 | "username": "user@example.com", 34 | "logId": "07L1g000000XXXXEAA0", 35 | "includeBody": true 36 | } 37 | 38 | Notes: 39 | - The operation must be one of: 'enable', 'disable', or 'retrieve' 40 | - The username parameter is required for all operations 41 | - For 'enable' operation, logLevel is optional (defaults to 'DEBUG') 42 | - Log levels: NONE, ERROR, WARN, INFO, DEBUG, FINE, FINER, FINEST 43 | - expirationTime is optional for 'enable' operation (minutes until expiration, defaults to 30) 44 | - limit is optional for 'retrieve' operation (maximum number of logs to return, defaults to 10) 45 | - logId is optional for 'retrieve' operation (to get a specific log) 46 | - includeBody is optional for 'retrieve' operation (to include the full log content, defaults to false) 47 | - The tool validates that the specified user exists before performing operations 48 | - If logLevel is not specified when enabling logs, the tool will ask for clarification`, 49 | inputSchema: { 50 | type: "object", 51 | properties: { 52 | operation: { 53 | type: "string", 54 | enum: ["enable", "disable", "retrieve"], 55 | description: "Operation to perform on debug logs" 56 | }, 57 | username: { 58 | type: "string", 59 | description: "Username of the Salesforce user" 60 | }, 61 | logLevel: { 62 | type: "string", 63 | enum: ["NONE", "ERROR", "WARN", "INFO", "DEBUG", "FINE", "FINER", "FINEST"], 64 | description: "Log level for debug logs (required for 'enable' operation)" 65 | }, 66 | expirationTime: { 67 | type: "number", 68 | description: "Minutes until the debug log configuration expires (optional, defaults to 30)" 69 | }, 70 | limit: { 71 | type: "number", 72 | description: "Maximum number of logs to retrieve (optional, defaults to 10)" 73 | }, 74 | logId: { 75 | type: "string", 76 | description: "ID of a specific log to retrieve (optional)" 77 | }, 78 | includeBody: { 79 | type: "boolean", 80 | description: "Whether to include the full log content (optional, defaults to false)" 81 | } 82 | }, 83 | required: ["operation", "username"] 84 | } 85 | }; 86 | 87 | export interface ManageDebugLogsArgs { 88 | operation: 'enable' | 'disable' | 'retrieve'; 89 | username: string; 90 | logLevel?: 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'FINE' | 'FINER' | 'FINEST'; 91 | expirationTime?: number; 92 | limit?: number; 93 | logId?: string; 94 | includeBody?: boolean; 95 | } 96 | 97 | /** 98 | * Handles managing debug logs for Salesforce users 99 | * @param conn Active Salesforce connection 100 | * @param args Arguments for managing debug logs 101 | * @returns Tool response with operation results 102 | */ 103 | export async function handleManageDebugLogs(conn: any, args: ManageDebugLogsArgs) { 104 | try { 105 | // Validate inputs 106 | if (!args.username) { 107 | throw new Error('username is required'); 108 | } 109 | 110 | // Determine if the input is likely a username or a full name 111 | const isLikelyUsername = args.username.includes('@') || !args.username.includes(' '); 112 | 113 | // Build the query based on whether the input looks like a username or a full name 114 | let userQuery; 115 | if (isLikelyUsername) { 116 | // Query by username 117 | userQuery = await conn.query(` 118 | SELECT Id, Username, Name, IsActive 119 | FROM User 120 | WHERE Username = '${args.username}' 121 | `); 122 | } else { 123 | // Query by full name 124 | userQuery = await conn.query(` 125 | SELECT Id, Username, Name, IsActive 126 | FROM User 127 | WHERE Name LIKE '%${args.username}%' 128 | ORDER BY LastModifiedDate DESC 129 | LIMIT 5 130 | `); 131 | } 132 | 133 | if (userQuery.records.length === 0) { 134 | // If no results with the initial query, try a more flexible search 135 | userQuery = await conn.query(` 136 | SELECT Id, Username, Name, IsActive 137 | FROM User 138 | WHERE Name LIKE '%${args.username}%' 139 | OR Username LIKE '%${args.username}%' 140 | ORDER BY LastModifiedDate DESC 141 | LIMIT 5 142 | `); 143 | 144 | if (userQuery.records.length === 0) { 145 | return { 146 | content: [{ 147 | type: "text", 148 | text: `Error: No user found matching '${args.username}'. Please verify the username or full name and try again.` 149 | }], 150 | isError: true, 151 | }; 152 | } 153 | 154 | // If multiple users found, ask for clarification 155 | if (userQuery.records.length > 1) { 156 | let responseText = `Multiple users found matching '${args.username}'. Please specify which user by providing the exact username:\n\n`; 157 | 158 | userQuery.records.forEach((user: any) => { 159 | responseText += `- **${user.Name}** (${user.Username})\n`; 160 | }); 161 | 162 | return { 163 | content: [{ 164 | type: "text", 165 | text: responseText 166 | }] 167 | }; 168 | } 169 | } 170 | 171 | const user = userQuery.records[0]; 172 | 173 | if (!user.IsActive) { 174 | return { 175 | content: [{ 176 | type: "text", 177 | text: `Warning: User '${args.username}' exists but is inactive. Debug logs may not be generated for inactive users.` 178 | }] 179 | }; 180 | } 181 | 182 | // Handle operations 183 | switch (args.operation) { 184 | case 'enable': { 185 | // If logLevel is not provided, we need to ask for it 186 | if (!args.logLevel) { 187 | return { 188 | content: [{ 189 | type: "text", 190 | text: `Please specify a log level for enabling debug logs. Valid options are: NONE, ERROR, WARN, INFO, DEBUG, FINE, FINER, FINEST.` 191 | }], 192 | isError: true, 193 | }; 194 | } 195 | 196 | // Set default expiration time if not provided 197 | const expirationTime = args.expirationTime || 30; 198 | 199 | // Check if a trace flag already exists for this user 200 | const existingTraceFlag = await conn.tooling.query(` 201 | SELECT Id, DebugLevelId FROM TraceFlag 202 | WHERE TracedEntityId = '${user.Id}' 203 | AND ExpirationDate > ${new Date().toISOString()} 204 | `); 205 | 206 | let traceFlagId; 207 | let debugLevelId; 208 | let operation; 209 | 210 | // Calculate expiration date 211 | const expirationDate = new Date(); 212 | expirationDate.setMinutes(expirationDate.getMinutes() + expirationTime); 213 | 214 | if (existingTraceFlag.records.length > 0) { 215 | // Update existing trace flag 216 | traceFlagId = existingTraceFlag.records[0].Id; 217 | debugLevelId = existingTraceFlag.records[0].DebugLevelId; 218 | 219 | await conn.tooling.sobject('TraceFlag').update({ 220 | Id: traceFlagId, 221 | LogType: 'USER_DEBUG', 222 | StartDate: new Date().toISOString(), 223 | ExpirationDate: expirationDate.toISOString() 224 | }); 225 | operation = 'updated'; 226 | } else { 227 | // Create a new debug level with the correct field names 228 | const debugLevelResult = await conn.tooling.sobject('DebugLevel').create({ 229 | DeveloperName: `UserDebug_${Date.now()}`, 230 | MasterLabel: `User Debug ${user.Username}`, 231 | ApexCode: args.logLevel, 232 | ApexProfiling: args.logLevel, 233 | Callout: args.logLevel, 234 | Database: args.logLevel, 235 | System: args.logLevel, 236 | Validation: args.logLevel, 237 | Visualforce: args.logLevel, 238 | Workflow: args.logLevel 239 | }); 240 | 241 | debugLevelId = debugLevelResult.id; 242 | 243 | // Create a new trace flag 244 | const traceFlagResult = await conn.tooling.sobject('TraceFlag').create({ 245 | TracedEntityId: user.Id, 246 | DebugLevelId: debugLevelId, 247 | LogType: 'USER_DEBUG', 248 | StartDate: new Date().toISOString(), 249 | ExpirationDate: expirationDate.toISOString() 250 | }); 251 | 252 | traceFlagId = traceFlagResult.id; 253 | operation = 'enabled'; 254 | } 255 | 256 | return { 257 | content: [{ 258 | type: "text", 259 | text: `Successfully ${operation} debug logs for user '${args.username}'.\n\n` + 260 | `**Log Level:** ${args.logLevel}\n` + 261 | `**Expiration:** ${expirationDate.toLocaleString()} (${expirationTime} minutes from now)\n` + 262 | `**Trace Flag ID:** ${traceFlagId}` 263 | }] 264 | }; 265 | } 266 | 267 | case 'disable': { 268 | // Find all active trace flags for this user 269 | const traceFlags = await conn.tooling.query(` 270 | SELECT Id FROM TraceFlag WHERE TracedEntityId = '${user.Id}' AND ExpirationDate > ${new Date().toISOString()} 271 | `); 272 | 273 | if (traceFlags.records.length === 0) { 274 | return { 275 | content: [{ 276 | type: "text", 277 | text: `No active debug logs found for user '${args.username}'.` 278 | }] 279 | }; 280 | } 281 | 282 | try { 283 | // Delete trace flags instead of updating expiration date 284 | const traceFlagIds = traceFlags.records.map((tf: any) => tf.Id); 285 | const deleteResults = await Promise.all( 286 | traceFlagIds.map((id: string) => 287 | conn.tooling.sobject('TraceFlag').delete(id) 288 | ) 289 | ); 290 | 291 | return { 292 | content: [{ 293 | type: "text", 294 | text: `Successfully disabled ${traceFlagIds.length} debug log configuration(s) for user '${args.username}' by removing them.` 295 | }] 296 | }; 297 | } catch (deleteError) { 298 | console.error('Error deleting trace flags:', deleteError); 299 | 300 | // Fallback to setting a future expiration date if delete fails 301 | try { 302 | // Set expiration date to 5 minutes in the future to satisfy Salesforce's requirement 303 | const nearFutureExpiration = new Date(); 304 | nearFutureExpiration.setMinutes(nearFutureExpiration.getMinutes() + 5); 305 | 306 | const traceFlagIds = traceFlags.records.map((tf: any) => tf.Id); 307 | const updateResults = await Promise.all( 308 | traceFlagIds.map((id: string) => 309 | conn.tooling.sobject('TraceFlag').update({ 310 | Id: id, 311 | ExpirationDate: nearFutureExpiration.toISOString() 312 | }) 313 | ) 314 | ); 315 | 316 | return { 317 | content: [{ 318 | type: "text", 319 | text: `Successfully disabled ${traceFlagIds.length} debug log configuration(s) for user '${args.username}'. They will expire in 5 minutes.` 320 | }] 321 | }; 322 | } catch (updateError) { 323 | console.error('Error updating trace flags:', updateError); 324 | throw new Error(`Could not disable debug logs: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`); 325 | } 326 | } 327 | } 328 | 329 | case 'retrieve': { 330 | // Set default limit if not provided 331 | const limit = args.limit || 10; 332 | 333 | // If a specific log ID is provided, retrieve that log directly 334 | if (args.logId) { 335 | try { 336 | // First check if the log exists 337 | const logQuery = await conn.tooling.query(` 338 | SELECT Id, LogUserId, Operation, Application, Status, LogLength, LastModifiedDate, Request 339 | FROM ApexLog 340 | WHERE Id = '${args.logId}' 341 | `); 342 | 343 | if (logQuery.records.length === 0) { 344 | return { 345 | content: [{ 346 | type: "text", 347 | text: `No log found with ID '${args.logId}'.` 348 | }] 349 | }; 350 | } 351 | 352 | const log = logQuery.records[0]; 353 | 354 | // If includeBody is true, retrieve the log body 355 | if (args.includeBody) { 356 | try { 357 | // Retrieve the log body 358 | const logBody = await conn.tooling.request({ 359 | method: 'GET', 360 | url: `${conn.instanceUrl}/services/data/v58.0/tooling/sobjects/ApexLog/${log.Id}/Body` 361 | }); 362 | 363 | let responseText = `**Log Details:**\n\n`; 364 | responseText += `- **ID:** ${log.Id}\n`; 365 | responseText += `- **Operation:** ${log.Operation}\n`; 366 | responseText += `- **Application:** ${log.Application}\n`; 367 | responseText += `- **Status:** ${log.Status}\n`; 368 | responseText += `- **Size:** ${log.LogLength} bytes\n`; 369 | responseText += `- **Date:** ${new Date(log.LastModifiedDate).toLocaleString()}\n\n`; 370 | responseText += `**Log Body:**\n\`\`\`\n${logBody}\n\`\`\`\n`; 371 | 372 | return { 373 | content: [{ 374 | type: "text", 375 | text: responseText 376 | }] 377 | }; 378 | } catch (logError) { 379 | console.error('Error retrieving log body:', logError); 380 | return { 381 | content: [{ 382 | type: "text", 383 | text: `Error retrieving log body: ${logError instanceof Error ? logError.message : String(logError)}` 384 | }], 385 | isError: true 386 | }; 387 | } 388 | } else { 389 | // Just return the log metadata 390 | let responseText = `**Log Details:**\n\n`; 391 | responseText += `- **ID:** ${log.Id}\n`; 392 | responseText += `- **Operation:** ${log.Operation}\n`; 393 | responseText += `- **Application:** ${log.Application}\n`; 394 | responseText += `- **Status:** ${log.Status}\n`; 395 | responseText += `- **Size:** ${log.LogLength} bytes\n`; 396 | responseText += `- **Date:** ${new Date(log.LastModifiedDate).toLocaleString()}\n\n`; 397 | responseText += `To view the full log content, add "includeBody": true to your request.`; 398 | 399 | return { 400 | content: [{ 401 | type: "text", 402 | text: responseText 403 | }] 404 | }; 405 | } 406 | } catch (error) { 407 | console.error('Error retrieving log:', error); 408 | return { 409 | content: [{ 410 | type: "text", 411 | text: `Error retrieving log: ${error instanceof Error ? error.message : String(error)}` 412 | }], 413 | isError: true, 414 | }; 415 | } 416 | } 417 | 418 | // Query for logs 419 | const logs = await conn.tooling.query(` 420 | SELECT Id, LogUserId, Operation, Application, Status, LogLength, LastModifiedDate, Request 421 | FROM ApexLog 422 | WHERE LogUserId = '${user.Id}' 423 | ORDER BY LastModifiedDate DESC 424 | LIMIT ${limit} 425 | `); 426 | 427 | if (logs.records.length === 0) { 428 | return { 429 | content: [{ 430 | type: "text", 431 | text: `No debug logs found for user '${args.username}'.` 432 | }] 433 | }; 434 | } 435 | 436 | // Format log information 437 | let responseText = `Found ${logs.records.length} debug logs for user '${args.username}':\n\n`; 438 | 439 | for (let i = 0; i < logs.records.length; i++) { 440 | const log = logs.records[i]; 441 | 442 | responseText += `**Log ${i + 1}**\n`; 443 | responseText += `- **ID:** ${log.Id}\n`; 444 | responseText += `- **Operation:** ${log.Operation}\n`; 445 | responseText += `- **Application:** ${log.Application}\n`; 446 | responseText += `- **Status:** ${log.Status}\n`; 447 | responseText += `- **Size:** ${log.LogLength} bytes\n`; 448 | responseText += `- **Date:** ${new Date(log.LastModifiedDate).toLocaleString()}\n\n`; 449 | } 450 | 451 | // Add a note about viewing specific logs with full content 452 | responseText += `To view a specific log with full content, use:\n\`\`\`\n`; 453 | responseText += `{\n`; 454 | responseText += ` "operation": "retrieve",\n`; 455 | responseText += ` "username": "${args.username}",\n`; 456 | responseText += ` "logId": "",\n`; 457 | responseText += ` "includeBody": true\n`; 458 | responseText += `}\n\`\`\`\n`; 459 | 460 | return { 461 | content: [{ 462 | type: "text", 463 | text: responseText 464 | }] 465 | }; 466 | } 467 | 468 | default: 469 | throw new Error(`Invalid operation: ${args.operation}. Must be 'enable', 'disable', or 'retrieve'.`); 470 | } 471 | } catch (error) { 472 | console.error('Error managing debug logs:', error); 473 | return { 474 | content: [{ 475 | type: "text", 476 | text: `Error managing debug logs: ${error instanceof Error ? error.message : String(error)}` 477 | }], 478 | isError: true, 479 | }; 480 | } 481 | } 482 | -------------------------------------------------------------------------------- /src/tools/manageField.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { FieldMetadataInfo } from "../types/metadata"; 3 | 4 | export const MANAGE_FIELD: Tool = { 5 | name: "salesforce_manage_field", 6 | description: `Create new custom fields or modify existing fields on any Salesforce object: 7 | - Field Types: Text, Number, Date, Lookup, Master-Detail, Picklist etc. 8 | - Properties: Required, Unique, External ID, Length, Scale etc. 9 | - Relationships: Create lookups and master-detail relationships 10 | - Automatically grants Field Level Security to System Administrator (or specified profiles) 11 | Examples: Add Rating__c picklist to Account, Create Account lookup on Custom Object 12 | Note: Use grantAccessTo parameter to specify profiles, defaults to System Administrator`, 13 | inputSchema: { 14 | type: "object", 15 | properties: { 16 | operation: { 17 | type: "string", 18 | enum: ["create", "update"], 19 | description: "Whether to create new field or update existing" 20 | }, 21 | objectName: { 22 | type: "string", 23 | description: "API name of the object to add/modify the field" 24 | }, 25 | fieldName: { 26 | type: "string", 27 | description: "API name for the field (without __c suffix)" 28 | }, 29 | label: { 30 | type: "string", 31 | description: "Label for the field", 32 | optional: true 33 | }, 34 | type: { 35 | type: "string", 36 | enum: ["Checkbox", "Currency", "Date", "DateTime", "Email", "Number", "Percent", 37 | "Phone", "Picklist", "MultiselectPicklist", "Text", "TextArea", "LongTextArea", 38 | "Html", "Url", "Lookup", "MasterDetail"], 39 | description: "Field type (required for create)", 40 | optional: true 41 | }, 42 | required: { 43 | type: "boolean", 44 | description: "Whether the field is required", 45 | optional: true 46 | }, 47 | unique: { 48 | type: "boolean", 49 | description: "Whether the field value must be unique", 50 | optional: true 51 | }, 52 | externalId: { 53 | type: "boolean", 54 | description: "Whether the field is an external ID", 55 | optional: true 56 | }, 57 | length: { 58 | type: "number", 59 | description: "Length for text fields", 60 | optional: true 61 | }, 62 | precision: { 63 | type: "number", 64 | description: "Precision for numeric fields", 65 | optional: true 66 | }, 67 | scale: { 68 | type: "number", 69 | description: "Scale for numeric fields", 70 | optional: true 71 | }, 72 | referenceTo: { 73 | type: "string", 74 | description: "API name of the object to reference (for Lookup/MasterDetail)", 75 | optional: true 76 | }, 77 | relationshipLabel: { 78 | type: "string", 79 | description: "Label for the relationship (for Lookup/MasterDetail)", 80 | optional: true 81 | }, 82 | relationshipName: { 83 | type: "string", 84 | description: "API name for the relationship (for Lookup/MasterDetail)", 85 | optional: true 86 | }, 87 | deleteConstraint: { 88 | type: "string", 89 | enum: ["Cascade", "Restrict", "SetNull"], 90 | description: "Delete constraint for Lookup fields", 91 | optional: true 92 | }, 93 | picklistValues: { 94 | type: "array", 95 | items: { 96 | type: "object", 97 | properties: { 98 | label: { type: "string" }, 99 | isDefault: { type: "boolean", optional: true } 100 | } 101 | }, 102 | description: "Values for Picklist/MultiselectPicklist fields", 103 | optional: true 104 | }, 105 | description: { 106 | type: "string", 107 | description: "Description of the field", 108 | optional: true 109 | }, 110 | grantAccessTo: { 111 | type: "array", 112 | items: { type: "string" }, 113 | description: "Profile names to grant field access to (defaults to ['System Administrator'])", 114 | optional: true 115 | } 116 | }, 117 | required: ["operation", "objectName", "fieldName"] 118 | } 119 | }; 120 | 121 | export interface ManageFieldArgs { 122 | operation: 'create' | 'update'; 123 | objectName: string; 124 | fieldName: string; 125 | label?: string; 126 | type?: string; 127 | required?: boolean; 128 | unique?: boolean; 129 | externalId?: boolean; 130 | length?: number; 131 | precision?: number; 132 | scale?: number; 133 | referenceTo?: string; 134 | relationshipLabel?: string; 135 | relationshipName?: string; 136 | deleteConstraint?: 'Cascade' | 'Restrict' | 'SetNull'; 137 | picklistValues?: Array<{ label: string; isDefault?: boolean }>; 138 | description?: string; 139 | grantAccessTo?: string[]; 140 | } 141 | 142 | // Helper function to set field permissions (simplified version of the one in manageFieldPermissions.ts) 143 | async function grantFieldPermissions(conn: any, objectName: string, fieldName: string, profileNames: string[]): Promise<{success: boolean; message: string}> { 144 | try { 145 | const fieldApiName = fieldName.endsWith('__c') || fieldName.includes('.') ? fieldName : `${fieldName}__c`; 146 | const fullFieldName = `${objectName}.${fieldApiName}`; 147 | 148 | // Get profile IDs 149 | const profileQuery = await conn.query(` 150 | SELECT Id, Name 151 | FROM Profile 152 | WHERE Name IN (${profileNames.map(name => `'${name}'`).join(', ')}) 153 | `); 154 | 155 | if (profileQuery.records.length === 0) { 156 | return { success: false, message: `No profiles found matching: ${profileNames.join(', ')}` }; 157 | } 158 | 159 | const results: any[] = []; 160 | const errors: string[] = []; 161 | 162 | for (const profile of profileQuery.records) { 163 | try { 164 | // Check if permission already exists 165 | const existingPerm = await conn.query(` 166 | SELECT Id, PermissionsRead, PermissionsEdit 167 | FROM FieldPermissions 168 | WHERE ParentId IN ( 169 | SELECT Id FROM PermissionSet 170 | WHERE IsOwnedByProfile = true 171 | AND ProfileId = '${profile.Id}' 172 | ) 173 | AND Field = '${fullFieldName}' 174 | AND SobjectType = '${objectName}' 175 | LIMIT 1 176 | `); 177 | 178 | if (existingPerm.records.length > 0) { 179 | // Update existing permission 180 | await conn.sobject('FieldPermissions').update({ 181 | Id: existingPerm.records[0].Id, 182 | PermissionsRead: true, 183 | PermissionsEdit: true 184 | }); 185 | results.push(profile.Name); 186 | } else { 187 | // Get the PermissionSet ID for this profile 188 | const permSetQuery = await conn.query(` 189 | SELECT Id FROM PermissionSet 190 | WHERE IsOwnedByProfile = true 191 | AND ProfileId = '${profile.Id}' 192 | LIMIT 1 193 | `); 194 | 195 | if (permSetQuery.records.length > 0) { 196 | // Create new permission 197 | await conn.sobject('FieldPermissions').create({ 198 | ParentId: permSetQuery.records[0].Id, 199 | SobjectType: objectName, 200 | Field: fullFieldName, 201 | PermissionsRead: true, 202 | PermissionsEdit: true 203 | }); 204 | results.push(profile.Name); 205 | } else { 206 | errors.push(profile.Name); 207 | } 208 | } 209 | } catch (error) { 210 | errors.push(profile.Name); 211 | console.error(`Error granting permission to ${profile.Name}:`, error); 212 | } 213 | } 214 | 215 | if (results.length > 0) { 216 | return { 217 | success: true, 218 | message: `Field Level Security granted to: ${results.join(', ')}${errors.length > 0 ? `. Failed for: ${errors.join(', ')}` : ''}` 219 | }; 220 | } else { 221 | return { 222 | success: false, 223 | message: `Could not grant Field Level Security to any profiles.` 224 | }; 225 | } 226 | } catch (error) { 227 | console.error('Error granting field permissions:', error); 228 | return { 229 | success: false, 230 | message: `Field Level Security configuration failed.` 231 | }; 232 | } 233 | } 234 | 235 | export async function handleManageField(conn: any, args: ManageFieldArgs) { 236 | const { operation, objectName, fieldName, type, grantAccessTo, ...fieldProps } = args; 237 | 238 | try { 239 | if (operation === 'create') { 240 | if (!type) { 241 | throw new Error('Field type is required for field creation'); 242 | } 243 | 244 | // Prepare base metadata for the new field 245 | const metadata: FieldMetadataInfo = { 246 | fullName: `${objectName}.${fieldName}__c`, 247 | label: fieldProps.label || fieldName, 248 | type, 249 | ...(fieldProps.required && { required: fieldProps.required }), 250 | ...(fieldProps.unique && { unique: fieldProps.unique }), 251 | ...(fieldProps.externalId && { externalId: fieldProps.externalId }), 252 | ...(fieldProps.description && { description: fieldProps.description }) 253 | }; 254 | 255 | // Add type-specific properties 256 | switch (type) { 257 | case 'MasterDetail': 258 | case 'Lookup': 259 | if (fieldProps.referenceTo) { 260 | metadata.referenceTo = fieldProps.referenceTo; 261 | metadata.relationshipName = fieldProps.relationshipName; 262 | metadata.relationshipLabel = fieldProps.relationshipLabel || fieldProps.relationshipName; 263 | if (type === 'Lookup' && fieldProps.deleteConstraint) { 264 | metadata.deleteConstraint = fieldProps.deleteConstraint; 265 | } 266 | } 267 | break; 268 | 269 | case 'TextArea': 270 | metadata.type = 'LongTextArea'; 271 | metadata.length = fieldProps.length || 32768; 272 | metadata.visibleLines = 3; 273 | break; 274 | 275 | case 'Text': 276 | if (fieldProps.length) { 277 | metadata.length = fieldProps.length; 278 | } 279 | break; 280 | 281 | case 'Number': 282 | if (fieldProps.precision) { 283 | metadata.precision = fieldProps.precision; 284 | metadata.scale = fieldProps.scale || 0; 285 | } 286 | break; 287 | 288 | case 'Picklist': 289 | case 'MultiselectPicklist': 290 | if (fieldProps.picklistValues) { 291 | metadata.valueSet = { 292 | valueSetDefinition: { 293 | sorted: true, 294 | value: fieldProps.picklistValues.map(val => ({ 295 | fullName: val.label, 296 | default: val.isDefault || false, 297 | label: val.label 298 | })) 299 | } 300 | }; 301 | } 302 | break; 303 | } 304 | 305 | // Create the field 306 | const result = await conn.metadata.create('CustomField', metadata); 307 | 308 | if (result && (Array.isArray(result) ? result[0].success : result.success)) { 309 | let permissionMessage = ''; 310 | 311 | // Grant Field Level Security (default to System Administrator if not specified) 312 | const profilesToGrant = grantAccessTo && grantAccessTo.length > 0 ? grantAccessTo : ['System Administrator']; 313 | 314 | // Wait a moment for field to be fully created 315 | await new Promise(resolve => setTimeout(resolve, 2000)); 316 | 317 | const permissionResult = await grantFieldPermissions(conn, objectName, fieldName, profilesToGrant); 318 | permissionMessage = `\n${permissionResult.message}`; 319 | 320 | return { 321 | content: [{ 322 | type: "text", 323 | text: `Successfully created custom field ${fieldName}__c on ${objectName}.${permissionMessage}` 324 | }], 325 | isError: false, 326 | }; 327 | } 328 | } else { 329 | // For update, first get existing metadata 330 | const existingMetadata = await conn.metadata.read('CustomField', [`${objectName}.${fieldName}__c`]); 331 | const currentMetadata = Array.isArray(existingMetadata) ? existingMetadata[0] : existingMetadata; 332 | 333 | if (!currentMetadata) { 334 | throw new Error(`Field ${fieldName}__c not found on object ${objectName}`); 335 | } 336 | 337 | // Prepare update metadata 338 | const metadata: FieldMetadataInfo = { 339 | ...currentMetadata, 340 | ...(fieldProps.label && { label: fieldProps.label }), 341 | ...(fieldProps.required !== undefined && { required: fieldProps.required }), 342 | ...(fieldProps.unique !== undefined && { unique: fieldProps.unique }), 343 | ...(fieldProps.externalId !== undefined && { externalId: fieldProps.externalId }), 344 | ...(fieldProps.description !== undefined && { description: fieldProps.description }), 345 | ...(fieldProps.length && { length: fieldProps.length }), 346 | ...(fieldProps.precision && { precision: fieldProps.precision, scale: fieldProps.scale || 0 }) 347 | }; 348 | 349 | // Special handling for picklist values if provided 350 | if (fieldProps.picklistValues && 351 | (currentMetadata.type === 'Picklist' || currentMetadata.type === 'MultiselectPicklist')) { 352 | metadata.valueSet = { 353 | valueSetDefinition: { 354 | sorted: true, 355 | value: fieldProps.picklistValues.map(val => ({ 356 | fullName: val.label, 357 | default: val.isDefault || false, 358 | label: val.label 359 | })) 360 | } 361 | }; 362 | } 363 | 364 | // Update the field 365 | const result = await conn.metadata.update('CustomField', metadata); 366 | 367 | if (result && (Array.isArray(result) ? result[0].success : result.success)) { 368 | return { 369 | content: [{ 370 | type: "text", 371 | text: `Successfully updated custom field ${fieldName}__c on ${objectName}` 372 | }], 373 | isError: false, 374 | }; 375 | } 376 | } 377 | 378 | return { 379 | content: [{ 380 | type: "text", 381 | text: `Failed to ${operation} custom field ${fieldName}__c` 382 | }], 383 | isError: true, 384 | }; 385 | 386 | } catch (error) { 387 | return { 388 | content: [{ 389 | type: "text", 390 | text: `Error ${operation === 'create' ? 'creating' : 'updating'} custom field: ${error instanceof Error ? error.message : String(error)}` 391 | }], 392 | isError: true, 393 | }; 394 | } 395 | } -------------------------------------------------------------------------------- /src/tools/manageFieldPermissions.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | export const MANAGE_FIELD_PERMISSIONS: Tool = { 4 | name: "salesforce_manage_field_permissions", 5 | description: `Manage Field Level Security (Field Permissions) for custom and standard fields. 6 | - Grant or revoke read/edit access to fields for specific profiles or permission sets 7 | - View current field permissions 8 | - Bulk update permissions for multiple profiles 9 | 10 | Examples: 11 | 1. Grant System Administrator access to a field 12 | 2. Give read-only access to a field for specific profiles 13 | 3. Check which profiles have access to a field`, 14 | inputSchema: { 15 | type: "object", 16 | properties: { 17 | operation: { 18 | type: "string", 19 | enum: ["grant", "revoke", "view"], 20 | description: "Operation to perform on field permissions" 21 | }, 22 | objectName: { 23 | type: "string", 24 | description: "API name of the object (e.g., 'Account', 'Custom_Object__c')" 25 | }, 26 | fieldName: { 27 | type: "string", 28 | description: "API name of the field (e.g., 'Custom_Field__c')" 29 | }, 30 | profileNames: { 31 | type: "array", 32 | items: { type: "string" }, 33 | description: "Names of profiles to grant/revoke access (e.g., ['System Administrator', 'Sales User'])", 34 | optional: true 35 | }, 36 | readable: { 37 | type: "boolean", 38 | description: "Grant/revoke read access (default: true)", 39 | optional: true 40 | }, 41 | editable: { 42 | type: "boolean", 43 | description: "Grant/revoke edit access (default: true)", 44 | optional: true 45 | } 46 | }, 47 | required: ["operation", "objectName", "fieldName"] 48 | } 49 | }; 50 | 51 | export interface ManageFieldPermissionsArgs { 52 | operation: 'grant' | 'revoke' | 'view'; 53 | objectName: string; 54 | fieldName: string; 55 | profileNames?: string[]; 56 | readable?: boolean; 57 | editable?: boolean; 58 | } 59 | 60 | export async function handleManageFieldPermissions(conn: any, args: ManageFieldPermissionsArgs) { 61 | const { operation, objectName, fieldName, readable = true, editable = true } = args; 62 | let { profileNames } = args; 63 | 64 | try { 65 | // Ensure field name has __c suffix if it's a custom field and doesn't already have it 66 | const fieldApiName = fieldName.endsWith('__c') || fieldName.includes('.') ? fieldName : `${fieldName}__c`; 67 | const fullFieldName = `${objectName}.${fieldApiName}`; 68 | 69 | if (operation === 'view') { 70 | // Query existing field permissions 71 | const permissionsQuery = ` 72 | SELECT Id, Parent.ProfileId, Parent.Profile.Name, Parent.IsOwnedByProfile, 73 | Parent.PermissionSetId, Parent.PermissionSet.Name, 74 | Field, PermissionsRead, PermissionsEdit 75 | FROM FieldPermissions 76 | WHERE SobjectType = '${objectName}' 77 | AND Field = '${fullFieldName}' 78 | ORDER BY Parent.Profile.Name 79 | `; 80 | 81 | const result = await conn.query(permissionsQuery); 82 | 83 | if (result.records.length === 0) { 84 | return { 85 | content: [{ 86 | type: "text", 87 | text: `No field permissions found for ${fullFieldName}. This field might not have any specific permissions set, or it might be universally accessible.` 88 | }], 89 | isError: false, 90 | }; 91 | } 92 | 93 | let responseText = `Field permissions for ${fullFieldName}:\n\n`; 94 | 95 | result.records.forEach((perm: any) => { 96 | const name = perm.Parent.IsOwnedByProfile 97 | ? perm.Parent.Profile?.Name 98 | : perm.Parent.PermissionSet?.Name; 99 | const type = perm.Parent.IsOwnedByProfile ? 'Profile' : 'Permission Set'; 100 | 101 | responseText += `${type}: ${name}\n`; 102 | responseText += ` - Read Access: ${perm.PermissionsRead ? 'Yes' : 'No'}\n`; 103 | responseText += ` - Edit Access: ${perm.PermissionsEdit ? 'Yes' : 'No'}\n\n`; 104 | }); 105 | 106 | return { 107 | content: [{ 108 | type: "text", 109 | text: responseText 110 | }], 111 | isError: false, 112 | }; 113 | } 114 | 115 | // For grant/revoke operations 116 | if (!profileNames || profileNames.length === 0) { 117 | // If no profiles specified, default to System Administrator 118 | profileNames = ['System Administrator']; 119 | } 120 | 121 | // Get profile IDs 122 | const profileQuery = await conn.query(` 123 | SELECT Id, Name 124 | FROM Profile 125 | WHERE Name IN (${profileNames.map(name => `'${name}'`).join(', ')}) 126 | `); 127 | 128 | if (profileQuery.records.length === 0) { 129 | return { 130 | content: [{ 131 | type: "text", 132 | text: `No profiles found matching: ${profileNames.join(', ')}` 133 | }], 134 | isError: true, 135 | }; 136 | } 137 | 138 | const results: any[] = []; 139 | const errors: string[] = []; 140 | 141 | for (const profile of profileQuery.records) { 142 | try { 143 | if (operation === 'grant') { 144 | // First, check if permission already exists 145 | const existingPerm = await conn.query(` 146 | SELECT Id, PermissionsRead, PermissionsEdit 147 | FROM FieldPermissions 148 | WHERE ParentId IN ( 149 | SELECT Id FROM PermissionSet 150 | WHERE IsOwnedByProfile = true 151 | AND ProfileId = '${profile.Id}' 152 | ) 153 | AND Field = '${fullFieldName}' 154 | AND SobjectType = '${objectName}' 155 | LIMIT 1 156 | `); 157 | 158 | if (existingPerm.records.length > 0) { 159 | // Update existing permission 160 | const updateResult = await conn.sobject('FieldPermissions').update({ 161 | Id: existingPerm.records[0].Id, 162 | PermissionsRead: readable, 163 | PermissionsEdit: editable && readable // Edit requires read 164 | }); 165 | 166 | results.push({ 167 | profile: profile.Name, 168 | action: 'updated', 169 | success: updateResult.success 170 | }); 171 | } else { 172 | // Get the PermissionSet ID for this profile 173 | const permSetQuery = await conn.query(` 174 | SELECT Id FROM PermissionSet 175 | WHERE IsOwnedByProfile = true 176 | AND ProfileId = '${profile.Id}' 177 | LIMIT 1 178 | `); 179 | 180 | if (permSetQuery.records.length > 0) { 181 | // Create new permission 182 | const createResult = await conn.sobject('FieldPermissions').create({ 183 | ParentId: permSetQuery.records[0].Id, 184 | SobjectType: objectName, 185 | Field: fullFieldName, 186 | PermissionsRead: readable, 187 | PermissionsEdit: editable && readable // Edit requires read 188 | }); 189 | 190 | results.push({ 191 | profile: profile.Name, 192 | action: 'created', 193 | success: createResult.success 194 | }); 195 | } else { 196 | errors.push(`Could not find permission set for profile: ${profile.Name}`); 197 | } 198 | } 199 | } else if (operation === 'revoke') { 200 | // Find and delete the permission 201 | const existingPerm = await conn.query(` 202 | SELECT Id 203 | FROM FieldPermissions 204 | WHERE ParentId IN ( 205 | SELECT Id FROM PermissionSet 206 | WHERE IsOwnedByProfile = true 207 | AND ProfileId = '${profile.Id}' 208 | ) 209 | AND Field = '${fullFieldName}' 210 | AND SobjectType = '${objectName}' 211 | LIMIT 1 212 | `); 213 | 214 | if (existingPerm.records.length > 0) { 215 | const deleteResult = await conn.sobject('FieldPermissions').delete(existingPerm.records[0].Id); 216 | results.push({ 217 | profile: profile.Name, 218 | action: 'revoked', 219 | success: true 220 | }); 221 | } else { 222 | results.push({ 223 | profile: profile.Name, 224 | action: 'no permission found', 225 | success: true 226 | }); 227 | } 228 | } 229 | } catch (error) { 230 | errors.push(`${profile.Name}: ${error instanceof Error ? error.message : String(error)}`); 231 | } 232 | } 233 | 234 | // Format response 235 | let responseText = `Field permission ${operation} operation completed for ${fullFieldName}:\n\n`; 236 | 237 | const successful = results.filter(r => r.success); 238 | const failed = results.filter(r => !r.success); 239 | 240 | if (successful.length > 0) { 241 | responseText += 'Successful:\n'; 242 | successful.forEach(r => { 243 | responseText += ` - ${r.profile}: ${r.action}\n`; 244 | }); 245 | } 246 | 247 | if (failed.length > 0 || errors.length > 0) { 248 | responseText += '\nFailed:\n'; 249 | failed.forEach(r => { 250 | responseText += ` - ${r.profile}: ${r.action}\n`; 251 | }); 252 | errors.forEach(e => { 253 | responseText += ` - ${e}\n`; 254 | }); 255 | } 256 | 257 | if (operation === 'grant') { 258 | responseText += `\nPermissions granted:\n - Read: ${readable ? 'Yes' : 'No'}\n - Edit: ${editable ? 'Yes' : 'No'}`; 259 | } 260 | 261 | return { 262 | content: [{ 263 | type: "text", 264 | text: responseText 265 | }], 266 | isError: false, 267 | }; 268 | 269 | } catch (error) { 270 | return { 271 | content: [{ 272 | type: "text", 273 | text: `Error managing field permissions: ${error instanceof Error ? error.message : String(error)}` 274 | }], 275 | isError: true, 276 | }; 277 | } 278 | } -------------------------------------------------------------------------------- /src/tools/manageObject.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { MetadataInfo } from "../types/metadata"; 3 | 4 | export const MANAGE_OBJECT: Tool = { 5 | name: "salesforce_manage_object", 6 | description: `Create new custom objects or modify existing ones in Salesforce: 7 | - Create: New custom objects with fields, relationships, and settings 8 | - Update: Modify existing object settings, labels, sharing model 9 | Examples: Create Customer_Feedback__c object, Update object sharing settings 10 | Note: Changes affect metadata and require proper permissions`, 11 | inputSchema: { 12 | type: "object", 13 | properties: { 14 | operation: { 15 | type: "string", 16 | enum: ["create", "update"], 17 | description: "Whether to create new object or update existing" 18 | }, 19 | objectName: { 20 | type: "string", 21 | description: "API name for the object (without __c suffix)" 22 | }, 23 | label: { 24 | type: "string", 25 | description: "Label for the object" 26 | }, 27 | pluralLabel: { 28 | type: "string", 29 | description: "Plural label for the object" 30 | }, 31 | description: { 32 | type: "string", 33 | description: "Description of the object", 34 | optional: true 35 | }, 36 | nameFieldLabel: { 37 | type: "string", 38 | description: "Label for the name field", 39 | optional: true 40 | }, 41 | nameFieldType: { 42 | type: "string", 43 | enum: ["Text", "AutoNumber"], 44 | description: "Type of the name field", 45 | optional: true 46 | }, 47 | nameFieldFormat: { 48 | type: "string", 49 | description: "Display format for AutoNumber field (e.g., 'A-{0000}')", 50 | optional: true 51 | }, 52 | sharingModel: { 53 | type: "string", 54 | enum: ["ReadWrite", "Read", "Private", "ControlledByParent"], 55 | description: "Sharing model for the object", 56 | optional: true 57 | } 58 | }, 59 | required: ["operation", "objectName"] 60 | } 61 | }; 62 | 63 | export interface ManageObjectArgs { 64 | operation: 'create' | 'update'; 65 | objectName: string; 66 | label?: string; 67 | pluralLabel?: string; 68 | description?: string; 69 | nameFieldLabel?: string; 70 | nameFieldType?: 'Text' | 'AutoNumber'; 71 | nameFieldFormat?: string; 72 | sharingModel?: 'ReadWrite' | 'Read' | 'Private' | 'ControlledByParent'; 73 | } 74 | 75 | export async function handleManageObject(conn: any, args: ManageObjectArgs) { 76 | const { operation, objectName, label, pluralLabel, description, nameFieldLabel, nameFieldType, nameFieldFormat, sharingModel } = args; 77 | 78 | try { 79 | if (operation === 'create') { 80 | if (!label || !pluralLabel) { 81 | throw new Error('Label and pluralLabel are required for object creation'); 82 | } 83 | 84 | // Prepare metadata for the new object 85 | const metadata = { 86 | fullName: `${objectName}__c`, 87 | label, 88 | pluralLabel, 89 | nameField: { 90 | label: nameFieldLabel || `${label} Name`, 91 | type: nameFieldType || 'Text', 92 | ...(nameFieldType === 'AutoNumber' && nameFieldFormat ? { displayFormat: nameFieldFormat } : {}) 93 | }, 94 | deploymentStatus: 'Deployed', 95 | sharingModel: sharingModel || 'ReadWrite' 96 | } as MetadataInfo; 97 | 98 | if (description) { 99 | metadata.description = description; 100 | } 101 | 102 | // Create the object using Metadata API 103 | const result = await conn.metadata.create('CustomObject', metadata); 104 | 105 | if (result && (Array.isArray(result) ? result[0].success : result.success)) { 106 | return { 107 | content: [{ 108 | type: "text", 109 | text: `Successfully created custom object ${objectName}__c` 110 | }], 111 | isError: false, 112 | }; 113 | } 114 | } else { 115 | // For update, first get existing metadata 116 | const existingMetadata = await conn.metadata.read('CustomObject', [`${objectName}__c`]); 117 | const currentMetadata = Array.isArray(existingMetadata) ? existingMetadata[0] : existingMetadata; 118 | 119 | if (!currentMetadata) { 120 | throw new Error(`Object ${objectName}__c not found`); 121 | } 122 | 123 | // Prepare update metadata 124 | const metadata = { 125 | ...currentMetadata, 126 | label: label || currentMetadata.label, 127 | pluralLabel: pluralLabel || currentMetadata.pluralLabel, 128 | description: description !== undefined ? description : currentMetadata.description, 129 | sharingModel: sharingModel || currentMetadata.sharingModel 130 | } as MetadataInfo; 131 | 132 | // Update the object using Metadata API 133 | const result = await conn.metadata.update('CustomObject', metadata); 134 | 135 | if (result && (Array.isArray(result) ? result[0].success : result.success)) { 136 | return { 137 | content: [{ 138 | type: "text", 139 | text: `Successfully updated custom object ${objectName}__c` 140 | }], 141 | isError: false, 142 | }; 143 | } 144 | } 145 | 146 | return { 147 | content: [{ 148 | type: "text", 149 | text: `Failed to ${operation} custom object ${objectName}__c` 150 | }], 151 | isError: true, 152 | }; 153 | 154 | } catch (error) { 155 | return { 156 | content: [{ 157 | type: "text", 158 | text: `Error ${operation === 'create' ? 'creating' : 'updating'} custom object: ${error instanceof Error ? error.message : String(error)}` 159 | }], 160 | isError: true, 161 | }; 162 | } 163 | } -------------------------------------------------------------------------------- /src/tools/query.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | export const QUERY_RECORDS: Tool = { 4 | name: "salesforce_query_records", 5 | description: `Query records from any Salesforce object using SOQL, including relationship queries. 6 | 7 | NOTE: For queries with GROUP BY, aggregate functions (COUNT, SUM, AVG, etc.), or HAVING clauses, use salesforce_aggregate_query instead. 8 | 9 | Examples: 10 | 1. Parent-to-child query (e.g., Account with Contacts): 11 | - objectName: "Account" 12 | - fields: ["Name", "(SELECT Id, FirstName, LastName FROM Contacts)"] 13 | 14 | 2. Child-to-parent query (e.g., Contact with Account details): 15 | - objectName: "Contact" 16 | - fields: ["FirstName", "LastName", "Account.Name", "Account.Industry"] 17 | 18 | 3. Multiple level query (e.g., Contact -> Account -> Owner): 19 | - objectName: "Contact" 20 | - fields: ["Name", "Account.Name", "Account.Owner.Name"] 21 | 22 | 4. Related object filtering: 23 | - objectName: "Contact" 24 | - fields: ["Name", "Account.Name"] 25 | - whereClause: "Account.Industry = 'Technology'" 26 | 27 | Note: When using relationship fields: 28 | - Use dot notation for parent relationships (e.g., "Account.Name") 29 | - Use subqueries in parentheses for child relationships (e.g., "(SELECT Id FROM Contacts)") 30 | - Custom relationship fields end in "__r" (e.g., "CustomObject__r.Name")`, 31 | inputSchema: { 32 | type: "object", 33 | properties: { 34 | objectName: { 35 | type: "string", 36 | description: "API name of the object to query" 37 | }, 38 | fields: { 39 | type: "array", 40 | items: { type: "string" }, 41 | description: "List of fields to retrieve, including relationship fields" 42 | }, 43 | whereClause: { 44 | type: "string", 45 | description: "WHERE clause, can include conditions on related objects", 46 | optional: true 47 | }, 48 | orderBy: { 49 | type: "string", 50 | description: "ORDER BY clause, can include fields from related objects", 51 | optional: true 52 | }, 53 | limit: { 54 | type: "number", 55 | description: "Maximum number of records to return", 56 | optional: true 57 | } 58 | }, 59 | required: ["objectName", "fields"] 60 | } 61 | }; 62 | 63 | export interface QueryArgs { 64 | objectName: string; 65 | fields: string[]; 66 | whereClause?: string; 67 | orderBy?: string; 68 | limit?: number; 69 | } 70 | 71 | // Helper function to validate relationship field syntax 72 | function validateRelationshipFields(fields: string[]): { isValid: boolean; error?: string } { 73 | for (const field of fields) { 74 | // Check for parent relationship syntax (dot notation) 75 | if (field.includes('.')) { 76 | const parts = field.split('.'); 77 | // Check for empty parts 78 | if (parts.some(part => !part)) { 79 | return { 80 | isValid: false, 81 | error: `Invalid relationship field format: "${field}". Relationship fields should use proper dot notation (e.g., "Account.Name")` 82 | }; 83 | } 84 | // Check for too many levels (Salesforce typically limits to 5) 85 | if (parts.length > 5) { 86 | return { 87 | isValid: false, 88 | error: `Relationship field "${field}" exceeds maximum depth of 5 levels` 89 | }; 90 | } 91 | } 92 | 93 | // Check for child relationship syntax (subqueries) 94 | if (field.includes('SELECT') && !field.match(/^\(SELECT.*FROM.*\)$/)) { 95 | return { 96 | isValid: false, 97 | error: `Invalid subquery format: "${field}". Child relationship queries should be wrapped in parentheses` 98 | }; 99 | } 100 | } 101 | 102 | return { isValid: true }; 103 | } 104 | 105 | // Helper function to format relationship query results 106 | function formatRelationshipResults(record: any, field: string, prefix = ''): string { 107 | if (field.includes('.')) { 108 | const [relationship, ...rest] = field.split('.'); 109 | const relatedRecord = record[relationship]; 110 | if (relatedRecord === null) { 111 | return `${prefix}${field}: null`; 112 | } 113 | return formatRelationshipResults(relatedRecord, rest.join('.'), `${prefix}${relationship}.`); 114 | } 115 | 116 | const value = record[field]; 117 | if (Array.isArray(value)) { 118 | // Handle child relationship arrays 119 | return `${prefix}${field}: [${value.length} records]`; 120 | } 121 | return `${prefix}${field}: ${value !== null && value !== undefined ? value : 'null'}`; 122 | } 123 | 124 | export async function handleQueryRecords(conn: any, args: QueryArgs) { 125 | const { objectName, fields, whereClause, orderBy, limit } = args; 126 | 127 | try { 128 | // Validate relationship field syntax 129 | const validation = validateRelationshipFields(fields); 130 | if (!validation.isValid) { 131 | return { 132 | content: [{ 133 | type: "text", 134 | text: validation.error! 135 | }], 136 | isError: true, 137 | }; 138 | } 139 | 140 | // Construct SOQL query 141 | let soql = `SELECT ${fields.join(', ')} FROM ${objectName}`; 142 | if (whereClause) soql += ` WHERE ${whereClause}`; 143 | if (orderBy) soql += ` ORDER BY ${orderBy}`; 144 | if (limit) soql += ` LIMIT ${limit}`; 145 | 146 | const result = await conn.query(soql); 147 | 148 | // Format the output 149 | const formattedRecords = result.records.map((record: any, index: number) => { 150 | const recordStr = fields.map(field => { 151 | // Handle special case for subqueries (child relationships) 152 | if (field.startsWith('(SELECT')) { 153 | const relationshipName = field.match(/FROM\s+(\w+)/)?.[1]; 154 | if (!relationshipName) return ` ${field}: Invalid subquery format`; 155 | const childRecords = record[relationshipName]; 156 | return ` ${relationshipName}: [${childRecords?.length || 0} records]`; 157 | } 158 | return ' ' + formatRelationshipResults(record, field); 159 | }).join('\n'); 160 | return `Record ${index + 1}:\n${recordStr}`; 161 | }).join('\n\n'); 162 | 163 | return { 164 | content: [{ 165 | type: "text", 166 | text: `Query returned ${result.records.length} records:\n\n${formattedRecords}` 167 | }], 168 | isError: false, 169 | }; 170 | } catch (error) { 171 | // Enhanced error handling for relationship queries 172 | const errorMessage = error instanceof Error ? error.message : String(error); 173 | let enhancedError = errorMessage; 174 | 175 | if (errorMessage.includes('INVALID_FIELD')) { 176 | // Try to identify which relationship field caused the error 177 | const fieldMatch = errorMessage.match(/(?:No such column |Invalid field: )['"]?([^'")\s]+)/); 178 | if (fieldMatch) { 179 | const invalidField = fieldMatch[1]; 180 | if (invalidField.includes('.')) { 181 | enhancedError = `Invalid relationship field "${invalidField}". Please check:\n` + 182 | `1. The relationship name is correct\n` + 183 | `2. The field exists on the related object\n` + 184 | `3. You have access to the field\n` + 185 | `4. For custom relationships, ensure you're using '__r' suffix`; 186 | } 187 | } 188 | } 189 | 190 | return { 191 | content: [{ 192 | type: "text", 193 | text: `Error executing query: ${enhancedError}` 194 | }], 195 | isError: true, 196 | }; 197 | } 198 | } -------------------------------------------------------------------------------- /src/tools/readApex.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | export const READ_APEX: Tool = { 4 | name: "salesforce_read_apex", 5 | description: `Read Apex classes from Salesforce. 6 | 7 | Examples: 8 | 1. Read a specific Apex class by name: 9 | { 10 | "className": "AccountController" 11 | } 12 | 13 | 2. List all Apex classes with an optional name pattern: 14 | { 15 | "namePattern": "Controller" 16 | } 17 | 18 | 3. Get metadata about Apex classes: 19 | { 20 | "includeMetadata": true, 21 | "namePattern": "Trigger" 22 | } 23 | 24 | 4. Use wildcards in name patterns: 25 | { 26 | "namePattern": "Account*Cont*" 27 | } 28 | 29 | Notes: 30 | - When className is provided, the full body of that specific class is returned 31 | - When namePattern is provided, all matching class names are returned (without body) 32 | - Use includeMetadata to get additional information like API version, length, and last modified date 33 | - If neither className nor namePattern is provided, all Apex class names will be listed 34 | - Wildcards are supported in namePattern: * (matches any characters) and ? (matches a single character)`, 35 | inputSchema: { 36 | type: "object", 37 | properties: { 38 | className: { 39 | type: "string", 40 | description: "Name of a specific Apex class to read" 41 | }, 42 | namePattern: { 43 | type: "string", 44 | description: "Pattern to match Apex class names (supports wildcards * and ?)" 45 | }, 46 | includeMetadata: { 47 | type: "boolean", 48 | description: "Whether to include metadata about the Apex classes" 49 | } 50 | } 51 | } 52 | }; 53 | 54 | export interface ReadApexArgs { 55 | className?: string; 56 | namePattern?: string; 57 | includeMetadata?: boolean; 58 | } 59 | 60 | /** 61 | * Converts a wildcard pattern to a SQL LIKE pattern 62 | * @param pattern Pattern with * and ? wildcards 63 | * @returns SQL LIKE compatible pattern 64 | */ 65 | function wildcardToLikePattern(pattern: string): string { 66 | if (!pattern.includes('*') && !pattern.includes('?')) { 67 | // If no wildcards, wrap with % for substring match 68 | return `%${pattern}%`; 69 | } 70 | 71 | // Replace * with % and ? with _ for SQL LIKE 72 | let likePattern = pattern.replace(/\*/g, '%').replace(/\?/g, '_'); 73 | 74 | return likePattern; 75 | } 76 | 77 | /** 78 | * Handles reading Apex classes from Salesforce 79 | * @param conn Active Salesforce connection 80 | * @param args Arguments for reading Apex classes 81 | * @returns Tool response with Apex class information 82 | */ 83 | export async function handleReadApex(conn: any, args: ReadApexArgs) { 84 | try { 85 | // If a specific class name is provided, get the full class body 86 | if (args.className) { 87 | console.error(`Reading Apex class: ${args.className}`); 88 | 89 | // Query the ApexClass object to get the class body 90 | const result = await conn.query(` 91 | SELECT Id, Name, Body, ApiVersion, LengthWithoutComments, Status, 92 | IsValid, LastModifiedDate, LastModifiedById 93 | FROM ApexClass 94 | WHERE Name = '${args.className}' 95 | `); 96 | 97 | if (result.records.length === 0) { 98 | return { 99 | content: [{ 100 | type: "text", 101 | text: `No Apex class found with name: ${args.className}` 102 | }], 103 | isError: true, 104 | }; 105 | } 106 | 107 | const apexClass = result.records[0]; 108 | 109 | // Format the response with the class body and metadata 110 | return { 111 | content: [ 112 | { 113 | type: "text", 114 | text: `# Apex Class: ${apexClass.Name}\n\n` + 115 | (args.includeMetadata ? 116 | `**API Version:** ${apexClass.ApiVersion}\n` + 117 | `**Length:** ${apexClass.LengthWithoutComments} characters\n` + 118 | `**Status:** ${apexClass.Status}\n` + 119 | `**Valid:** ${apexClass.IsValid ? 'Yes' : 'No'}\n` + 120 | `**Last Modified:** ${new Date(apexClass.LastModifiedDate).toLocaleString()}\n\n` : '') + 121 | "```apex\n" + apexClass.Body + "\n```" 122 | } 123 | ] 124 | }; 125 | } 126 | // Otherwise, list classes matching the pattern 127 | else { 128 | console.error(`Listing Apex classes${args.namePattern ? ` matching: ${args.namePattern}` : ''}`); 129 | 130 | // Build the query 131 | let query = ` 132 | SELECT Id, Name${args.includeMetadata ? ', ApiVersion, LengthWithoutComments, Status, IsValid, LastModifiedDate' : ''} 133 | FROM ApexClass 134 | `; 135 | 136 | // Add name pattern filter if provided 137 | if (args.namePattern) { 138 | const likePattern = wildcardToLikePattern(args.namePattern); 139 | query += ` WHERE Name LIKE '${likePattern}'`; 140 | } 141 | 142 | // Order by name 143 | query += ` ORDER BY Name`; 144 | 145 | const result = await conn.query(query); 146 | 147 | if (result.records.length === 0) { 148 | return { 149 | content: [{ 150 | type: "text", 151 | text: `No Apex classes found${args.namePattern ? ` matching: ${args.namePattern}` : ''}` 152 | }] 153 | }; 154 | } 155 | 156 | // Format the response as a list of classes 157 | let responseText = `# Found ${result.records.length} Apex Classes\n\n`; 158 | 159 | if (args.includeMetadata) { 160 | // Table format with metadata 161 | responseText += "| Name | API Version | Length | Status | Valid | Last Modified |\n"; 162 | responseText += "|------|------------|--------|--------|-------|---------------|\n"; 163 | 164 | for (const cls of result.records) { 165 | responseText += `| ${cls.Name} | ${cls.ApiVersion} | ${cls.LengthWithoutComments} | ${cls.Status} | ${cls.IsValid ? 'Yes' : 'No'} | ${new Date(cls.LastModifiedDate).toLocaleString()} |\n`; 166 | } 167 | } else { 168 | // Simple list format 169 | for (const cls of result.records) { 170 | responseText += `- ${cls.Name}\n`; 171 | } 172 | } 173 | 174 | return { 175 | content: [{ type: "text", text: responseText }] 176 | }; 177 | } 178 | } catch (error) { 179 | console.error('Error reading Apex classes:', error); 180 | return { 181 | content: [{ 182 | type: "text", 183 | text: `Error reading Apex classes: ${error instanceof Error ? error.message : String(error)}` 184 | }], 185 | isError: true, 186 | }; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/tools/readApexTrigger.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | export const READ_APEX_TRIGGER: Tool = { 4 | name: "salesforce_read_apex_trigger", 5 | description: `Read Apex triggers from Salesforce. 6 | 7 | Examples: 8 | 1. Read a specific Apex trigger by name: 9 | { 10 | "triggerName": "AccountTrigger" 11 | } 12 | 13 | 2. List all Apex triggers with an optional name pattern: 14 | { 15 | "namePattern": "Account" 16 | } 17 | 18 | 3. Get metadata about Apex triggers: 19 | { 20 | "includeMetadata": true, 21 | "namePattern": "Contact" 22 | } 23 | 24 | 4. Use wildcards in name patterns: 25 | { 26 | "namePattern": "Account*" 27 | } 28 | 29 | Notes: 30 | - When triggerName is provided, the full body of that specific trigger is returned 31 | - When namePattern is provided, all matching trigger names are returned (without body) 32 | - Use includeMetadata to get additional information like API version, object type, and last modified date 33 | - If neither triggerName nor namePattern is provided, all Apex trigger names will be listed 34 | - Wildcards are supported in namePattern: * (matches any characters) and ? (matches a single character)`, 35 | inputSchema: { 36 | type: "object", 37 | properties: { 38 | triggerName: { 39 | type: "string", 40 | description: "Name of a specific Apex trigger to read" 41 | }, 42 | namePattern: { 43 | type: "string", 44 | description: "Pattern to match Apex trigger names (supports wildcards * and ?)" 45 | }, 46 | includeMetadata: { 47 | type: "boolean", 48 | description: "Whether to include metadata about the Apex triggers" 49 | } 50 | } 51 | } 52 | }; 53 | 54 | export interface ReadApexTriggerArgs { 55 | triggerName?: string; 56 | namePattern?: string; 57 | includeMetadata?: boolean; 58 | } 59 | 60 | /** 61 | * Converts a wildcard pattern to a SQL LIKE pattern 62 | * @param pattern Pattern with * and ? wildcards 63 | * @returns SQL LIKE compatible pattern 64 | */ 65 | function wildcardToLikePattern(pattern: string): string { 66 | if (!pattern.includes('*') && !pattern.includes('?')) { 67 | // If no wildcards, wrap with % for substring match 68 | return `%${pattern}%`; 69 | } 70 | 71 | // Replace * with % and ? with _ for SQL LIKE 72 | let likePattern = pattern.replace(/\*/g, '%').replace(/\?/g, '_'); 73 | 74 | return likePattern; 75 | } 76 | 77 | /** 78 | * Handles reading Apex triggers from Salesforce 79 | * @param conn Active Salesforce connection 80 | * @param args Arguments for reading Apex triggers 81 | * @returns Tool response with Apex trigger information 82 | */ 83 | export async function handleReadApexTrigger(conn: any, args: ReadApexTriggerArgs) { 84 | try { 85 | // If a specific trigger name is provided, get the full trigger body 86 | if (args.triggerName) { 87 | console.error(`Reading Apex trigger: ${args.triggerName}`); 88 | 89 | // Query the ApexTrigger object to get the trigger body 90 | const result = await conn.query(` 91 | SELECT Id, Name, Body, ApiVersion, TableEnumOrId, Status, 92 | IsValid, LastModifiedDate, LastModifiedById 93 | FROM ApexTrigger 94 | WHERE Name = '${args.triggerName}' 95 | `); 96 | 97 | if (result.records.length === 0) { 98 | return { 99 | content: [{ 100 | type: "text", 101 | text: `No Apex trigger found with name: ${args.triggerName}` 102 | }], 103 | isError: true, 104 | }; 105 | } 106 | 107 | const apexTrigger = result.records[0]; 108 | 109 | // Format the response with the trigger body and metadata 110 | return { 111 | content: [ 112 | { 113 | type: "text", 114 | text: `# Apex Trigger: ${apexTrigger.Name}\n\n` + 115 | (args.includeMetadata ? 116 | `**API Version:** ${apexTrigger.ApiVersion}\n` + 117 | `**Object:** ${apexTrigger.TableEnumOrId}\n` + 118 | `**Status:** ${apexTrigger.Status}\n` + 119 | `**Valid:** ${apexTrigger.IsValid ? 'Yes' : 'No'}\n` + 120 | `**Last Modified:** ${new Date(apexTrigger.LastModifiedDate).toLocaleString()}\n\n` : '') + 121 | "```apex\n" + apexTrigger.Body + "\n```" 122 | } 123 | ] 124 | }; 125 | } 126 | // Otherwise, list triggers matching the pattern 127 | else { 128 | console.error(`Listing Apex triggers${args.namePattern ? ` matching: ${args.namePattern}` : ''}`); 129 | 130 | // Build the query 131 | let query = ` 132 | SELECT Id, Name${args.includeMetadata ? ', ApiVersion, TableEnumOrId, Status, IsValid, LastModifiedDate' : ''} 133 | FROM ApexTrigger 134 | `; 135 | 136 | // Add name pattern filter if provided 137 | if (args.namePattern) { 138 | const likePattern = wildcardToLikePattern(args.namePattern); 139 | query += ` WHERE Name LIKE '${likePattern}'`; 140 | } 141 | 142 | // Order by name 143 | query += ` ORDER BY Name`; 144 | 145 | const result = await conn.query(query); 146 | 147 | if (result.records.length === 0) { 148 | return { 149 | content: [{ 150 | type: "text", 151 | text: `No Apex triggers found${args.namePattern ? ` matching: ${args.namePattern}` : ''}` 152 | }] 153 | }; 154 | } 155 | 156 | // Format the response as a list of triggers 157 | let responseText = `# Found ${result.records.length} Apex Triggers\n\n`; 158 | 159 | if (args.includeMetadata) { 160 | // Table format with metadata 161 | responseText += "| Name | API Version | Object | Status | Valid | Last Modified |\n"; 162 | responseText += "|------|------------|--------|--------|-------|---------------|\n"; 163 | 164 | for (const trigger of result.records) { 165 | responseText += `| ${trigger.Name} | ${trigger.ApiVersion} | ${trigger.TableEnumOrId} | ${trigger.Status} | ${trigger.IsValid ? 'Yes' : 'No'} | ${new Date(trigger.LastModifiedDate).toLocaleString()} |\n`; 166 | } 167 | } else { 168 | // Simple list format 169 | for (const trigger of result.records) { 170 | responseText += `- ${trigger.Name}\n`; 171 | } 172 | } 173 | 174 | return { 175 | content: [{ type: "text", text: responseText }] 176 | }; 177 | } 178 | } catch (error) { 179 | console.error('Error reading Apex triggers:', error); 180 | return { 181 | content: [{ 182 | type: "text", 183 | text: `Error reading Apex triggers: ${error instanceof Error ? error.message : String(error)}` 184 | }], 185 | isError: true, 186 | }; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/tools/search.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import { SalesforceObject } from "../types/salesforce"; 3 | 4 | export const SEARCH_OBJECTS: Tool = { 5 | name: "salesforce_search_objects", 6 | description: "Search for Salesforce standard and custom objects by name pattern. Examples: 'Account' will find Account, AccountHistory; 'Order' will find WorkOrder, ServiceOrder__c etc.", 7 | inputSchema: { 8 | type: "object", 9 | properties: { 10 | searchPattern: { 11 | type: "string", 12 | description: "Search pattern to find objects (e.g., 'Account Coverage' will find objects like 'AccountCoverage__c')" 13 | } 14 | }, 15 | required: ["searchPattern"] 16 | } 17 | }; 18 | 19 | export async function handleSearchObjects(conn: any, searchPattern: string) { 20 | // Get list of all objects 21 | const describeGlobal = await conn.describeGlobal(); 22 | 23 | // Process search pattern to create a more flexible search 24 | const searchTerms = searchPattern.toLowerCase().split(' ').filter(term => term.length > 0); 25 | 26 | // Filter objects based on search pattern 27 | const matchingObjects = describeGlobal.sobjects.filter((obj: SalesforceObject) => { 28 | const objectName = obj.name.toLowerCase(); 29 | const objectLabel = obj.label.toLowerCase(); 30 | 31 | // Check if all search terms are present in either the API name or label 32 | return searchTerms.every(term => 33 | objectName.includes(term) || objectLabel.includes(term) 34 | ); 35 | }); 36 | 37 | if (matchingObjects.length === 0) { 38 | return { 39 | content: [{ 40 | type: "text", 41 | text: `No Salesforce objects found matching "${searchPattern}".` 42 | }], 43 | isError: false, 44 | }; 45 | } 46 | 47 | // Format the output 48 | const formattedResults = matchingObjects.map((obj: SalesforceObject) => 49 | `${obj.name}${obj.custom ? ' (Custom)' : ''}\n Label: ${obj.label}` 50 | ).join('\n\n'); 51 | 52 | return { 53 | content: [{ 54 | type: "text", 55 | text: `Found ${matchingObjects.length} matching objects:\n\n${formattedResults}` 56 | }], 57 | isError: false, 58 | }; 59 | } -------------------------------------------------------------------------------- /src/tools/searchAll.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | export const SEARCH_ALL: Tool = { 4 | name: "salesforce_search_all", 5 | description: `Search across multiple Salesforce objects using SOSL (Salesforce Object Search Language). 6 | 7 | Examples: 8 | 1. Basic search across all objects: 9 | { 10 | "searchTerm": "John", 11 | "objects": [ 12 | { "name": "Account", "fields": ["Name"], "limit": 10 }, 13 | { "name": "Contact", "fields": ["FirstName", "LastName", "Email"] } 14 | ] 15 | } 16 | 17 | 2. Advanced search with filters: 18 | { 19 | "searchTerm": "Cloud*", 20 | "searchIn": "NAME FIELDS", 21 | "objects": [ 22 | { 23 | "name": "Account", 24 | "fields": ["Name", "Industry"], 25 | "orderBy": "Name DESC", 26 | "where": "Industry = 'Technology'" 27 | } 28 | ], 29 | "withClauses": [ 30 | { "type": "NETWORK", "value": "ALL NETWORKS" }, 31 | { "type": "SNIPPET", "fields": ["Description"] } 32 | ] 33 | } 34 | 35 | Notes: 36 | - Use * and ? for wildcards in search terms 37 | - Each object can have its own WHERE, ORDER BY, and LIMIT clauses 38 | - Support for WITH clauses: DATA CATEGORY, DIVISION, METADATA, NETWORK, PRICEBOOKID, SNIPPET, SECURITY_ENFORCED 39 | - "updateable" and "viewable" options control record access filtering`, 40 | inputSchema: { 41 | type: "object", 42 | properties: { 43 | searchTerm: { 44 | type: "string", 45 | description: "Text to search for (supports wildcards * and ?)" 46 | }, 47 | searchIn: { 48 | type: "string", 49 | enum: ["ALL FIELDS", "NAME FIELDS", "EMAIL FIELDS", "PHONE FIELDS", "SIDEBAR FIELDS"], 50 | description: "Which fields to search in", 51 | optional: true 52 | }, 53 | objects: { 54 | type: "array", 55 | items: { 56 | type: "object", 57 | properties: { 58 | name: { 59 | type: "string", 60 | description: "API name of the object" 61 | }, 62 | fields: { 63 | type: "array", 64 | items: { type: "string" }, 65 | description: "Fields to return for this object" 66 | }, 67 | where: { 68 | type: "string", 69 | description: "WHERE clause for this object", 70 | optional: true 71 | }, 72 | orderBy: { 73 | type: "string", 74 | description: "ORDER BY clause for this object", 75 | optional: true 76 | }, 77 | limit: { 78 | type: "number", 79 | description: "Maximum number of records to return for this object", 80 | optional: true 81 | } 82 | }, 83 | required: ["name", "fields"] 84 | }, 85 | description: "List of objects to search and their return fields" 86 | }, 87 | withClauses: { 88 | type: "array", 89 | items: { 90 | type: "object", 91 | properties: { 92 | type: { 93 | type: "string", 94 | enum: ["DATA CATEGORY", "DIVISION", "METADATA", "NETWORK", 95 | "PRICEBOOKID", "SNIPPET", "SECURITY_ENFORCED"] 96 | }, 97 | value: { 98 | type: "string", 99 | description: "Value for the WITH clause", 100 | optional: true 101 | }, 102 | fields: { 103 | type: "array", 104 | items: { type: "string" }, 105 | description: "Fields for SNIPPET clause", 106 | optional: true 107 | } 108 | }, 109 | required: ["type"] 110 | }, 111 | description: "Additional WITH clauses for the search", 112 | optional: true 113 | }, 114 | updateable: { 115 | type: "boolean", 116 | description: "Return only updateable records", 117 | optional: true 118 | }, 119 | viewable: { 120 | type: "boolean", 121 | description: "Return only viewable records", 122 | optional: true 123 | } 124 | }, 125 | required: ["searchTerm", "objects"] 126 | } 127 | }; 128 | 129 | export interface SearchObject { 130 | name: string; 131 | fields: string[]; 132 | where?: string; 133 | orderBy?: string; 134 | limit?: number; 135 | } 136 | 137 | export interface WithClause { 138 | type: "DATA CATEGORY" | "DIVISION" | "METADATA" | "NETWORK" | 139 | "PRICEBOOKID" | "SNIPPET" | "SECURITY_ENFORCED"; 140 | value?: string; 141 | fields?: string[]; 142 | } 143 | 144 | export interface SearchAllArgs { 145 | searchTerm: string; 146 | searchIn?: "ALL FIELDS" | "NAME FIELDS" | "EMAIL FIELDS" | "PHONE FIELDS" | "SIDEBAR FIELDS"; 147 | objects: SearchObject[]; 148 | withClauses?: WithClause[]; 149 | updateable?: boolean; 150 | viewable?: boolean; 151 | } 152 | 153 | function buildWithClause(withClause: WithClause): string { 154 | switch (withClause.type) { 155 | case "SNIPPET": 156 | return `WITH SNIPPET (${withClause.fields?.join(', ')})`; 157 | case "DATA CATEGORY": 158 | case "DIVISION": 159 | case "NETWORK": 160 | case "PRICEBOOKID": 161 | return `WITH ${withClause.type} = ${withClause.value}`; 162 | case "METADATA": 163 | case "SECURITY_ENFORCED": 164 | return `WITH ${withClause.type}`; 165 | default: 166 | return ''; 167 | } 168 | } 169 | 170 | export async function handleSearchAll(conn: any, args: SearchAllArgs) { 171 | const { searchTerm, searchIn = "ALL FIELDS", objects, withClauses, updateable, viewable } = args; 172 | 173 | try { 174 | // Validate the search term 175 | if (!searchTerm.trim()) { 176 | throw new Error('Search term cannot be empty'); 177 | } 178 | 179 | // Construct the RETURNING clause with object-specific clauses 180 | const returningClause = objects 181 | .map(obj => { 182 | let clause = `${obj.name}(${obj.fields.join(',')}` 183 | 184 | // Add object-specific clauses if present 185 | if (obj.where) clause += ` WHERE ${obj.where}`; 186 | if (obj.orderBy) clause += ` ORDER BY ${obj.orderBy}`; 187 | if (obj.limit) clause += ` LIMIT ${obj.limit}`; 188 | 189 | return clause + ')'; 190 | }) 191 | .join(', '); 192 | 193 | // Build WITH clauses if present 194 | const withClausesStr = withClauses 195 | ? withClauses.map(buildWithClause).join(' ') 196 | : ''; 197 | 198 | // Add updateable/viewable flags if specified 199 | const accessFlags = []; 200 | if (updateable) accessFlags.push('UPDATEABLE'); 201 | if (viewable) accessFlags.push('VIEWABLE'); 202 | const accessClause = accessFlags.length > 0 ? 203 | ` RETURNING ${accessFlags.join(',')}` : ''; 204 | 205 | // Construct complete SOSL query 206 | const soslQuery = `FIND {${searchTerm}} IN ${searchIn} 207 | ${withClausesStr} 208 | RETURNING ${returningClause} 209 | ${accessClause}`.trim(); 210 | 211 | // Execute search 212 | const result = await conn.search(soslQuery); 213 | 214 | // Format results by object 215 | let formattedResults = ''; 216 | objects.forEach((obj, index) => { 217 | const objectResults = result.searchRecords.filter((record: any) => 218 | record.attributes.type === obj.name 219 | ); 220 | 221 | formattedResults += `\n${obj.name} (${objectResults.length} records found):\n`; 222 | 223 | if (objectResults.length > 0) { 224 | objectResults.forEach((record: any, recordIndex: number) => { 225 | formattedResults += ` Record ${recordIndex + 1}:\n`; 226 | obj.fields.forEach(field => { 227 | const value = record[field]; 228 | formattedResults += ` ${field}: ${value !== null && value !== undefined ? value : 'null'}\n`; 229 | }); 230 | // Add metadata or snippet info if requested 231 | if (withClauses?.some(w => w.type === "METADATA")) { 232 | formattedResults += ` Metadata:\n Last Modified: ${record.attributes.lastModifiedDate}\n`; 233 | } 234 | if (withClauses?.some(w => w.type === "SNIPPET")) { 235 | formattedResults += ` Snippets:\n${record.snippets?.map((s: any) => 236 | ` ${s.field}: ${s.snippet}`).join('\n') || ' None'}\n`; 237 | } 238 | }); 239 | } 240 | 241 | if (index < objects.length - 1) { 242 | formattedResults += '\n'; 243 | } 244 | }); 245 | 246 | return { 247 | content: [{ 248 | type: "text", 249 | text: `Search Results:${formattedResults}` 250 | }], 251 | isError: false, 252 | }; 253 | } catch (error) { 254 | // Enhanced error handling for SOSL queries 255 | const errorMessage = error instanceof Error ? error.message : String(error); 256 | let enhancedError = errorMessage; 257 | 258 | if (errorMessage.includes('MALFORMED_SEARCH')) { 259 | enhancedError = `Invalid search query format. Common issues:\n` + 260 | `1. Search term contains invalid characters\n` + 261 | `2. Object or field names are incorrect\n` + 262 | `3. Missing required SOSL syntax elements\n` + 263 | `4. Invalid WITH clause combination\n\n` + 264 | `Original error: ${errorMessage}`; 265 | } else if (errorMessage.includes('INVALID_FIELD')) { 266 | enhancedError = `Invalid field specified in RETURNING clause. Please check:\n` + 267 | `1. Field names are correct\n` + 268 | `2. Fields exist on the specified objects\n` + 269 | `3. You have access to all specified fields\n` + 270 | `4. WITH SNIPPET fields are valid\n\n` + 271 | `Original error: ${errorMessage}`; 272 | } else if (errorMessage.includes('WITH_CLAUSE')) { 273 | enhancedError = `Error in WITH clause. Please check:\n` + 274 | `1. WITH clause type is supported\n` + 275 | `2. WITH clause value is valid\n` + 276 | `3. You have permission to use the specified WITH clause\n\n` + 277 | `Original error: ${errorMessage}`; 278 | } 279 | 280 | return { 281 | content: [{ 282 | type: "text", 283 | text: `Error executing search: ${enhancedError}` 284 | }], 285 | isError: true, 286 | }; 287 | } 288 | } -------------------------------------------------------------------------------- /src/tools/writeApex.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | import type { Connection } from "jsforce"; 3 | 4 | export const WRITE_APEX: Tool = { 5 | name: "salesforce_write_apex", 6 | description: `Create or update Apex classes in Salesforce. 7 | 8 | Examples: 9 | 1. Create a new Apex class: 10 | { 11 | "operation": "create", 12 | "className": "AccountService", 13 | "apiVersion": "58.0", 14 | "body": "public class AccountService { public static void updateAccounts() { /* implementation */ } }" 15 | } 16 | 17 | 2. Update an existing Apex class: 18 | { 19 | "operation": "update", 20 | "className": "AccountService", 21 | "body": "public class AccountService { public static void updateAccounts() { /* updated implementation */ } }" 22 | } 23 | 24 | Notes: 25 | - The operation must be either 'create' or 'update' 26 | - For 'create' operations, className and body are required 27 | - For 'update' operations, className and body are required 28 | - apiVersion is optional for 'create' (defaults to the latest version) 29 | - The body must be valid Apex code 30 | - The className in the body must match the className parameter 31 | - Status information is returned after successful operations`, 32 | inputSchema: { 33 | type: "object", 34 | properties: { 35 | operation: { 36 | type: "string", 37 | enum: ["create", "update"], 38 | description: "Whether to create a new class or update an existing one" 39 | }, 40 | className: { 41 | type: "string", 42 | description: "Name of the Apex class to create or update" 43 | }, 44 | apiVersion: { 45 | type: "string", 46 | description: "API version for the Apex class (e.g., '58.0')" 47 | }, 48 | body: { 49 | type: "string", 50 | description: "Full body of the Apex class" 51 | } 52 | }, 53 | required: ["operation", "className", "body"] 54 | } 55 | }; 56 | 57 | export interface WriteApexArgs { 58 | operation: 'create' | 'update'; 59 | className: string; 60 | apiVersion?: string; 61 | body: string; 62 | } 63 | 64 | /** 65 | * Handles creating or updating Apex classes in Salesforce 66 | * @param conn Active Salesforce connection 67 | * @param args Arguments for writing Apex classes 68 | * @returns Tool response with operation result 69 | */ 70 | export async function handleWriteApex(conn: any, args: WriteApexArgs) { 71 | try { 72 | // Validate inputs 73 | if (!args.className) { 74 | throw new Error('className is required'); 75 | } 76 | 77 | if (!args.body) { 78 | throw new Error('body is required'); 79 | } 80 | 81 | // Check if the class name in the body matches the provided className 82 | const classNameRegex = new RegExp(`\\b(class|interface|enum)\\s+${args.className}\\b`); 83 | if (!classNameRegex.test(args.body)) { 84 | throw new Error(`The class name in the body must match the provided className: ${args.className}`); 85 | } 86 | 87 | // Handle create operation 88 | if (args.operation === 'create') { 89 | console.error(`Creating new Apex class: ${args.className}`); 90 | 91 | // Check if class already exists 92 | const existingClass = await conn.query(` 93 | SELECT Id FROM ApexClass WHERE Name = '${args.className}' 94 | `); 95 | 96 | if (existingClass.records.length > 0) { 97 | throw new Error(`Apex class with name '${args.className}' already exists. Use 'update' operation instead.`); 98 | } 99 | 100 | // Create the new class using the Tooling API 101 | const createResult = await conn.tooling.sobject('ApexClass').create({ 102 | Name: args.className, 103 | Body: args.body, 104 | ApiVersion: args.apiVersion || '58.0', // Default to latest if not specified 105 | Status: 'Active' 106 | }); 107 | 108 | if (!createResult.success) { 109 | throw new Error(`Failed to create Apex class: ${createResult.errors.join(', ')}`); 110 | } 111 | 112 | return { 113 | content: [{ 114 | type: "text", 115 | text: `Successfully created Apex class: ${args.className}\n\n` + 116 | `**ID:** ${createResult.id}\n` + 117 | `**API Version:** ${args.apiVersion || '58.0'}\n` + 118 | `**Status:** Active` 119 | }] 120 | }; 121 | } 122 | // Handle update operation 123 | else if (args.operation === 'update') { 124 | console.error(`Updating Apex class: ${args.className}`); 125 | 126 | // Find the existing class 127 | const existingClass = await conn.query(` 128 | SELECT Id FROM ApexClass WHERE Name = '${args.className}' 129 | `); 130 | 131 | if (existingClass.records.length === 0) { 132 | throw new Error(`No Apex class found with name: ${args.className}. Use 'create' operation instead.`); 133 | } 134 | 135 | const classId = existingClass.records[0].Id; 136 | 137 | // Update the class using the Tooling API 138 | const updateResult = await conn.tooling.sobject('ApexClass').update({ 139 | Id: classId, 140 | Body: args.body 141 | }); 142 | 143 | if (!updateResult.success) { 144 | throw new Error(`Failed to update Apex class: ${updateResult.errors.join(', ')}`); 145 | } 146 | 147 | // Get the updated class details 148 | const updatedClass = await conn.query(` 149 | SELECT Id, Name, ApiVersion, Status, LastModifiedDate 150 | FROM ApexClass 151 | WHERE Id = '${classId}' 152 | `); 153 | 154 | const classDetails = updatedClass.records[0]; 155 | 156 | return { 157 | content: [{ 158 | type: "text", 159 | text: `Successfully updated Apex class: ${args.className}\n\n` + 160 | `**ID:** ${classId}\n` + 161 | `**API Version:** ${classDetails.ApiVersion}\n` + 162 | `**Status:** ${classDetails.Status}\n` + 163 | `**Last Modified:** ${new Date(classDetails.LastModifiedDate).toLocaleString()}` 164 | }] 165 | }; 166 | } else { 167 | throw new Error(`Invalid operation: ${args.operation}. Must be 'create' or 'update'.`); 168 | } 169 | } catch (error) { 170 | console.error('Error writing Apex class:', error); 171 | return { 172 | content: [{ 173 | type: "text", 174 | text: `Error writing Apex class: ${error instanceof Error ? error.message : String(error)}` 175 | }], 176 | isError: true, 177 | }; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/tools/writeApexTrigger.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from "@modelcontextprotocol/sdk/types.js"; 2 | 3 | export const WRITE_APEX_TRIGGER: Tool = { 4 | name: "salesforce_write_apex_trigger", 5 | description: `Create or update Apex triggers in Salesforce. 6 | 7 | Examples: 8 | 1. Create a new Apex trigger: 9 | { 10 | "operation": "create", 11 | "triggerName": "AccountTrigger", 12 | "objectName": "Account", 13 | "apiVersion": "58.0", 14 | "body": "trigger AccountTrigger on Account (before insert, before update) { /* implementation */ }" 15 | } 16 | 17 | 2. Update an existing Apex trigger: 18 | { 19 | "operation": "update", 20 | "triggerName": "AccountTrigger", 21 | "body": "trigger AccountTrigger on Account (before insert, before update, after update) { /* updated implementation */ }" 22 | } 23 | 24 | Notes: 25 | - The operation must be either 'create' or 'update' 26 | - For 'create' operations, triggerName, objectName, and body are required 27 | - For 'update' operations, triggerName and body are required 28 | - apiVersion is optional for 'create' (defaults to the latest version) 29 | - The body must be valid Apex trigger code 30 | - The triggerName in the body must match the triggerName parameter 31 | - The objectName in the body must match the objectName parameter (for 'create') 32 | - Status information is returned after successful operations`, 33 | inputSchema: { 34 | type: "object", 35 | properties: { 36 | operation: { 37 | type: "string", 38 | enum: ["create", "update"], 39 | description: "Whether to create a new trigger or update an existing one" 40 | }, 41 | triggerName: { 42 | type: "string", 43 | description: "Name of the Apex trigger to create or update" 44 | }, 45 | objectName: { 46 | type: "string", 47 | description: "Name of the Salesforce object the trigger is for (required for 'create')" 48 | }, 49 | apiVersion: { 50 | type: "string", 51 | description: "API version for the Apex trigger (e.g., '58.0')" 52 | }, 53 | body: { 54 | type: "string", 55 | description: "Full body of the Apex trigger" 56 | } 57 | }, 58 | required: ["operation", "triggerName", "body"] 59 | } 60 | }; 61 | 62 | export interface WriteApexTriggerArgs { 63 | operation: 'create' | 'update'; 64 | triggerName: string; 65 | objectName?: string; 66 | apiVersion?: string; 67 | body: string; 68 | } 69 | 70 | /** 71 | * Handles creating or updating Apex triggers in Salesforce 72 | * @param conn Active Salesforce connection 73 | * @param args Arguments for writing Apex triggers 74 | * @returns Tool response with operation result 75 | */ 76 | export async function handleWriteApexTrigger(conn: any, args: WriteApexTriggerArgs) { 77 | try { 78 | // Validate inputs 79 | if (!args.triggerName) { 80 | throw new Error('triggerName is required'); 81 | } 82 | 83 | if (!args.body) { 84 | throw new Error('body is required'); 85 | } 86 | 87 | // Check if the trigger name in the body matches the provided triggerName 88 | const triggerNameRegex = new RegExp(`\\btrigger\\s+${args.triggerName}\\b`); 89 | if (!triggerNameRegex.test(args.body)) { 90 | throw new Error(`The trigger name in the body must match the provided triggerName: ${args.triggerName}`); 91 | } 92 | 93 | // Handle create operation 94 | if (args.operation === 'create') { 95 | console.error(`Creating new Apex trigger: ${args.triggerName}`); 96 | 97 | // Validate object name for create operation 98 | if (!args.objectName) { 99 | throw new Error('objectName is required for creating a new trigger'); 100 | } 101 | 102 | // Check if the object name in the body matches the provided objectName 103 | const objectNameRegex = new RegExp(`\\bon\\s+${args.objectName}\\b`); 104 | if (!objectNameRegex.test(args.body)) { 105 | throw new Error(`The object name in the body must match the provided objectName: ${args.objectName}`); 106 | } 107 | 108 | // Check if trigger already exists 109 | const existingTrigger = await conn.query(` 110 | SELECT Id FROM ApexTrigger WHERE Name = '${args.triggerName}' 111 | `); 112 | 113 | if (existingTrigger.records.length > 0) { 114 | throw new Error(`Apex trigger with name '${args.triggerName}' already exists. Use 'update' operation instead.`); 115 | } 116 | 117 | // Create the new trigger using the Tooling API 118 | const createResult = await conn.tooling.sobject('ApexTrigger').create({ 119 | Name: args.triggerName, 120 | TableEnumOrId: args.objectName, 121 | Body: args.body, 122 | ApiVersion: args.apiVersion || '58.0', // Default to latest if not specified 123 | Status: 'Active' 124 | }); 125 | 126 | if (!createResult.success) { 127 | throw new Error(`Failed to create Apex trigger: ${createResult.errors.join(', ')}`); 128 | } 129 | 130 | return { 131 | content: [{ 132 | type: "text", 133 | text: `Successfully created Apex trigger: ${args.triggerName}\n\n` + 134 | `**ID:** ${createResult.id}\n` + 135 | `**Object:** ${args.objectName}\n` + 136 | `**API Version:** ${args.apiVersion || '58.0'}\n` + 137 | `**Status:** Active` 138 | }] 139 | }; 140 | } 141 | // Handle update operation 142 | else if (args.operation === 'update') { 143 | console.error(`Updating Apex trigger: ${args.triggerName}`); 144 | 145 | // Find the existing trigger 146 | const existingTrigger = await conn.query(` 147 | SELECT Id, TableEnumOrId FROM ApexTrigger WHERE Name = '${args.triggerName}' 148 | `); 149 | 150 | if (existingTrigger.records.length === 0) { 151 | throw new Error(`No Apex trigger found with name: ${args.triggerName}. Use 'create' operation instead.`); 152 | } 153 | 154 | const triggerId = existingTrigger.records[0].Id; 155 | const objectName = existingTrigger.records[0].TableEnumOrId; 156 | 157 | // Check if the object name in the body matches the existing object 158 | const objectNameRegex = new RegExp(`\\bon\\s+${objectName}\\b`); 159 | if (!objectNameRegex.test(args.body)) { 160 | throw new Error(`The object name in the body must match the existing object: ${objectName}`); 161 | } 162 | 163 | // Update the trigger using the Tooling API 164 | const updateResult = await conn.tooling.sobject('ApexTrigger').update({ 165 | Id: triggerId, 166 | Body: args.body 167 | }); 168 | 169 | if (!updateResult.success) { 170 | throw new Error(`Failed to update Apex trigger: ${updateResult.errors.join(', ')}`); 171 | } 172 | 173 | // Get the updated trigger details 174 | const updatedTrigger = await conn.query(` 175 | SELECT Id, Name, TableEnumOrId, ApiVersion, Status, LastModifiedDate 176 | FROM ApexTrigger 177 | WHERE Id = '${triggerId}' 178 | `); 179 | 180 | const triggerDetails = updatedTrigger.records[0]; 181 | 182 | return { 183 | content: [{ 184 | type: "text", 185 | text: `Successfully updated Apex trigger: ${args.triggerName}\n\n` + 186 | `**ID:** ${triggerId}\n` + 187 | `**Object:** ${triggerDetails.TableEnumOrId}\n` + 188 | `**API Version:** ${triggerDetails.ApiVersion}\n` + 189 | `**Status:** ${triggerDetails.Status}\n` + 190 | `**Last Modified:** ${new Date(triggerDetails.LastModifiedDate).toLocaleString()}` 191 | }] 192 | }; 193 | } else { 194 | throw new Error(`Invalid operation: ${args.operation}. Must be 'create' or 'update'.`); 195 | } 196 | } catch (error) { 197 | console.error('Error writing Apex trigger:', error); 198 | return { 199 | content: [{ 200 | type: "text", 201 | text: `Error writing Apex trigger: ${error instanceof Error ? error.message : String(error)}` 202 | }], 203 | isError: true, 204 | }; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/types/connection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enum representing the available Salesforce connection types 3 | */ 4 | export enum ConnectionType { 5 | /** 6 | * Standard username/password authentication with security token 7 | * Requires SALESFORCE_USERNAME, SALESFORCE_PASSWORD, and optionally SALESFORCE_TOKEN 8 | */ 9 | User_Password = 'User_Password', 10 | 11 | /** 12 | * OAuth 2.0 Client Credentials Flow using client ID and secret 13 | * Requires SALESFORCE_CLIENT_ID and SALESFORCE_CLIENT_SECRET 14 | */ 15 | OAuth_2_0_Client_Credentials = 'OAuth_2.0_Client_Credentials' 16 | } 17 | 18 | /** 19 | * Configuration options for Salesforce connection 20 | */ 21 | export interface ConnectionConfig { 22 | /** 23 | * The type of connection to use 24 | * @default ConnectionType.User_Password 25 | */ 26 | type?: ConnectionType; 27 | 28 | /** 29 | * The login URL for Salesforce instance 30 | * @default 'https://login.salesforce.com' 31 | */ 32 | loginUrl?: string; 33 | } 34 | -------------------------------------------------------------------------------- /src/types/metadata.ts: -------------------------------------------------------------------------------- 1 | export interface MetadataInfo { 2 | fullName: string; 3 | label: string; 4 | pluralLabel?: string; 5 | nameField?: { 6 | type: string; 7 | label: string; 8 | displayFormat?: string; 9 | }; 10 | deploymentStatus?: 'Deployed' | 'InDevelopment'; 11 | sharingModel?: 'ReadWrite' | 'Read' | 'Private' | 'ControlledByParent'; 12 | enableActivities?: boolean; 13 | description?: string; 14 | } 15 | 16 | export interface ValueSetDefinition { 17 | sorted?: boolean; 18 | value: Array<{ 19 | fullName: string; 20 | default?: boolean; 21 | label: string; 22 | }>; 23 | } 24 | 25 | export interface FieldMetadataInfo { 26 | fullName: string; 27 | label: string; 28 | type: string; 29 | required?: boolean; 30 | unique?: boolean; 31 | externalId?: boolean; 32 | length?: number; 33 | precision?: number; 34 | scale?: number; 35 | visibleLines?: number; 36 | referenceTo?: string; 37 | relationshipLabel?: string; 38 | relationshipName?: string; 39 | deleteConstraint?: 'Cascade' | 'Restrict' | 'SetNull'; 40 | valueSet?: { 41 | valueSetDefinition: ValueSetDefinition; 42 | }; 43 | defaultValue?: string | number | boolean; 44 | description?: string; 45 | } -------------------------------------------------------------------------------- /src/types/salesforce.ts: -------------------------------------------------------------------------------- 1 | export interface SalesforceObject { 2 | name: string; 3 | label: string; 4 | custom: boolean; 5 | } 6 | 7 | export interface SalesforceField { 8 | name: string; 9 | label: string; 10 | type: string; 11 | nillable: boolean; 12 | length?: number; 13 | picklistValues: Array<{ value: string }>; 14 | defaultValue: string | null; 15 | referenceTo: string[]; 16 | } 17 | 18 | export interface SalesforceDescribeResponse { 19 | name: string; 20 | label: string; 21 | fields: SalesforceField[]; 22 | custom: boolean; 23 | } 24 | 25 | export interface SalesforceError { 26 | statusCode: string; 27 | message: string; 28 | fields?: string[]; 29 | } 30 | 31 | export interface DMLResult { 32 | success: boolean; 33 | id?: string; 34 | errors?: SalesforceError[] | SalesforceError; 35 | } -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'jsforce'; 2 | -------------------------------------------------------------------------------- /src/utils/connection.ts: -------------------------------------------------------------------------------- 1 | import jsforce from 'jsforce'; 2 | import { ConnectionType, ConnectionConfig } from '../types/connection.js'; 3 | import https from 'https'; 4 | import querystring from 'querystring'; 5 | 6 | /** 7 | * Creates a Salesforce connection using either username/password or OAuth 2.0 Client Credentials Flow 8 | * @param config Optional connection configuration 9 | * @returns Connected jsforce Connection instance 10 | */ 11 | export async function createSalesforceConnection(config?: ConnectionConfig) { 12 | // Determine connection type from environment variables or config 13 | const connectionType = config?.type || 14 | (process.env.SALESFORCE_CONNECTION_TYPE as ConnectionType) || 15 | ConnectionType.User_Password; 16 | 17 | // Set login URL from config or environment variable 18 | const loginUrl = config?.loginUrl || 19 | process.env.SALESFORCE_INSTANCE_URL || 20 | 'https://login.salesforce.com'; 21 | 22 | try { 23 | if (connectionType === ConnectionType.OAuth_2_0_Client_Credentials) { 24 | // OAuth 2.0 Client Credentials Flow 25 | const clientId = process.env.SALESFORCE_CLIENT_ID; 26 | const clientSecret = process.env.SALESFORCE_CLIENT_SECRET; 27 | 28 | if (!clientId || !clientSecret) { 29 | throw new Error('SALESFORCE_CLIENT_ID and SALESFORCE_CLIENT_SECRET are required for OAuth 2.0 Client Credentials Flow'); 30 | } 31 | 32 | console.error('Connecting to Salesforce using OAuth 2.0 Client Credentials Flow'); 33 | 34 | // Get the instance URL from environment variable or config 35 | const instanceUrl = loginUrl; 36 | 37 | // Create the token URL 38 | const tokenUrl = new URL('/services/oauth2/token', instanceUrl); 39 | 40 | // Prepare the request body 41 | const requestBody = querystring.stringify({ 42 | grant_type: 'client_credentials', 43 | client_id: clientId, 44 | client_secret: clientSecret 45 | }); 46 | 47 | // Make the token request 48 | const tokenResponse = await new Promise((resolve, reject) => { 49 | const req = https.request({ 50 | method: 'POST', 51 | hostname: tokenUrl.hostname, 52 | path: tokenUrl.pathname, 53 | headers: { 54 | 'Content-Type': 'application/x-www-form-urlencoded', 55 | 'Content-Length': Buffer.byteLength(requestBody) 56 | } 57 | }, (res) => { 58 | let data = ''; 59 | res.on('data', (chunk) => { 60 | data += chunk; 61 | }); 62 | res.on('end', () => { 63 | try { 64 | const parsedData = JSON.parse(data); 65 | if (res.statusCode !== 200) { 66 | reject(new Error(`OAuth token request failed: ${parsedData.error} - ${parsedData.error_description}`)); 67 | } else { 68 | resolve(parsedData); 69 | } 70 | } catch (e: unknown) { 71 | reject(new Error(`Failed to parse OAuth response: ${e instanceof Error ? e.message : String(e)}`)); 72 | } 73 | }); 74 | }); 75 | 76 | req.on('error', (e) => { 77 | reject(new Error(`OAuth request error: ${e.message}`)); 78 | }); 79 | 80 | req.write(requestBody); 81 | req.end(); 82 | }); 83 | 84 | // Create connection with the access token 85 | const conn = new jsforce.Connection({ 86 | instanceUrl: tokenResponse.instance_url, 87 | accessToken: tokenResponse.access_token 88 | }); 89 | 90 | return conn; 91 | } else { 92 | // Default: Username/Password Flow with Security Token 93 | const username = process.env.SALESFORCE_USERNAME; 94 | const password = process.env.SALESFORCE_PASSWORD; 95 | const token = process.env.SALESFORCE_TOKEN; 96 | 97 | if (!username || !password) { 98 | throw new Error('SALESFORCE_USERNAME and SALESFORCE_PASSWORD are required for Username/Password authentication'); 99 | } 100 | 101 | console.error('Connecting to Salesforce using Username/Password authentication'); 102 | 103 | // Create connection with login URL 104 | const conn = new jsforce.Connection({ loginUrl }); 105 | 106 | await conn.login( 107 | username, 108 | password + (token || '') 109 | ); 110 | 111 | return conn; 112 | } 113 | } catch (error) { 114 | console.error('Error connecting to Salesforce:', error); 115 | throw error; 116 | } 117 | } -------------------------------------------------------------------------------- /src/utils/errorHandler.ts: -------------------------------------------------------------------------------- 1 | interface ErrorResult { 2 | success: boolean; 3 | fullName?: string; 4 | errors?: Array<{ message: string; statusCode?: string; fields?: string | string[]; }> | 5 | { message: string; statusCode?: string; fields?: string | string[]; }; 6 | } 7 | 8 | export function formatMetadataError(result: ErrorResult | ErrorResult[], operation: string): string { 9 | let errorMessage = `Failed to ${operation}`; 10 | const saveResult = Array.isArray(result) ? result[0] : result; 11 | 12 | if (saveResult && saveResult.errors) { 13 | if (Array.isArray(saveResult.errors)) { 14 | errorMessage += ': ' + saveResult.errors.map((e: { message: string }) => e.message).join(', '); 15 | } else if (typeof saveResult.errors === 'object') { 16 | const error = saveResult.errors; 17 | errorMessage += `: ${error.message}`; 18 | if (error.fields) { 19 | errorMessage += ` (Field: ${error.fields})`; 20 | } 21 | if (error.statusCode) { 22 | errorMessage += ` [${error.statusCode}]`; 23 | } 24 | } else { 25 | errorMessage += ': ' + String(saveResult.errors); 26 | } 27 | } 28 | 29 | return errorMessage; 30 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2022", 5 | "moduleResolution": "bundler", 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "declaration": true, 11 | "rootDir": "./src" 12 | }, 13 | "include": ["src/**/*.ts"], 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | --------------------------------------------------------------------------------