Expose Any Apex Class to Claude as a Custom MCP Server


Add three annotations to your Apex class. Register it in API Catalog. Now Claude can create Accounts, Contacts, and Opportunities with a single prompt.

Posted on SalesforceBolt.com  |  Apex  |  MCP  |  Claude Desktop

▶ Watch on YouTube — Full Build and Demo
Expose Any Apex Class to Claude as a Custom MCP Server | SF Bolt

Salesforce's hosted MCP servers give Claude read access to your org. But most real business workflows aren't just queries — they create records, run logic, and coordinate across multiple objects. That's where custom MCP servers come in. You annotate an Apex class the same way you would for a Flow invocable action, register it in API Catalog, and Claude can call it directly. No middleware, no custom API layer.

What We Are Building

The demo scenario is new customer onboarding. A sales rep tells Claude: "Set up a new customer — Acme Corp, Technology industry. Primary contact is Sarah Jones, sarah@acme.com. Opportunity is Enterprise Deal, $50,000, closing end of quarter."

Claude calls the custom MCP tool. The Apex method creates the Account, links a Contact to it, and creates an Opportunity — all in one transaction. If anything fails, nothing is created. The rep gets back the three record IDs confirming everything went through.

Prerequisites

  • Claude Desktop installed
  • A Salesforce sandbox or Developer Edition org
  • An External Client App already configured with MCP scopes — if you haven't done this yet, watch the Salesforce Hosted MCP Servers video first

The Apex Class

The class uses @InvocableMethod on the method and @InvocableVariable on every input and output field — the same annotations used for Flow invocable actions. Salesforce reads these annotations and builds the MCP tool schema automatically. You never write JSON schema by hand.

NewCustomerSetupService.cls
global class NewCustomerSetupService {

    global class NewCustomerSetupRequest {

        @InvocableVariable(label='Company Name' required=true
            description='The name of the new company to create as an Account.')
        global String companyName;

        @InvocableVariable(label='Industry'
            description='The industry of the company, optional.')
        global String industry;

        @InvocableVariable(label='Contact First Name' required=true
            description='First name of the primary contact at this company.')
        global String contactFirstName;

        @InvocableVariable(label='Contact Last Name' required=true
            description='Last name of the primary contact at this company.')
        global String contactLastName;

        @InvocableVariable(label='Contact Email' required=true
            description='Email address of the primary contact.')
        global String contactEmail;

        @InvocableVariable(label='Opportunity Name' required=true
            description='Name of the deal or opportunity for this new customer.')
        global String opportunityName;

        @InvocableVariable(label='Opportunity Amount'
            description='Estimated deal value, optional.')
        global Decimal opportunityAmount;

        @InvocableVariable(label='Close Date' required=true
            description='Expected close date for the opportunity.')
        global Date closeDate;
    }

    global class NewCustomerSetupResult {
        @InvocableVariable(label='Account Id')
        global String accountId;

        @InvocableVariable(label='Contact Id')
        global String contactId;

        @InvocableVariable(label='Opportunity Id')
        global String opportunityId;

        @InvocableVariable(label='Status')
        global String status;
    }

    @InvocableMethod(
        label='Set Up New Customer'
        description='Creates an Account, a Contact, and an Opportunity together
for a brand new customer, all in one step. If any part of this fails,
nothing is created, so you never end up with a partial or orphaned record.'
    )
    global static List<NewCustomerSetupResult> setUpCustomer(
        List<NewCustomerSetupRequest> requests
    ) {
        // Build all accounts first — no DML inside the loop
        List<Account> accountsToInsert = new List<Account>();
        for (NewCustomerSetupRequest req : requests) {
            accountsToInsert.add(new Account(
                Name     = req.companyName,
                Industry = req.industry
            ));
        }
        insert accountsToInsert;

        // Build contacts and opportunities using the account IDs
        List<Contact>     contactsToInsert     = new List<Contact>();
        List<Opportunity> opportunitiesToInsert = new List<Opportunity>();

        for (Integer i = 0; i < requests.size(); i++) {
            NewCustomerSetupRequest req = requests[i];
            Id accId = accountsToInsert[i].Id;

            contactsToInsert.add(new Contact(
                FirstName = req.contactFirstName,
                LastName  = req.contactLastName,
                Email     = req.contactEmail,
                AccountId = accId
            ));

            opportunitiesToInsert.add(new Opportunity(
                Name      = req.opportunityName,
                AccountId = accId,
                Amount    = req.opportunityAmount,
                CloseDate = req.closeDate,
                StageName = 'Prospecting'
            ));
        }

        insert contactsToInsert;
        insert opportunitiesToInsert;

        // Build results
        List<NewCustomerSetupResult> results = new List<NewCustomerSetupResult>();
        for (Integer i = 0; i < requests.size(); i++) {
            NewCustomerSetupResult result = new NewCustomerSetupResult();
            result.accountId     = accountsToInsert[i].Id;
            result.contactId     = contactsToInsert[i].Id;
            result.opportunityId = opportunitiesToInsert[i].Id;
            result.status        = 'Success';
            results.add(result);
        }

        return results;
    }
}

