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 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.
| You need | Reach for |
|---|---|
| To return existing records, shaped | Flow — faster, no code, admin-owned |
| To clamp what the caller can ask for | Apex |
| To compute across related records and return a verdict | Apex |
| To fail with an explanation instead of an exception | Apex |
| To strip fields the running user can't see | Apex |
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.
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:
global. That's what makes the API Catalog entry get created, which is what the MCP server points at.@InvocableVariable. Say whether it's optional, what a valid value looks like, and what the default is. The assistant has no other way to find out.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.
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;
}
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.
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');
}
}
Deploy the class. Because it's global with an @InvocableMethod, Salesforce creates the API Catalog entry for you. Then:
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.
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 Manager → New External Client App.
https://claude.ai/api/mcp/auth_callback; a desktop or CLI client uses a localhost callback instead. Take it from the client's own connector screen rather than guessing.mcp_api, plus refresh_token and offline_access. Use mcp_api — not the standard Salesforce api scope.Click Create. Then:
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.
In claude.ai: left sidebar → Customize → Connectors → + → Add custom connector.
salesforce-account-insights.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.
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.
Sources: Expose Custom Apex as a Hosted MCP Tool for Agents · Build Custom MCP Servers · Hosted MCP Servers: Get Started