├── .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 |
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 |
--------------------------------------------------------------------------------