Three Things Worth Calling Out in This Class

1. The description on @InvocableMethod is what the agent reads

This is not a code comment for another developer. It is the text Claude reads to decide when to call this tool and what it does. A vague description like "creates customer records" will result in the tool being misused or ignored. Write it in plain language, describe what it creates, and include what happens on failure — as shown in the description above.

2. DML outside the loop — this is critical

The @InvocableMethod parameter is always a List, meaning Salesforce can pass multiple requests in a single call. If the insert statements were inside the loop, ten incoming requests would trigger thirty separate DML operations. Salesforce allows a maximum of 150 DML statements per transaction, so it breaks at scale.

The fix is what you see in the code: build all records into lists inside the loop, then insert each list once outside the loop. Request index three will always line up with Account index three, Contact index three, and Opportunity index three — because all three lists are built in the same loop iteration.

⚠️ AI-generated Apex often gets this wrongIf you use Agentforce Vibes or another AI tool to generate your invocable Apex, always check whether DML is inside the loop. It is one of the most common mistakes in AI-generated Salesforce code. The class will pass tests and deploy successfully — but fail under bulk load.

3. Class, inner classes, and all fields must be global

The outer class, both inner classes (NewCustomerSetupRequest and NewCustomerSetupResult), the method, and every @InvocableVariable field must use the global access modifier. public is not sufficient for MCP server exposure. Salesforce will not surface the method in the Tool picker if the visibility is wrong.

Register the MCP Server in Setup

Once the class is deployed, go to Setup and search for MCP Servers under Integrations, API Catalog. Click the Custom Servers tab and click New.

FieldValue
LabelNew Customer Setup
API NameNewCustomerSetup
DescriptionCreates an Account, Contact, and Opportunity for a new customer in one step

Under Tools, click Add Tool, select Apex Action, and search for NewCustomerSetupService. Salesforce reads the annotations and generates the input and output schema automatically.

Click Save, then Activate. Copy the Server URL shown on the detail page.

⚠️ The Server URL differs by org typeSandbox and scratch orgs use a different URL pattern from production and Developer Edition orgs. Always copy the exact URL from the MCP server detail page — do not try to construct it manually.

Connect to Claude Desktop

Open Claude Desktop. Click Customize in the left sidebar, then Connectors, then +, then Add custom connector.

FieldValue
NameSalesforce New Customer Setup
Remote MCP server URLYour Server URL from the detail page
OAuth Client IDConsumer Key from your External Client App
OAuth Client SecretLeave empty

Click Add, then Connect. Authenticate in Salesforce, approve access, and you are redirected back. The connector shows as Connected.

⚠️ redirect_uri_mismatch errorIf this appears, go back to your External Client App in Setup and confirm the Callback URL is exactly https://claude.ai/api/mcp/auth_callback with no trailing slash or extra characters. Also wait at least 10 to 15 minutes after creating the app before trying to connect.

Demo Prompts

Claude Desktop — prompt examples
# Full new customer setup in one prompt
Set up a new customer. Company is Acme Corp in the Technology industry.
Primary contact is Sarah Jones, sarah@acme.com.
Opportunity name is Enterprise Deal, amount is $50,000,
closing on December 31st.

# Minimal prompt — Claude asks for the missing required fields
Add a new customer called Globe Retail

On the first prompt, Claude calls NewCustomerSetupService, creates the Account, Contact, and Opportunity in one transaction, and returns all three record IDs. On the second prompt, Claude recognises the required fields are missing and asks for them before calling the tool.

Key Takeaways

🚀 Apex as Custom MCP Server — Quick Reference
  • Same annotations as Flow invocable actions: @InvocableMethod on the method, @InvocableVariable on every input and output field
  • Everything must be global — outer class, inner classes, method, and all fields
  • Method signature: List<RequestType> in, List<ResultType> out — always
  • Never put DML inside the loop — build lists inside, insert once outside
  • Write the @InvocableMethod description for Claude, not for another developer
  • Register in Setup: Integrations, API Catalog, MCP Servers, Custom Servers, New
  • Connect in Claude Desktop via Add custom connector with the Server URL and Consumer Key
  • Claude asks for missing required fields before calling the tool — it does not guess

The pattern here applies to any Apex class you already have. Complex multi-object transactions, callouts to external APIs, business logic that plain data access cannot reach — all of it becomes callable from Claude in a natural language prompt. Annotate it, register it, connect it.

📄 Source: Expose Any Apex Class to Claude as a Custom MCP Server — SF Bolt YouTube  |  Source code — github.com/batra-kapil/salesforce-agentforce

Watch Complete Video Below


 If you have any question please leave a comment below.

If you would like to add something to this post please leave a comment below.
Share this blog with your friends if you find it helpful!

Post a Comment

0 Comments