Skip to main content
Glama
simonl77

Salesforce MCP Server

by simonl77

salesforce_aggregate_query

Execute SOQL queries with GROUP BY and aggregate functions to summarize and analyze Salesforce data. Group records by fields, calculate counts, sums, averages, and apply statistical analysis across grouped results.

Instructions

Execute SOQL queries with GROUP BY, aggregate functions, and statistical analysis. Use this tool for queries that summarize and group data rather than returning individual records.

NOTE: For regular queries without GROUP BY or aggregates, use salesforce_query_records instead.

This tool handles:

  1. GROUP BY queries (single/multiple fields, related objects, date functions)

  2. Aggregate functions: COUNT(), COUNT_DISTINCT(), SUM(), AVG(), MIN(), MAX()

  3. HAVING clauses for filtering grouped results

  4. Date/time grouping: CALENDAR_YEAR(), CALENDAR_MONTH(), CALENDAR_QUARTER(), FISCAL_YEAR(), FISCAL_QUARTER()

Examples:

  1. Count opportunities by stage:

    • objectName: "Opportunity"

    • selectFields: ["StageName", "COUNT(Id) OpportunityCount"]

    • groupByFields: ["StageName"]

  2. Analyze cases by priority and status:

    • objectName: "Case"

    • selectFields: ["Priority", "Status", "COUNT(Id) CaseCount", "AVG(Days_Open__c) AvgDaysOpen"]

    • groupByFields: ["Priority", "Status"]

  3. Count contacts by account industry:

    • objectName: "Contact"

    • selectFields: ["Account.Industry", "COUNT(Id) ContactCount"]

    • groupByFields: ["Account.Industry"]

  4. Quarterly opportunity analysis:

    • objectName: "Opportunity"

    • selectFields: ["CALENDAR_YEAR(CloseDate) Year", "CALENDAR_QUARTER(CloseDate) Quarter", "SUM(Amount) Revenue"]

    • groupByFields: ["CALENDAR_YEAR(CloseDate)", "CALENDAR_QUARTER(CloseDate)"]

  5. Find accounts with more than 10 opportunities:

    • objectName: "Opportunity"

    • selectFields: ["Account.Name", "COUNT(Id) OpportunityCount"]

    • groupByFields: ["Account.Name"]

    • havingClause: "COUNT(Id) > 10"

Important Rules:

  • All non-aggregate fields in selectFields MUST be included in groupByFields

  • Use whereClause to filter rows BEFORE grouping

  • Use havingClause to filter AFTER grouping (for aggregate conditions)

  • ORDER BY can only use fields from groupByFields or aggregate functions

  • OFFSET is not supported with GROUP BY in Salesforce

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
objectNameYesAPI name of the object to query
selectFieldsYesFields to select - mix of group fields and aggregates. Format: 'FieldName' or 'COUNT(Id) AliasName'
groupByFieldsYesFields to group by - must include all non-aggregate fields from selectFields
whereClauseNoWHERE clause to filter rows BEFORE grouping (cannot contain aggregate functions)
havingClauseNoHAVING clause to filter results AFTER grouping (use for aggregate conditions)
orderByNoORDER BY clause - can only use grouped fields or aggregate functions
limitNoMaximum number of grouped results to return

