@aaron-pienza/mcp-server-salesforce 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +392 -0
  5. package/dist/tools/aggregateQuery.d.ts +18 -0
  6. package/dist/tools/aggregateQuery.js +275 -0
  7. package/dist/tools/describe.d.ts +9 -0
  8. package/dist/tools/describe.js +45 -0
  9. package/dist/tools/describeAnalytics.d.ts +13 -0
  10. package/dist/tools/describeAnalytics.js +178 -0
  11. package/dist/tools/dml.d.ts +15 -0
  12. package/dist/tools/dml.js +133 -0
  13. package/dist/tools/executeAnonymous.d.ts +19 -0
  14. package/dist/tools/executeAnonymous.js +139 -0
  15. package/dist/tools/listAnalytics.d.ts +13 -0
  16. package/dist/tools/listAnalytics.js +149 -0
  17. package/dist/tools/manageDebugLogs.d.ts +31 -0
  18. package/dist/tools/manageDebugLogs.js +451 -0
  19. package/dist/tools/manageField.d.ts +32 -0
  20. package/dist/tools/manageField.js +358 -0
  21. package/dist/tools/manageFieldPermissions.d.ts +17 -0
  22. package/dist/tools/manageFieldPermissions.js +256 -0
  23. package/dist/tools/manageObject.d.ts +20 -0
  24. package/dist/tools/manageObject.js +138 -0
  25. package/dist/tools/query.d.ts +17 -0
  26. package/dist/tools/query.js +237 -0
  27. package/dist/tools/readApex.d.ts +28 -0
  28. package/dist/tools/readApex.js +188 -0
  29. package/dist/tools/readApexTrigger.d.ts +28 -0
  30. package/dist/tools/readApexTrigger.js +188 -0
  31. package/dist/tools/refreshDashboard.d.ts +13 -0
  32. package/dist/tools/refreshDashboard.js +91 -0
  33. package/dist/tools/restApi.d.ts +17 -0
  34. package/dist/tools/restApi.js +150 -0
  35. package/dist/tools/runAnalytics.d.ts +30 -0
  36. package/dist/tools/runAnalytics.js +367 -0
  37. package/dist/tools/search.d.ts +14 -0
  38. package/dist/tools/search.js +69 -0
  39. package/dist/tools/searchAll.d.ts +29 -0
  40. package/dist/tools/searchAll.js +258 -0
  41. package/dist/tools/writeApex.d.ts +27 -0
  42. package/dist/tools/writeApex.js +159 -0
  43. package/dist/tools/writeApexTrigger.d.ts +28 -0
  44. package/dist/tools/writeApexTrigger.js +187 -0
  45. package/dist/types/analytics.d.ts +2 -0
  46. package/dist/types/analytics.js +1 -0
  47. package/dist/types/connection.d.ts +52 -0
  48. package/dist/types/connection.js +21 -0
  49. package/dist/types/metadata.d.ts +43 -0
  50. package/dist/types/metadata.js +1 -0
  51. package/dist/types/salesforce.d.ts +33 -0
  52. package/dist/types/salesforce.js +1 -0
  53. package/dist/utils/connection.d.ts +7 -0
  54. package/dist/utils/connection.js +169 -0
  55. package/dist/utils/errorHandler.d.ts +15 -0
  56. package/dist/utils/errorHandler.js +23 -0
  57. package/dist/utils/logging.d.ts +12 -0
  58. package/dist/utils/logging.js +23 -0
  59. package/dist/utils/pagination.d.ts +14 -0
  60. package/dist/utils/pagination.js +26 -0
  61. package/dist/utils/sanitize.d.ts +44 -0
  62. package/dist/utils/sanitize.js +73 -0
  63. package/dist/utils/validate.d.ts +13 -0
  64. package/dist/utils/validate.js +60 -0
  65. package/package.json +52 -0
