Expose Invocable Apex as a Custom MCP Tool

A Flow-backed tool returns records. An Apex-backed tool returns an answer — with clamped inputs, enforced security, and a summary the assistant can reason over. Full working code. A hands-on AI Projects Lab build.

The scenario

The Flow-backed tool in the previous build answers "tell me about this one account". The obvious next question is the plural one: which accounts need attention? That's not a lookup — it's a filter, a computation over related records, and a judgement threshold.

This build adds a second tool to the same custom MCP server, backed by a parameterised Apex invocable action. It takes search criteria — industry, minimum revenue, an open-case threshold — and returns a computed health summary rather than raw records.

For beginners: an invocable action is an Apex method marked @InvocableMethod. It was designed so Flow could call Apex; the same annotation now lets Agentforce and MCP call it. One annotation, three callers.

Why Apex instead of Flow here

You needReach for
To return existing records, shapedFlow — faster, no code, admin-owned
To clamp what the caller can ask forApex
To compute across related records and return a verdictApex
To fail with an explanation instead of an exceptionApex
To strip fields the running user can't seeApex

The distinction that matters: an assistant is an untrusted, enthusiastic caller. It will happily ask for every account in the org, pass a negative threshold, or request ten thousand rows. Apex is where you make that harmless.

Step 1 — Design the contract before the logic

Everything the assistant knows about your tool comes from labels and descriptions. They are not documentation — they are the API. Four rules that shaped the class below:

Step 2 — The input and output classes

Read the descriptions rather than the field names — that's what the assistant reads.

global with sharing class AccountHealthMcpAction {

    // Guardrails: an agent will happily ask for everything.
    private static final Integer MAX_ACCOUNTS  = 50;
    private static final Integer DEFAULT_LIMIT = 10;

    global class Request {
        @InvocableVariable(
            label='Industry'
            description='Optional. Restrict to Accounts in this exact Industry picklist value, for example Energy or Banking. Leave blank for all industries.'
        )
        global String industry;

        @InvocableVariable(
            label='Minimum Annual Revenue'
            description='Optional. Only include Accounts whose AnnualRevenue is at or above this amount. Accounts with no revenue populated are excluded when this is set.'
        )
        global Decimal minAnnualRevenue;

        @InvocableVariable(
            label='Open Case Threshold'
            description='Optional. An Account is flagged as needing attention when its open Case count is at or above this number. Defaults to 3.'
        )
        global Integer openCaseThreshold;

        @InvocableVariable(
            label='Maximum Accounts To Return'
            description='Optional. Caps how many Accounts come back, between 1 and 50. Defaults to 10.'
        )
        global Integer maxResults;
    }

    global class Response {
        @InvocableVariable(
            label='Accounts Evaluated'
            description='How many Accounts matched the filter criteria.'
        )
        global Integer accountsEvaluated;

        @InvocableVariable(
            label='Accounts Needing Attention'
            description='How many of those Accounts met or exceeded the open Case threshold.'
        )
        global Integer accountsNeedingAttention;

        @InvocableVariable(
            label='Summary'
            description='Human-readable, one line per Account: name, industry, annual revenue, contact count, open case count, and whether it needs attention.'
        )
        global String summary;

        @InvocableVariable(
            label='Status Message'
            description='Explains the outcome, including why zero results were returned or whether limits were applied.'
        )
        global String statusMessage;
    }

Note what the outputs are not: a list of SObjects. Returning raw records makes the assistant do the summarising, which is slower, costs context, and invites invention. Returning accountsEvaluated, accountsNeedingAttention and a formatted summary means the numbers are computed in Apex, where they are correct by construction.

Step 3 — The invocable method

The description on @InvocableMethod is what drives tool selection. Mine states what it does, when to use it, and — importantly — that it changes nothing.

    @InvocableMethod(
        label='Summarize Account Health By Criteria'
        description='Finds Accounts matching an optional industry and minimum annual revenue, then reports each one\'s contact count, open case count, and whether it exceeds an open-case threshold. Use when asked which accounts are at risk, need attention, or have too many open cases. Does not modify any data.'
        category='Account Insights'
    )
    global static List<Response> summarizeAccountHealth(List<Request> requests) {
        List<Response> results = new List<Response>();
        for (Request req : requests) {
            results.add(processOne(req));
        }
        return results;
    }
"Does not modify any data" earns its place. It tells the assistant this tool is safe to reach for while exploring, which makes it far more likely to be used well. State the side effects — or the absence of them — explicitly.

Step 4 — The body, with the guardrails that matter

Five defensive moves, each one guarding against something an assistant will genuinely do.

    private static Response processOne(Request req) {
        Response res = new Response();
        res.accountsEvaluated = 0;
        res.accountsNeedingAttention = 0;
        res.summary = '';

        // 1. Access check. The MCP call runs as the authenticated user,
        //    so this fails cleanly rather than throwing an opaque error.
        if (!Schema.sObjectType.Account.isAccessible()) {
            res.statusMessage = 'You do not have read access to Accounts.';
            return res;
        }

        // 2. Normalise and clamp inputs. Never trust what the agent sends.
        Integer threshold = (req.openCaseThreshold == null || req.openCaseThreshold < 0)
            ? 3 : req.openCaseThreshold;

        Integer rowLimit = (req.maxResults == null || req.maxResults <= 0)
            ? DEFAULT_LIMIT : Math.min(req.maxResults, MAX_ACCOUNTS);

        String  industryFilter = String.isBlank(req.industry) ? null : req.industry.trim();
        Decimal revenueFilter  = req.minAnnualRevenue;

        // 3. Bind variables only. No string concatenation of caller input.
        String soql = 'SELECT Id, Name, Industry, AnnualRevenue, '
                    + '(SELECT Id FROM Contacts LIMIT 200), '
                    + '(SELECT Id FROM Cases WHERE IsClosed = false LIMIT 200) '
                    + 'FROM Account WHERE Id != null';

        if (industryFilter != null) soql += ' AND Industry = :industryFilter';
        if (revenueFilter  != null) soql += ' AND AnnualRevenue >= :revenueFilter';

        soql += ' ORDER BY AnnualRevenue DESC NULLS LAST LIMIT :rowLimit';

        List<Account> accounts;
        try {
            accounts = Database.query(soql);
        } catch (Exception e) {
            res.statusMessage = 'Query failed: ' + e.getMessage();
            return res;
        }

        // 4. An empty result is an answer, not a failure. Say why.
        if (accounts.isEmpty()) {
            res.statusMessage = 'No Accounts matched'
                + (industryFilter != null ? ' industry "' + industryFilter + '"' : '')
                + (revenueFilter  != null ? ' with annual revenue at or above ' + revenueFilter : '')
                + '. Try widening the criteria.';
            return res;
        }

        // 5. Strip fields this user can't see, rather than leaking them.
        SObjectAccessDecision decision = Security.stripInaccessible(
            AccessType.READABLE, accounts
        );
        accounts = (List<Account>) decision.getRecords();

        List<String> lines = new List<String>();

        for (Account acc : accounts) {
            Integer contactCount  = (acc.Contacts == null) ? 0 : acc.Contacts.size();
            Integer openCaseCount = (acc.Cases    == null) ? 0 : acc.Cases.size();
            Boolean needsAttention = openCaseCount >= threshold;

            if (needsAttention) res.accountsNeedingAttention++;

            lines.add(
                acc.Name
                + ' | industry: ' + (acc.Industry == null ? 'not set' : acc.Industry)
                + ' | revenue: '  + (acc.AnnualRevenue == null ? 'not set' : String.valueOf(acc.AnnualRevenue))
                + ' | contacts: ' + contactCount
                + ' | open cases: ' + openCaseCount
                + (needsAttention ? ' | NEEDS ATTENTION' : '')
            );
        }

        res.accountsEvaluated = accounts.size();
        res.summary = String.join(lines, '\n');
        res.statusMessage = 'Evaluated ' + res.accountsEvaluated + ' Account(s). '
            + res.accountsNeedingAttention + ' at or above the threshold of '
            + threshold + ' open case(s).'
            + (accounts.size() == rowLimit ? ' Result limit reached; more may exist.' : '');

        return res;
    }
}