Implementation Reference

  • Executes the aggregate SOQL query: validates inputs (GROUP BY fields, WHERE/HAVING/ORDER BY), constructs the full SOQL with GROUP BY and aggregates, queries Salesforce, formats grouped results, handles common errors with helpful messages.
    export async function handleAggregateQuery(conn: any, args: AggregateQueryArgs) {
      const { objectName, selectFields, groupByFields, whereClause, havingClause, orderBy, limit } = args;
    
      try {
        // Validate GROUP BY contains all non-aggregate fields
        const groupByValidation = validateGroupByFields(selectFields, groupByFields);
        if (!groupByValidation.isValid) {
          return {
            content: [{
              type: "text",
              text: `Error: The following non-aggregate fields must be included in GROUP BY clause: ${groupByValidation.missingFields!.join(', ')}\n\n` +
                    `All fields in SELECT that are not aggregate functions (COUNT, SUM, AVG, etc.) must be included in GROUP BY.`
            }],
            isError: true,
          };
        }
    
        // Validate WHERE clause doesn't contain aggregates
        const whereValidation = validateWhereClause(whereClause);
        if (!whereValidation.isValid) {
          return {
            content: [{
              type: "text",
              text: whereValidation.error!
            }],
            isError: true,
          };
        }
    
        // Validate ORDER BY fields
        const orderByValidation = validateOrderBy(orderBy, groupByFields, selectFields);
        if (!orderByValidation.isValid) {
          return {
            content: [{
              type: "text",
              text: orderByValidation.error!
            }],
            isError: true,
          };
        }
    
        // Construct SOQL query
        let soql = `SELECT ${selectFields.join(', ')} FROM ${objectName}`;
        if (whereClause) soql += ` WHERE ${whereClause}`;
        soql += ` GROUP BY ${groupByFields.join(', ')}`;
        if (havingClause) soql += ` HAVING ${havingClause}`;
        if (orderBy) soql += ` ORDER BY ${orderBy}`;
        if (limit) soql += ` LIMIT ${limit}`;
    
        const result = await conn.query(soql);
        
        // Format the output
        const formattedRecords = result.records.map((record: any, index: number) => {
          const recordStr = selectFields.map(field => {
            const baseField = extractBaseField(field);
            const fieldParts = field.trim().split(/\s+/);
            const displayName = fieldParts.length > 1 ? fieldParts[fieldParts.length - 1] : baseField;
            
            // Handle nested fields in results
            if (baseField.includes('.')) {
              const parts = baseField.split('.');
              let value = record;
              for (const part of parts) {
                value = value?.[part];
              }
              return `    ${displayName}: ${value !== null && value !== undefined ? value : 'null'}`;
            }
            
            const value = record[baseField] || record[displayName];
            return `    ${displayName}: ${value !== null && value !== undefined ? value : 'null'}`;
          }).join('\n');
          return `Group ${index + 1}:\n${recordStr}`;
        }).join('\n\n');
    
        return {
          content: [{
            type: "text",
            text: `Aggregate query returned ${result.records.length} grouped results:\n\n${formattedRecords}`
          }],
          isError: false,
        };
      } catch (error) {
        const errorMessage = error instanceof Error ? error.message : String(error);
        
        // Provide more helpful error messages for common issues
        let enhancedError = errorMessage;
        if (errorMessage.includes('MALFORMED_QUERY')) {
          if (errorMessage.includes('GROUP BY')) {
            enhancedError = `Query error: ${errorMessage}\n\nCommon issues:\n` +
              `1. Ensure all non-aggregate fields in SELECT are in GROUP BY\n` +
              `2. Check that date functions match exactly between SELECT and GROUP BY\n` +
              `3. Verify field names and relationships are correct`;
          }
        }
    
        return {
          content: [{
            type: "text",
            text: `Error executing aggregate query: ${enhancedError}`
          }],
          isError: true,
        };
      }
    } 
  • Defines the tool specification including name, detailed description with examples, and input schema for parameters like objectName, selectFields (with aggregates), groupByFields, whereClause, havingClause, etc.
    export const AGGREGATE_QUERY: Tool = {
      name: "salesforce_aggregate_query",
      description: `Execute SOQL queries with GROUP BY, aggregate functions, and statistical analysis. Use this tool for queries that summarize and group data rather than returning individual records.
    
    NOTE: For regular queries without GROUP BY or aggregates, use salesforce_query_records instead.
    
    This tool handles:
    1. GROUP BY queries (single/multiple fields, related objects, date functions)
    2. Aggregate functions: COUNT(), COUNT_DISTINCT(), SUM(), AVG(), MIN(), MAX()
    3. HAVING clauses for filtering grouped results
    4. Date/time grouping: CALENDAR_YEAR(), CALENDAR_MONTH(), CALENDAR_QUARTER(), FISCAL_YEAR(), FISCAL_QUARTER()
    
    Examples:
    1. Count opportunities by stage:
       - objectName: "Opportunity"
       - selectFields: ["StageName", "COUNT(Id) OpportunityCount"]
       - groupByFields: ["StageName"]
    
    2. Analyze cases by priority and status:
       - objectName: "Case"
       - selectFields: ["Priority", "Status", "COUNT(Id) CaseCount", "AVG(Days_Open__c) AvgDaysOpen"]
       - groupByFields: ["Priority", "Status"]
    
    3. Count contacts by account industry:
       - objectName: "Contact"
       - selectFields: ["Account.Industry", "COUNT(Id) ContactCount"]
       - groupByFields: ["Account.Industry"]
    
    4. Quarterly opportunity analysis:
       - objectName: "Opportunity"
       - selectFields: ["CALENDAR_YEAR(CloseDate) Year", "CALENDAR_QUARTER(CloseDate) Quarter", "SUM(Amount) Revenue"]
       - groupByFields: ["CALENDAR_YEAR(CloseDate)", "CALENDAR_QUARTER(CloseDate)"]
    
    5. Find accounts with more than 10 opportunities:
       - objectName: "Opportunity"
       - selectFields: ["Account.Name", "COUNT(Id) OpportunityCount"]
       - groupByFields: ["Account.Name"]
       - havingClause: "COUNT(Id) > 10"
    
    Important Rules:
    - All non-aggregate fields in selectFields MUST be included in groupByFields
    - Use whereClause to filter rows BEFORE grouping
    - Use havingClause to filter AFTER grouping (for aggregate conditions)
    - ORDER BY can only use fields from groupByFields or aggregate functions
    - OFFSET is not supported with GROUP BY in Salesforce`,
      inputSchema: {
        type: "object",
        properties: {
          objectName: {
            type: "string",
            description: "API name of the object to query"
          },
          selectFields: {
            type: "array",
            items: { type: "string" },
            description: "Fields to select - mix of group fields and aggregates. Format: 'FieldName' or 'COUNT(Id) AliasName'"
          },
          groupByFields: {
            type: "array",
            items: { type: "string" },
            description: "Fields to group by - must include all non-aggregate fields from selectFields"
          },
          whereClause: {
            type: "string",
            description: "WHERE clause to filter rows BEFORE grouping (cannot contain aggregate functions)",
            optional: true
          },
          havingClause: {
            type: "string",
            description: "HAVING clause to filter results AFTER grouping (use for aggregate conditions)",
            optional: true
          },
          orderBy: {
            type: "string",
            description: "ORDER BY clause - can only use grouped fields or aggregate functions",
            optional: true
          },
          limit: {
            type: "number",
            description: "Maximum number of grouped results to return",
            optional: true
          }
        },
        required: ["objectName", "selectFields", "groupByFields"]
      }
    };
  • src/index.ts:45-63 (registration)
    Registers the tool in the MCP server's listTools response by including AGGREGATE_QUERY in the tools array.
    server.setRequestHandler(ListToolsRequestSchema, async () => ({
      tools: [
        SEARCH_OBJECTS, 
        DESCRIBE_OBJECT, 
        QUERY_RECORDS, 
        AGGREGATE_QUERY,
        DML_RECORDS,
        MANAGE_OBJECT,
        MANAGE_FIELD,
        MANAGE_FIELD_PERMISSIONS,
        SEARCH_ALL,
        READ_APEX,
        WRITE_APEX,
        READ_APEX_TRIGGER,
        WRITE_APEX_TRIGGER,
        EXECUTE_ANONYMOUS,
        MANAGE_DEBUG_LOGS
      ],
    }));
  • src/index.ts:101-117 (registration)
    Dispatches calls to 'salesforce_aggregate_query' by validating arguments, typing them as AggregateQueryArgs, and invoking the handleAggregateQuery function.
    case "salesforce_aggregate_query": {
      const aggregateArgs = args as Record<string, unknown>;
      if (!aggregateArgs.objectName || !Array.isArray(aggregateArgs.selectFields) || !Array.isArray(aggregateArgs.groupByFields)) {
        throw new Error('objectName, selectFields array, and groupByFields array are required for aggregate query');
      }
      // Type check and conversion
      const validatedArgs: AggregateQueryArgs = {
        objectName: aggregateArgs.objectName as string,
        selectFields: aggregateArgs.selectFields as string[],
        groupByFields: aggregateArgs.groupByFields as string[],
        whereClause: aggregateArgs.whereClause as string | undefined,
        havingClause: aggregateArgs.havingClause as string | undefined,
        orderBy: aggregateArgs.orderBy as string | undefined,
        limit: aggregateArgs.limit as number | undefined
      };
      return await handleAggregateQuery(conn, validatedArgs);
    }
  • Helper function to validate that all non-aggregate selectFields are present in groupByFields, ensuring SOQL compliance.
    function validateGroupByFields(selectFields: string[], groupByFields: string[]): { isValid: boolean; missingFields?: string[] } {
      const nonAggregateFields = extractNonAggregateFields(selectFields);
      const groupBySet = new Set(groupByFields.map(f => f.trim()));
      
      const missingFields = nonAggregateFields.filter(field => !groupBySet.has(field));
      
      return {
        isValid: missingFields.length === 0,
        missingFields
      };
    }

