So far in this series we’ve used the tools Salesforce gave us. Today we build our own.
This is the article where MCP stops being an integration feature and becomes a superpower, because the tool we expose can contain your business logic — your scoring rules, your tier calculations, your definition of a good lead. Once it’s a tool, anyone connected through Claude can invoke that logic with a sentence.
The recipe has two parts, and both are smaller than you’d expect:
- Write an Apex class with an
@InvocableMethod— the same annotation you already know from Flow. - Expose it as an MCP tool via
McpServerDefinitionmetadata.
That’s it. No REST endpoint to hand-roll, no JSON schema to write by hand, no middleware. Let’s build one properly.
The example: qualifying leads at Urla Shoes
In my Urla Shoes org, inbound leads need the same triage every time: score them, and if they’re worth a call, make sure someone actually calls. My rules are simple — company size and buying intent drive the score, and because our strongest resellers are in the DACH region, leads from Germany, Austria, or Switzerland get a bonus. Hot and Warm leads get a follow-up call task created automatically.
That’s LeadQualificationService. (Its sibling in my org, OnboardReseller, follows the exact same pattern at a larger scale — it derives a reseller’s tier and commission and creates the Account, Contact, Opportunity, and Quote in one motion. Same recipe, more DML.)
Step 1: The invocable method, done right
Three habits separate a demo-quality invocable from one you’d let an AI call in a real org. I’ll show the code first, then name them.
public with sharing class LeadQualificationService {
public class QualifyRequest {
@InvocableVariable(label='Lead Id'
description='The Id of the Lead record to qualify' required=true)
public Id leadId;
}
public class QualifyResult {
@InvocableVariable(label='Rating' description='Hot, Warm, or Cold')
public String rating;
@InvocableVariable(label='Score' description='Numeric score, 0-100')
public Integer score;
@InvocableVariable(label='Task Created'
description='True if a follow-up call task was created')
public Boolean taskCreated;
}
@InvocableMethod(
label='Qualify Lead'
description='Scores a lead Hot, Warm, or Cold based on company size and ' +
'intent, with a bonus for DACH countries (DE, AT, CH). Creates a ' +
'follow-up call task for Hot and Warm leads. Returns rating and score.')
public static List<QualifyResult> qualify(List<QualifyRequest> requests) {
Set<Id> leadIds = new Set<Id>();
for (QualifyRequest req : requests) {
leadIds.add(req.leadId);
}
Map<Id, Lead> leads = new Map<Id, Lead>([
SELECT Id, Country, NumberOfEmployees, Description
FROM Lead
WHERE Id IN :leadIds
WITH USER_MODE
]);
Set<String> dach = new Set<String>{ 'Germany', 'Austria', 'Switzerland' };
List<QualifyResult> results = new List<QualifyResult>();
List<Task> tasks = new List<Task>();
for (QualifyRequest req : requests) {
Lead ld = leads.get(req.leadId);
QualifyResult res = new QualifyResult();
Integer score = 20;
if (ld.NumberOfEmployees != null && ld.NumberOfEmployees >= 100) {
score += 30;
}
if (ld.Description != null &&
ld.Description.containsIgnoreCase('bulk')) {
score += 25;
}
if (dach.contains(ld.Country)) {
score += 15; // DACH bonus
}
res.score = score;
res.rating = score >= 70 ? 'Hot' : (score >= 45 ? 'Warm' : 'Cold');
res.taskCreated = res.rating != 'Cold';
if (res.taskCreated) {
tasks.add(new Task(
WhoId = ld.Id,
Subject = 'Call ' + res.rating + ' lead',
ActivityDate = Date.today().addDays(1)
));
}
results.add(res);
}
if (!tasks.isEmpty()) {
insert as user tasks;
}
return results;
}
}
(I’ve trimmed the scoring rules to keep the snippet readable — the real class checks a few more signals — but the structure is exactly this.)
Now, the three habits.
Habit 1: Request and response wrapper classes
The method takes a List<QualifyRequest> and returns a List<QualifyResult> — small inner classes decorated with @InvocableVariable. Don’t pass raw Ids in and Strings out, even when it would work. Wrappers give every input and output a label and description, and those descriptions travel: they’re what describes the tool’s inputs and outputs to Claude. A well-described wrapper field is documentation the AI actually reads.
Habit 2: Bulk-safe, always
Notice the shape: collect Ids into a set, one SOQL query for all leads, build all tasks in a list, one DML at the end. No query or insert inside the loop.
Yes, an MCP call will usually carry one request. Write it bulk-safe anyway. Invocables are called in batches by Flow, and habits you only keep “when it matters” aren’t habits. Twenty years of teaching taught me that you perform the way you practice.
Habit 3: WITH USER_MODE — the non-negotiable one
Look at the query: WITH USER_MODE. Look at the insert: insert as user.
This is the line that makes the whole security story from Parts 2 and 3 true all the way down. Every MCP call runs as the authenticated user — but Apex, by default, runs in system mode and can ignore that user’s field- and object-level security. WITH USER_MODE on SOQL and as user on DML close that gap: the caller’s FLS and object permissions are enforced inside your code, not just at the door.
Without this, you’ve built a polite lie — a tool that authenticates as the user and then quietly reads fields the user can’t see. With it, the promise holds end to end: if the human can’t, the tool can’t. For a tool an AI calls on other people’s behalf, I consider this non-negotiable.
Step 2: Expose it with McpServerDefinition
With the class deployed, the second step is metadata: an McpServerDefinition that registers your invocable as an MCP tool. This is the piece that tells your org’s hosted MCP server — the one we activated back in Part 2 — “this action exists; offer it as a tool.”
Once it’s deployed, the flow you already know takes over: the tool shows up on the server, Claude discovers it on connection, and its label and description become the model’s entire understanding of what it does. Which brings us to the part people underinvest in.
Naming and descriptions: writing for a reader who only reads
Here’s the mental model. When Claude decides which tool to call, it doesn’t see your Apex. It sees the tool’s name, its description, and its input/output descriptions. That’s the whole interview. You’re not documenting for a future developer who can open the class — you’re writing for a reader who will only ever read the label on the box.
My working rules:
Name the tool for the outcome, not the implementation. Qualify Lead beats LeadQualificationServiceInvoker. The model matches your name against phrases like “can you qualify this lead” — meet it there.
Say what it does, when to use it, and what comes back. Reread my @InvocableMethod description above: the scoring inputs, the DACH bonus, the task side effect, the return values. When someone types “score this lead from Vienna,” Claude has everything it needs to know this is the right tool — and that a task will be created as a consequence. Declare side effects explicitly. A tool that quietly creates records is a tool the model will use at the wrong moments.
Draw boundaries between similar tools. My org has both Qualify Lead and Onboard Reseller, and a request like “we have a new company interested” sits ambiguously between them. The descriptions resolve it: one scores an existing lead, the other creates the full account, contact, opportunity, and quote for an approved reseller. If two of your tools could plausibly answer the same sentence, sharpen both descriptions until they can’t.
Describe every input. required=true plus “The Id of the Lead record to qualify” tells the model exactly what to supply and what to ask the human for when it’s missing.
Try it
Deploy, confirm the tool is exposed on your server’s tool list in Setup, reconnect from Claude, and ask in plain language:
“Qualify the lead from Zurich that came in this morning — 300 employees, asked about bulk pricing.”
Claude picks Qualify Lead, passes the Id, and your Apex does the rest: Hot rating (that DACH bonus doing its work), call task created, everything executed as you and fully audited. One sentence in; your business logic out.
Start with one small tool like this. Get its description sharp enough that Claude never picks wrong, watch how people actually invoke it, and only then build the next one. That’s how a tool list stays deliberate — which, if you’ve been with me since Part 2, is where this series began.