Two of those are worth dwelling on, because they're specific to being called by an AI rather than by a Flow:

with sharing plus stripInaccessible. Sharing rules decide which records come back; Security.stripInaccessible removes fields the running user can't read. You want both — a user with Account access but no revenue visibility should get the row without the number.

Step 5 — Test the guardrails, not just the happy path

The interesting tests are the ones that prove the limits hold, because those are the paths an assistant will actually hit.

@IsTest
private class AccountHealthMcpActionTest {

    @TestSetup
    static void setup() {
        List<Account> accts = new List<Account>{
            new Account(Name = 'High Risk Energy', Industry = 'Energy', AnnualRevenue = 5000000),
            new Account(Name = 'Calm Energy',      Industry = 'Energy', AnnualRevenue = 2000000),
            new Account(Name = 'Small Retail',     Industry = 'Retail', AnnualRevenue = 100)
        };
        insert accts;

        List<Case> cases = new List<Case>();
        for (Integer i = 0; i < 4; i++) {
            cases.add(new Case(AccountId = accts[0].Id, Subject = 'Open ' + i, Status = 'New'));
        }
        cases.add(new Case(AccountId = accts[1].Id, Subject = 'Only one', Status = 'New'));
        insert cases;

        insert new Contact(LastName = 'Tester', AccountId = accts[0].Id);
    }