Schema Changelog

Changes observed during successful MCP inspections.

  1. First observed

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries full behavioral burden. It discloses critical rules: non-aggregate selectFields must be in groupByFields, ORDER BY limits, OFFSET unsupported with GROUP BY, and the distinction between row-level vs group-level filtering. This goes beyond schema details to expose real constraints.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Despite its length, the description is excellently structured with a purpose statement, alternative-tool note, numbered examples covering each feature, and an 'Important Rules' list. Every section is useful; no filler or redundancy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a complex tool with 7 parameters and no output schema, the description covers all relevant aspects: what it does, when to use it, parameter semantics, behavioral constraints, and representative examples. The absence of return-value details is acceptable given no output schema and the query nature.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, so baseline is 3. The description adds value beyond the schema by providing concrete examples of selectFields with aliases (e.g., 'COUNT(Id) OpportunityCount') and how groupByFields handle related objects and date functions. These clarify parameter formatting far better than the schema alone.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Description opens with a specific verb-resource pair: 'Execute SOQL queries with GROUP BY, aggregate functions, and statistical analysis.' It clearly scopes the tool to summarizing/grouping queries and explicitly distinguishes it from salesforce_query_records for regular queries.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Provides explicit guidance: 'For regular queries without GROUP BY or aggregates, use salesforce_query_records instead.' Also explains when to use whereClause vs havingClause (before vs after grouping), and enumerates supported grouping scenarios and examples.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.