package/dist/index.js ADDED
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import * as dotenv from "dotenv";
6
+ import { createSalesforceConnection } from "./utils/connection.js";
7
+ import { SEARCH_OBJECTS, handleSearchObjects } from "./tools/search.js";
8
+ import { DESCRIBE_OBJECT, handleDescribeObject } from "./tools/describe.js";
9
+ import { QUERY_RECORDS, handleQueryRecords } from "./tools/query.js";
10
+ import { AGGREGATE_QUERY, handleAggregateQuery } from "./tools/aggregateQuery.js";
11
+ import { DML_RECORDS, handleDMLRecords } from "./tools/dml.js";
12
+ import { MANAGE_OBJECT, handleManageObject } from "./tools/manageObject.js";
13
+ import { MANAGE_FIELD, handleManageField } from "./tools/manageField.js";
14
+ import { MANAGE_FIELD_PERMISSIONS, handleManageFieldPermissions } from "./tools/manageFieldPermissions.js";
15
+ import { SEARCH_ALL, handleSearchAll } from "./tools/searchAll.js";
16
+ import { READ_APEX, handleReadApex } from "./tools/readApex.js";
17
+ import { WRITE_APEX, handleWriteApex } from "./tools/writeApex.js";
18
+ import { READ_APEX_TRIGGER, handleReadApexTrigger } from "./tools/readApexTrigger.js";
19
+ import { WRITE_APEX_TRIGGER, handleWriteApexTrigger } from "./tools/writeApexTrigger.js";
20
+ import { EXECUTE_ANONYMOUS, handleExecuteAnonymous } from "./tools/executeAnonymous.js";
21
+ import { MANAGE_DEBUG_LOGS, handleManageDebugLogs } from "./tools/manageDebugLogs.js";
22
+ import { LIST_ANALYTICS, handleListAnalytics } from "./tools/listAnalytics.js";
23
+ import { DESCRIBE_ANALYTICS, handleDescribeAnalytics } from "./tools/describeAnalytics.js";
24
+ import { RUN_ANALYTICS, handleRunAnalytics } from "./tools/runAnalytics.js";
25
+ import { REFRESH_DASHBOARD, handleRefreshDashboard } from "./tools/refreshDashboard.js";
26
+ import { REST_API, handleRestApi } from "./tools/restApi.js";
27
+ // Load environment variables — quiet: true suppresses dotenv 17.x stderr logging
28
+ // MCP servers require stdout to contain ONLY JSON-RPC messages
29
+ dotenv.config({ quiet: true });
30
+ const server = new Server({
31
+ name: "salesforce-mcp-server",
32
+ version: "1.0.0",
33
+ }, {
34
+ capabilities: {
35
+ tools: {},
36
+ },
37
+ });
38
+ // Tool handlers
39
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
40
+ tools: [
41
+ SEARCH_OBJECTS,
42
+ DESCRIBE_OBJECT,
43
+ QUERY_RECORDS,
44
+ AGGREGATE_QUERY,
45
+ DML_RECORDS,
46
+ MANAGE_OBJECT,
47
+ MANAGE_FIELD,
48
+ MANAGE_FIELD_PERMISSIONS,
49
+ SEARCH_ALL,
50
+ READ_APEX,
51
+ WRITE_APEX,
52
+ READ_APEX_TRIGGER,
53
+ WRITE_APEX_TRIGGER,
54
+ EXECUTE_ANONYMOUS,
55
+ MANAGE_DEBUG_LOGS,
56
+ LIST_ANALYTICS,
57
+ DESCRIBE_ANALYTICS,
58
+ RUN_ANALYTICS,
59
+ REFRESH_DASHBOARD,
60
+ REST_API
61
+ ],
62
+ }));
63
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
64
+ try {
65
+ const { name, arguments: args } = request.params;
66
+ if (!args)
67
+ throw new Error('Arguments are required');
68
+ const conn = await createSalesforceConnection();
69
+ switch (name) {
70
+ case "salesforce_search_objects": {
71
+ const searchObjArgs = args;
72
+ if (!searchObjArgs.searchPattern)
73
+ throw new Error('searchPattern is required');
74
+ const validatedArgs = {
75
+ searchPattern: searchObjArgs.searchPattern,
76
+ limit: searchObjArgs.limit,
77
+ offset: searchObjArgs.offset,
78
+ };
79
+ return await handleSearchObjects(conn, validatedArgs);
80
+ }
81
+ case "salesforce_describe_object": {
82
+ const { objectName } = args;
83
+ if (!objectName)
84
+ throw new Error('objectName is required');
85
+ return await handleDescribeObject(conn, objectName);
86
+ }
87
+ case "salesforce_query_records": {
88
+ const queryArgs = args;
89
+ if (!queryArgs.objectName || !Array.isArray(queryArgs.fields)) {
90
+ throw new Error('objectName and fields array are required for query');
91
+ }
92
+ // Type check and conversion
93
+ const validatedArgs = {
94
+ objectName: queryArgs.objectName,
95
+ fields: queryArgs.fields,
96
+ whereClause: queryArgs.whereClause,
97
+ orderBy: queryArgs.orderBy,
98
+ limit: queryArgs.limit,
99
+ offset: queryArgs.offset
100
+ };
101
+ return await handleQueryRecords(conn, validatedArgs);
102
+ }
103
+ case "salesforce_aggregate_query": {
104
+ const aggregateArgs = args;
105
+ if (!aggregateArgs.objectName || !Array.isArray(aggregateArgs.selectFields) || !Array.isArray(aggregateArgs.groupByFields)) {
106
+ throw new Error('objectName, selectFields array, and groupByFields array are required for aggregate query');
107
+ }
108
+ // Type check and conversion
109
+ const validatedArgs = {
110
+ objectName: aggregateArgs.objectName,
111
+ selectFields: aggregateArgs.selectFields,
112
+ groupByFields: aggregateArgs.groupByFields,
113
+ whereClause: aggregateArgs.whereClause,
114
+ havingClause: aggregateArgs.havingClause,
115
+ orderBy: aggregateArgs.orderBy,
116
+ limit: aggregateArgs.limit
117
+ };
118
+ return await handleAggregateQuery(conn, validatedArgs);
119
+ }
120
+ case "salesforce_dml_records": {
121
+ const dmlArgs = args;
122
+ if (!dmlArgs.operation || !dmlArgs.objectName || !Array.isArray(dmlArgs.records)) {
123
+ throw new Error('operation, objectName, and records array are required for DML');
124
+ }
125
+ const validatedArgs = {
126
+ operation: dmlArgs.operation,
127
+ objectName: dmlArgs.objectName,
128
+ records: dmlArgs.records,
129
+ externalIdField: dmlArgs.externalIdField
130
+ };
131
+ return await handleDMLRecords(conn, validatedArgs);
132
+ }
133
+ case "salesforce_manage_object": {
134
+ const objectArgs = args;
135
+ if (!objectArgs.operation || !objectArgs.objectName) {
136
+ throw new Error('operation and objectName are required for object management');
137
+ }
138
+ const validatedArgs = {
139
+ operation: objectArgs.operation,
140
+ objectName: objectArgs.objectName,
141
+ label: objectArgs.label,
142
+ pluralLabel: objectArgs.pluralLabel,
143
+ description: objectArgs.description,
144
+ nameFieldLabel: objectArgs.nameFieldLabel,
145
+ nameFieldType: objectArgs.nameFieldType,
146
+ nameFieldFormat: objectArgs.nameFieldFormat,
147
+ sharingModel: objectArgs.sharingModel
148
+ };
149
+ return await handleManageObject(conn, validatedArgs);
150
+ }
151
+ case "salesforce_manage_field": {
152
+ const fieldArgs = args;
153
+ if (!fieldArgs.operation || !fieldArgs.objectName || !fieldArgs.fieldName) {
154
+ throw new Error('operation, objectName, and fieldName are required for field management');
155
+ }
156
+ const validatedArgs = {
157
+ operation: fieldArgs.operation,
158
+ objectName: fieldArgs.objectName,
159
+ fieldName: fieldArgs.fieldName,
160
+ label: fieldArgs.label,
161
+ type: fieldArgs.type,
162
+ required: fieldArgs.required,
163
+ unique: fieldArgs.unique,
164
+ externalId: fieldArgs.externalId,
165
+ length: fieldArgs.length,
166
+ precision: fieldArgs.precision,
167
+ scale: fieldArgs.scale,
168
+ referenceTo: fieldArgs.referenceTo,
169
+ relationshipLabel: fieldArgs.relationshipLabel,
170
+ relationshipName: fieldArgs.relationshipName,
171
+ deleteConstraint: fieldArgs.deleteConstraint,
172
+ picklistValues: fieldArgs.picklistValues,
173
+ description: fieldArgs.description,
174
+ grantAccessTo: fieldArgs.grantAccessTo
175
+ };
176
+ return await handleManageField(conn, validatedArgs);
177
+ }
178
+ case "salesforce_manage_field_permissions": {
179
+ const permArgs = args;
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 = {
184
+ operation: permArgs.operation,
185
+ objectName: permArgs.objectName,
186
+ fieldName: permArgs.fieldName,
187
+ profileNames: permArgs.profileNames,
188
+ readable: permArgs.readable,
189
+ editable: permArgs.editable
190
+ };
191
+ return await handleManageFieldPermissions(conn, validatedArgs);
192
+ }
193
+ case "salesforce_search_all": {
194
+ const searchArgs = args;
195
+ if (!searchArgs.searchTerm || !Array.isArray(searchArgs.objects)) {
196
+ throw new Error('searchTerm and objects array are required for search');
197
+ }
198
+ // Validate objects array
199
+ const objects = searchArgs.objects;
200
+ if (!objects.every(obj => obj.name && Array.isArray(obj.fields))) {
201
+ throw new Error('Each object must specify name and fields array');
202
+ }
203
+ // Type check and conversion
204
+ const validatedArgs = {
205
+ searchTerm: searchArgs.searchTerm,
206
+ searchIn: searchArgs.searchIn,
207
+ objects: objects.map(obj => ({
208
+ name: obj.name,
209
+ fields: obj.fields,
210
+ where: obj.where,
211
+ orderBy: obj.orderBy,
212
+ limit: obj.limit
213
+ })),
214
+ withClauses: searchArgs.withClauses,
215
+ updateable: searchArgs.updateable,
216
+ viewable: searchArgs.viewable
217
+ };
218
+ return await handleSearchAll(conn, validatedArgs);
219
+ }
220
+ case "salesforce_read_apex": {
221
+ const apexArgs = args;
222
+ // Type check and conversion
223
+ const validatedArgs = {
224
+ className: apexArgs.className,
225
+ namePattern: apexArgs.namePattern,
226
+ includeMetadata: apexArgs.includeMetadata,
227
+ limit: apexArgs.limit,
228
+ offset: apexArgs.offset
229
+ };
230
+ return await handleReadApex(conn, validatedArgs);
231
+ }
232
+ case "salesforce_write_apex": {
233
+ const apexArgs = args;
234
+ if (!apexArgs.operation || !apexArgs.className || !apexArgs.body) {
235
+ throw new Error('operation, className, and body are required for writing Apex');
236
+ }
237
+ // Type check and conversion
238
+ const validatedArgs = {
239
+ operation: apexArgs.operation,
240
+ className: apexArgs.className,
241
+ apiVersion: apexArgs.apiVersion,
242
+ body: apexArgs.body
243
+ };
244
+ return await handleWriteApex(conn, validatedArgs);
245
+ }
246
+ case "salesforce_read_apex_trigger": {
247
+ const triggerArgs = args;
248
+ // Type check and conversion
249
+ const validatedArgs = {
250
+ triggerName: triggerArgs.triggerName,
251
+ namePattern: triggerArgs.namePattern,
252
+ includeMetadata: triggerArgs.includeMetadata,
253
+ limit: triggerArgs.limit,
254
+ offset: triggerArgs.offset
255
+ };
256
+ return await handleReadApexTrigger(conn, validatedArgs);
257
+ }
258
+ case "salesforce_write_apex_trigger": {
259
+ const triggerArgs = args;
260
+ if (!triggerArgs.operation || !triggerArgs.triggerName || !triggerArgs.body) {
261
+ throw new Error('operation, triggerName, and body are required for writing Apex trigger');
262
+ }
263
+ // Type check and conversion
264
+ const validatedArgs = {
265
+ operation: triggerArgs.operation,
266
+ triggerName: triggerArgs.triggerName,
267
+ objectName: triggerArgs.objectName,
268
+ apiVersion: triggerArgs.apiVersion,
269
+ body: triggerArgs.body
270
+ };
271
+ return await handleWriteApexTrigger(conn, validatedArgs);
272
+ }
273
+ case "salesforce_execute_anonymous": {
274
+ const executeArgs = args;
275
+ if (!executeArgs.apexCode) {
276
+ throw new Error('apexCode is required for executing anonymous Apex');
277
+ }
278
+ // Type check and conversion
279
+ const validatedArgs = {
280
+ apexCode: executeArgs.apexCode,
281
+ logLevel: executeArgs.logLevel
282
+ };
283
+ return await handleExecuteAnonymous(conn, validatedArgs);
284
+ }
285
+ case "salesforce_manage_debug_logs": {
286
+ const debugLogsArgs = args;
287
+ if (!debugLogsArgs.operation || !debugLogsArgs.username) {
288
+ throw new Error('operation and username are required for managing debug logs');
289
+ }
290
+ // Type check and conversion
291
+ const validatedArgs = {
292
+ operation: debugLogsArgs.operation,
293
+ username: debugLogsArgs.username,
294
+ logLevel: debugLogsArgs.logLevel,
295
+ expirationTime: debugLogsArgs.expirationTime,
296
+ limit: debugLogsArgs.limit,
297
+ logId: debugLogsArgs.logId,
298
+ includeBody: debugLogsArgs.includeBody,
299
+ offset: debugLogsArgs.offset
300
+ };
301
+ return await handleManageDebugLogs(conn, validatedArgs);
302
+ }
303
+ case "salesforce_list_analytics": {
304
+ const listAnalyticsArgs = args;
305
+ if (!listAnalyticsArgs.type) {
306
+ throw new Error('type is required');
307
+ }
308
+ const validatedArgs = {
309
+ type: listAnalyticsArgs.type,
310
+ searchTerm: listAnalyticsArgs.searchTerm,
311
+ };
312
+ return await handleListAnalytics(conn, validatedArgs);
313
+ }
314
+ case "salesforce_describe_analytics": {
315
+ const descAnalyticsArgs = args;
316
+ if (!descAnalyticsArgs.type || !descAnalyticsArgs.resourceId) {
317
+ throw new Error('type and resourceId are required');
318
+ }
319
+ const validatedArgs = {
320
+ type: descAnalyticsArgs.type,
321
+ resourceId: descAnalyticsArgs.resourceId,
322
+ };
323
+ return await handleDescribeAnalytics(conn, validatedArgs);
324
+ }
325
+ case "salesforce_run_analytics": {
326
+ const runAnalyticsArgs = args;
327
+ if (!runAnalyticsArgs.type || !runAnalyticsArgs.resourceId) {
328
+ throw new Error('type and resourceId are required');
329
+ }
330
+ const validatedArgs = {
331
+ type: runAnalyticsArgs.type,
332
+ resourceId: runAnalyticsArgs.resourceId,
333
+ includeDetails: runAnalyticsArgs.includeDetails,
334
+ filters: runAnalyticsArgs.filters,
335
+ booleanFilter: runAnalyticsArgs.booleanFilter,
336
+ standardDateFilter: runAnalyticsArgs.standardDateFilter,
337
+ topRows: runAnalyticsArgs.topRows,
338
+ };
339
+ return await handleRunAnalytics(conn, validatedArgs);
340
+ }
341
+ case "salesforce_refresh_dashboard": {
342
+ const refreshArgs = args;
343
+ if (!refreshArgs.operation || !refreshArgs.dashboardId) {
344
+ throw new Error('operation and dashboardId are required');
345
+ }
346
+ const validatedArgs = {
347
+ operation: refreshArgs.operation,
348
+ dashboardId: refreshArgs.dashboardId,
349
+ };
350
+ return await handleRefreshDashboard(conn, validatedArgs);
351
+ }
352
+ case "salesforce_rest_api": {
353
+ const restArgs = args;
354
+ if (!restArgs.method || !restArgs.endpoint) {
355
+ throw new Error('method and endpoint are required for REST API calls');
356
+ }
357
+ const validatedArgs = {
358
+ method: restArgs.method,
359
+ endpoint: restArgs.endpoint,
360
+ body: restArgs.body,
361
+ queryParameters: restArgs.queryParameters,
362
+ apiVersion: restArgs.apiVersion,
363
+ rawPath: restArgs.rawPath
364
+ };
365
+ return await handleRestApi(conn, validatedArgs);
366
+ }
367
+ default:
368
+ return {
369
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
370
+ isError: true,
371
+ };
372
+ }
373
+ }
374
+ catch (error) {
375
+ return {
376
+ content: [{
377
+ type: "text",
378
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
379
+ }],
380
+ isError: true,
381
+ };
382
+ }
383
+ });
384
+ async function runServer() {
385
+ const transport = new StdioServerTransport();
386
+ await server.connect(transport);
387
+ console.error("Salesforce MCP Server running on stdio");
388
+ }
389
+ runServer().catch((error) => {
390
+ console.error("Fatal error running server:", error);
391
+ process.exit(1);
392
+ });
@@ -0,0 +1,18 @@
1
+ import { Tool } from "@modelcontextprotocol/sdk/types.js";
2
+ export declare const AGGREGATE_QUERY: Tool;
3
+ export interface AggregateQueryArgs {
4
+ objectName: string;
5
+ selectFields: string[];
6
+ groupByFields: string[];
7
+ whereClause?: string;
8
+ havingClause?: string;
9
+ orderBy?: string;
10
+ limit?: number;
11
+ }
12
+ export declare function handleAggregateQuery(conn: any, args: AggregateQueryArgs): Promise<{
13
+ content: {
14
+ type: string;
15
+ text: string;
16
+ }[];
17
+ isError: boolean;
18
+ }>;
@@ -0,0 +1,275 @@
1
+ import { DEFAULT_LIMITS } from "../utils/pagination.js";
2
+ import { validateIdentifier } from "../utils/sanitize.js";
3
+ export const AGGREGATE_QUERY = {
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
+ // Aggregate functions that don't need to be in GROUP BY
90
+ const AGGREGATE_FUNCTIONS = ['COUNT', 'COUNT_DISTINCT', 'SUM', 'AVG', 'MIN', 'MAX'];
91
+ const DATE_FUNCTIONS = ['CALENDAR_YEAR', 'CALENDAR_MONTH', 'CALENDAR_QUARTER', 'FISCAL_YEAR', 'FISCAL_QUARTER'];
92
+ // Helper function to detect if a field contains an aggregate function
93
+ function isAggregateField(field) {
94
+ const upperField = field.toUpperCase();
95
+ return AGGREGATE_FUNCTIONS.some(func => upperField.includes(`${func}(`));
96
+ }
97
+ // Helper function to extract the base field from a select field (removing alias)
98
+ function extractBaseField(field) {
99
+ // Remove alias if present (e.g., "COUNT(Id) OpportunityCount" -> "COUNT(Id)")
100
+ const parts = field.trim().split(/\s+/);
101
+ return parts[0];
102
+ }
103
+ // Helper function to extract non-aggregate fields from select fields
104
+ function extractNonAggregateFields(selectFields) {
105
+ return selectFields
106
+ .filter(field => !isAggregateField(field))
107
+ .map(field => extractBaseField(field));
108
+ }
109
+ // Helper function to validate that all non-aggregate fields are in GROUP BY
110
+ function validateGroupByFields(selectFields, groupByFields) {
111
+ const nonAggregateFields = extractNonAggregateFields(selectFields);
112
+ const groupBySet = new Set(groupByFields.map(f => f.trim()));
113
+ const missingFields = nonAggregateFields.filter(field => !groupBySet.has(field));
114
+ return {
115
+ isValid: missingFields.length === 0,
116
+ missingFields
117
+ };
118
+ }
119
+ // Helper function to validate WHERE clause doesn't contain aggregates
120
+ function validateWhereClause(whereClause) {
121
+ if (!whereClause)
122
+ return { isValid: true };
123
+ const upperWhere = whereClause.toUpperCase();
124
+ for (const func of AGGREGATE_FUNCTIONS) {
125
+ if (upperWhere.includes(`${func}(`)) {
126
+ return {
127
+ isValid: false,
128
+ error: `WHERE clause cannot contain aggregate functions. Use HAVING clause instead for aggregate conditions like ${func}()`
129
+ };
130
+ }
131
+ }
132
+ return { isValid: true };
133
+ }
134
+ // Helper function to validate ORDER BY fields
135
+ function validateOrderBy(orderBy, groupByFields, selectFields) {
136
+ if (!orderBy)
137
+ return { isValid: true };
138
+ // Extract fields from ORDER BY (handling DESC/ASC)
139
+ const orderByParts = orderBy.split(',').map(part => {
140
+ return part.trim().replace(/ (DESC|ASC)$/i, '').trim();
141
+ });
142
+ const groupBySet = new Set(groupByFields);
143
+ const aggregateFields = selectFields.filter(field => isAggregateField(field)).map(field => extractBaseField(field));
144
+ for (const orderField of orderByParts) {
145
+ // Check if it's in GROUP BY or is an aggregate
146
+ if (!groupBySet.has(orderField) && !aggregateFields.some(agg => agg === orderField) && !isAggregateField(orderField)) {
147
+ return {
148
+ isValid: false,
149
+ error: `ORDER BY field '${orderField}' must be in GROUP BY clause or be an aggregate function`
150
+ };
151
+ }
152
+ }
153
+ return { isValid: true };
154
+ }
155
+ export async function handleAggregateQuery(conn, args) {
156
+ const { objectName, selectFields, groupByFields, whereClause, havingClause, orderBy, limit } = args;
157
+ try {
158
+ // Validate GROUP BY contains all non-aggregate fields
159
+ const groupByValidation = validateGroupByFields(selectFields, groupByFields);
160
+ if (!groupByValidation.isValid) {
161
+ return {
162
+ content: [{
163
+ type: "text",
164
+ text: `Error: The following non-aggregate fields must be included in GROUP BY clause: ${groupByValidation.missingFields.join(', ')}\n\n` +
165
+ `All fields in SELECT that are not aggregate functions (COUNT, SUM, AVG, etc.) must be included in GROUP BY.`
166
+ }],
167
+ isError: true,
168
+ };
169
+ }
170
+ // Validate WHERE clause doesn't contain aggregates
171
+ const whereValidation = validateWhereClause(whereClause);
172
+ if (!whereValidation.isValid) {
173
+ return {
174
+ content: [{
175
+ type: "text",
176
+ text: whereValidation.error
177
+ }],
178
+ isError: true,
179
+ };
180
+ }
181
+ // Validate ORDER BY fields
182
+ const orderByValidation = validateOrderBy(orderBy, groupByFields, selectFields);
183
+ if (!orderByValidation.isValid) {
184
+ return {
185
+ content: [{
186
+ type: "text",
187
+ text: orderByValidation.error
188
+ }],
189
+ isError: true,
190
+ };
191
+ }
192
+ const objValidation = validateIdentifier(objectName);
193
+ if (!objValidation.valid) {
194
+ return { content: [{ type: "text", text: objValidation.error }], isError: true };
195
+ }
196
+ // Construct SOQL query
197
+ let soql = `SELECT ${selectFields.join(', ')} FROM ${objectName}`;
198
+ if (whereClause)
199
+ soql += ` WHERE ${whereClause}`;
200
+ soql += ` GROUP BY ${groupByFields.join(', ')}`;
201
+ if (havingClause)
202
+ soql += ` HAVING ${havingClause}`;
203
+ if (orderBy)
204
+ soql += ` ORDER BY ${orderBy}`;
205
+ const effectiveLimit = limit ?? DEFAULT_LIMITS.aggregate;
206
+ soql += ` LIMIT ${effectiveLimit}`;
207
+ const result = await conn.query(soql);
208
+ // Format the output
209
+ const formattedRecords = result.records.map((record, index) => {
210
+ const recordStr = selectFields.map(field => {
211
+ const baseField = extractBaseField(field);
212
+ const fieldParts = field.trim().split(/\s+/);
213
+ const displayName = fieldParts.length > 1 ? fieldParts[fieldParts.length - 1] : baseField;
214
+ // Handle nested fields in results (e.g., Account.Industry in GROUP BY)
215
+ if (baseField.includes('.')) {
216
+ // Strategy 1: Walk nested object (e.g., record.Account.Industry)
217
+ const parts = baseField.split('.');
218
+ let value = record;
219
+ for (const part of parts) {
220
+ value = value?.[part];
221
+ }
222
+ // Strategy 2: Flattened key (jsforce sometimes returns flat keys)
223
+ if (value === null || value === undefined) {
224
+ value = record[baseField];
225
+ }
226
+ // Strategy 3: Alias/display name
227
+ if (value === null || value === undefined) {
228
+ value = record[displayName];
229
+ }
230
+ // Strategy 4: Salesforce expr aliases (expr0, expr1, etc.)
231
+ if (value === null || value === undefined) {
232
+ const fieldIndex = selectFields.indexOf(field);
233
+ value = record[`expr${fieldIndex}`];
234
+ }
235
+ return ` ${displayName}: ${value !== null && value !== undefined ? value : 'null'}`;
236
+ }
237
+ const value = record[baseField] ?? record[displayName];
238
+ return ` ${displayName}: ${value !== null && value !== undefined ? value : 'null'}`;
239
+ }).join('\n');
240
+ return `Group ${index + 1}:\n${recordStr}`;
241
+ }).join('\n\n');
242
+ const totalSize = result.totalSize ?? result.records.length;
243
+ let text = `Aggregate query returned ${result.records.length} of ${totalSize} grouped results:\n\n${formattedRecords}`;
244
+ if (result.records.length < totalSize) {
245
+ text += `\n\nNote: Results are truncated (${result.records.length} of ${totalSize}). Narrow with WHERE/HAVING to see different slices (OFFSET is not supported with GROUP BY in Salesforce).`;
246
+ }
247
+ return {
248
+ content: [{
249
+ type: "text",
250
+ text,
251
+ }],
252
+ isError: false,
253
+ };
254
+ }
255
+ catch (error) {
256
+ const errorMessage = error instanceof Error ? error.message : String(error);
257
+ // Provide more helpful error messages for common issues
258
+ let enhancedError = errorMessage;
259
+ if (errorMessage.includes('MALFORMED_QUERY')) {
260
+ if (errorMessage.includes('GROUP BY')) {
261
+ enhancedError = `Query error: ${errorMessage}\n\nCommon issues:\n` +
262
+ `1. Ensure all non-aggregate fields in SELECT are in GROUP BY\n` +
263
+ `2. Check that date functions match exactly between SELECT and GROUP BY\n` +
264
+ `3. Verify field names and relationships are correct`;
265
+ }
266
+ }
267
+ return {
268
+ content: [{
269
+ type: "text",
270
+ text: `Error executing aggregate query: ${enhancedError}`
271
+ }],
272
+ isError: true,
273
+ };
274
+ }
275
+ }