    @IsTest
    static void flagsAccountsOverThreshold() {
        AccountHealthMcpAction.Request req = new AccountHealthMcpAction.Request();
        req.industry = 'Energy';
        req.openCaseThreshold = 3;

        Test.startTest();
        List<AccountHealthMcpAction.Response> res =
            AccountHealthMcpAction.summarizeAccountHealth(
                new List<AccountHealthMcpAction.Request>{ req }
            );
        Test.stopTest();

        Assert.areEqual(2, res[0].accountsEvaluated, 'Both Energy accounts should match');
        Assert.areEqual(1, res[0].accountsNeedingAttention, 'Only one has 4 open cases');
        Assert.isTrue(res[0].summary.contains('NEEDS ATTENTION'));
    }

    @IsTest
    static void appliesRevenueFilter() {
        AccountHealthMcpAction.Request req = new AccountHealthMcpAction.Request();
        req.minAnnualRevenue = 3000000;

        List<AccountHealthMcpAction.Response> res =
            AccountHealthMcpAction.summarizeAccountHealth(
                new List<AccountHealthMcpAction.Request>{ req }
            );

        Assert.areEqual(1, res[0].accountsEvaluated, 'Only the 5M account qualifies');
    }

    @IsTest
    static void returnsHelpfulMessageWhenNoMatch() {
        AccountHealthMcpAction.Request req = new AccountHealthMcpAction.Request();
        req.industry = 'Nonexistent Industry';

        List<AccountHealthMcpAction.Response> res =
            AccountHealthMcpAction.summarizeAccountHealth(
                new List<AccountHealthMcpAction.Request>{ req }
            );

        Assert.areEqual(0, res[0].accountsEvaluated);
        Assert.isTrue(res[0].statusMessage.contains('No Accounts matched'));
    }

    @IsTest
    static void clampsExcessiveLimit() {
        AccountHealthMcpAction.Request req = new AccountHealthMcpAction.Request();
        req.maxResults = 9999;

        List<AccountHealthMcpAction.Response> res =
            AccountHealthMcpAction.summarizeAccountHealth(
                new List<AccountHealthMcpAction.Request>{ req }
            );

        Assert.isTrue(res[0].accountsEvaluated <= 50, 'Should be clamped to MAX_ACCOUNTS');
    }

    @IsTest
    static void handlesBulkRequests() {
        List<AccountHealthMcpAction.Request> reqs = new List<AccountHealthMcpAction.Request>();
        for (Integer i = 0; i < 3; i++) {
            AccountHealthMcpAction.Request r = new AccountHealthMcpAction.Request();
            r.industry = 'Energy';
            reqs.add(r);
        }

        List<AccountHealthMcpAction.Response> res =
            AccountHealthMcpAction.summarizeAccountHealth(reqs);

        Assert.areEqual(3, res.size(), 'One response per request');
    }
}

Step 6 — Publish it as a tool

Deploy the class. Because it's global with an @InvocableMethod, Salesforce creates the API Catalog entry for you. Then:

  1. Setup → Quick Find → MCP ServersSalesforce Servers → open your custom server.
  2. Add Server AssetsAdd Tools, leave the type on Apex actions, and select the class.
  3. Add ToolSaveActivate.

The server now carries both tools — the Flow-backed one for a single account, this one for a filtered population. Copy the Server URL from the server record; custom servers follow this shape:

https://api.salesforce.com/platform/mcp/v1/custom/YourServerApiName

Sandbox and scratch orgs carry a /sandbox/ segment in that path. Copy it from Setup rather than typing it from the pattern.

Step 7 — Create an External Client App

This is the trust relationship between the client and your org. Classic Connected Apps do not work for this flow — it has to be an External Client App.

Setup → External Client App ManagerNew External Client App.

Click Create. Then:

  1. Policies → OAuth Policies: Permitted Users → All users may self-authorize; IP Relaxation → Relax IP restrictions.
  2. Settings → OAuth Settings → Consumer Key and Secret → complete the verification dialog → copy both.
Give the app time before you connect. An External Client App can take up to 30 minutes to become fully operational — much like a DNS propagation delay. Finish the configuration, then come back to it.

One app serves several servers. If you already created one for the standard servers or for the Flow tool, reuse its Consumer Key and Secret — only the server URL changes.

Step 8 — Add the connector in Claude

In claude.ai: left sidebar → CustomizeConnectors+Add custom connector.

You're redirected to Salesforce to log in. If you're already logged into that org, it may skip the challenge entirely.

Check the connector's tool list before asking anything: both tools should appear. If the connector was already added for the Flow tool, disconnect and reconnect it so it re-reads the list — a client that connected before you added the Apex tool won't know it exists.

Step 9 — Test tool selection

Ask the plural question, without naming the tool:

Which of my Energy accounts need attention right now?
Flag anything with three or more open cases.

What you're checking is tool selection under choice. With two tools available, the assistant has to pick the right one and map "three or more open cases" onto openCaseThreshold. When it does that from a plain sentence, your descriptions are doing their job.

The pattern worth keeping

Sources: Expose Custom Apex as a Hosted MCP Tool for Agents · Build Custom MCP Servers · Hosted MCP Servers: Get Started