Expose a Prompt Template as a Custom MCP Server

One plain sentence in, a written account brief out — generated by the platform LLM, grounded in live CRM data, with the lookup done inside the org. A hands-on AI Projects Lab build.

What this builds

A user types a plain sentence:

Give me a renewal-risk brief on United Oil

The assistant calls a tool on a Salesforce-hosted MCP server. Inside the org, an Apex class queries the Account along with its contacts, open cases and open opportunities. That data is merged into a Flex prompt template, the platform LLM writes the brief, and the text comes back.

AI assistant
  │  MCP over HTTPS
  ▼
https://api.salesforce.com/platform/mcp/v1/custom/AccountInsightPrompt
  │
  ├── tool   → AccountInsightPromptMcpAction (Apex, global)
  │              └── ConnectApi.EinsteinLLM
  └── prompt → Account_Insight_Brief (Flex prompt template)
                 │
                 └── data provider → AccountInsightPromptProvider (Apex)
                                       └── SOQL: Account + Contacts
                                                 + open Cases
                                                 + open Opportunities

The important design point: the prompt template does not receive a record Id. It receives an account name. A language model has the customer's name, not a 15-character Id, and asking it for an Id either fails or invites it to invent one. The lookup belongs inside the org.

For beginners: a Flex prompt template is a reusable prompt built in Prompt Builder with named inputs. A data provider is Apex that supplies grounding text to that template. Publishing both on a custom MCP server makes the whole thing callable from outside the org.

Prerequisites

Step 1 — The grounding Apex class

Create this first. Prompt Builder cannot offer a data provider that doesn't yet exist in the org.

Setup → Apex Classes → New, or create the file and deploy.

/**
 * Grounding data provider for the Account_Insight_Brief flex prompt template.
 *
 * Resolves an Account by name and returns its profile, contacts, open cases,
 * and open opportunities as a single block of text. The prompt template merges
 * that text into the prompt, so everything the model says about the account
 * originates here.
 *
 * The lookup takes a name rather than a record Id because the caller is a
 * language model, which has the customer's name and not a 15-character Id.
 */
public with sharing class AccountInsightPromptProvider {
  private static final Integer MAX_CONTACTS = 10;
  private static final Integer MAX_CASES = 15;
  private static final Integer MAX_OPPORTUNITIES = 10;

  public class Request {
    @InvocableVariable(
      label='Account Name'
      description='Name of the Salesforce Account to analyze. A partial name is acceptable.'
      required=true
    )
    public String AccountName;

    @InvocableVariable(
      label='Focus'
      description='What the brief should emphasize, such as renewal risk or support health.'
    )
    public String Focus;
  }

  public class Response {
    @InvocableVariable
    public String Prompt;
  }

  @InvocableMethod(
    label='Get Account Context For Prompt'
    description='Resolves an Account by name and returns its profile, contacts, open cases, and open opportunities as grounding text for the Account Insight Brief prompt template.'
  )
  public static List<Response> getAccountContext(List<Request> requests) {
    List<Response> results = new List<Response>();

    for (Request req : requests) {
      Response res = new Response();
      res.Prompt = buildContext(req);
      results.add(res);
    }
    return results;
  }

  private static String buildContext(Request req) {
    if (!Schema.sObjectType.Account.isAccessible()) {
      return 'NO DATA AVAILABLE: the current user does not have read access to Accounts.';
    }

    if (String.isBlank(req.AccountName)) {
      return 'NO ACCOUNT MATCHED: no account name was supplied.';
    }

    String searchTerm = '%' + String.escapeSingleQuotes(req.AccountName.trim()) + '%';

    List<Account> matches = [
      SELECT
        Id, Name, Industry, Type, Rating, AnnualRevenue, NumberOfEmployees,
        BillingCity, BillingCountry, Website, LastActivityDate,
        (
          SELECT Name, Title, Email, Phone
          FROM Contacts
          ORDER BY CreatedDate DESC
          LIMIT :MAX_CONTACTS
        ),
        (
          SELECT CaseNumber, Subject, Status, Priority, CreatedDate
          FROM Cases
          WHERE IsClosed = FALSE
          ORDER BY CreatedDate DESC
          LIMIT :MAX_CASES
        ),
        (
          SELECT Name, StageName, Amount, CloseDate, Probability
          FROM Opportunities
          WHERE IsClosed = FALSE
          ORDER BY CloseDate ASC
          LIMIT :MAX_OPPORTUNITIES
        )
      FROM Account
      WHERE Name LIKE :searchTerm
      ORDER BY AnnualRevenue DESC NULLS LAST
      LIMIT 1
    ];

    if (matches.isEmpty()) {
      // Stated explicitly so the template's refusal rule can fire. Returning
      // an empty string here invites the model to fill the gap.
      return 'NO ACCOUNT MATCHED: no Salesforce Account name contains "' +
        req.AccountName.trim() +
        '". Do not describe any account; report that no match was found.';
    }

    SObjectAccessDecision decision = Security.stripInaccessible(
      AccessType.READABLE,
      matches
    );
    Account acct = (Account) decision.getRecords()[0];

    List<String> lines = new List<String>();
    lines.add('ACCOUNT PROFILE');
    lines.add('Name: ' + acct.Name);
    lines.add('Industry: ' + orNotSet(acct.Industry));
    lines.add('Type: ' + orNotSet(acct.Type));
    lines.add('Rating: ' + orNotSet(acct.Rating));
    lines.add('Annual revenue: ' + orNotSet(acct.AnnualRevenue));
    lines.add('Employees: ' + orNotSet(acct.NumberOfEmployees));
    lines.add(
      'Location: ' + orNotSet(acct.BillingCity) + ', ' + orNotSet(acct.BillingCountry)
    );
    lines.add('Website: ' + orNotSet(acct.Website));
    lines.add('Last activity: ' + asDate(acct.LastActivityDate));

    lines.add('');
    lines.add(appendContacts(acct));
    lines.add('');
    lines.add(appendOpenCases(acct));
    lines.add('');
    lines.add(appendOpenOpportunities(acct));

    if (String.isNotBlank(req.Focus)) {
      lines.add('');
      lines.add('REQUESTED FOCUS: ' + req.Focus.trim());
    }

    return String.join(lines, '\n');
  }

  private static String appendContacts(Account acct) {
    List<String> lines = new List<String>();
    Integer total = acct.Contacts == null ? 0 : acct.Contacts.size();
    lines.add('CONTACTS (' + total + ' shown, max ' + MAX_CONTACTS + ')');

    if (total == 0) {
      lines.add('None on file.');
      return String.join(lines, '\n');
    }

    for (Contact con : acct.Contacts) {
      lines.add(
        '- ' + con.Name +
          ' | title: ' + orNotSet(con.Title) +
          ' | email: ' + orNotSet(con.Email) +
          ' | phone: ' + orNotSet(con.Phone)
      );
    }
    return String.join(lines, '\n');
  }

  private static String appendOpenCases(Account acct) {
    List<String> lines = new List<String>();
    Integer total = acct.Cases == null ? 0 : acct.Cases.size();
    lines.add('OPEN CASES (' + total + ' shown, max ' + MAX_CASES + ')');

    if (total == 0) {
      lines.add('None open.');
      return String.join(lines, '\n');
    }

    for (Case cse : acct.Cases) {
      Integer ageInDays = cse.CreatedDate.date().daysBetween(Date.today());
      lines.add(
        '- ' + cse.CaseNumber +
          ' | ' + orNotSet(cse.Subject) +
          ' | status: ' + orNotSet(cse.Status) +
          ' | priority: ' + orNotSet(cse.Priority) +
          ' | open ' + ageInDays + ' day(s)'
      );
    }
    return String.join(lines, '\n');
  }

  private static String appendOpenOpportunities(Account acct) {
    List<String> lines = new List<String>();
    Integer total = acct.Opportunities == null ? 0 : acct.Opportunities.size();
    lines.add('OPEN OPPORTUNITIES (' + total + ' shown, max ' + MAX_OPPORTUNITIES + ')');

    if (total == 0) {
      lines.add('None open.');
      return String.join(lines, '\n');
    }

    for (Opportunity opp : acct.Opportunities) {
      lines.add(
        '- ' + opp.Name +
          ' | stage: ' + orNotSet(opp.StageName) +
          ' | amount: ' + orNotSet(opp.Amount) +
          ' | close date: ' + asDate(opp.CloseDate) +
          ' (' + closeTiming(opp.CloseDate) + ')' +
          ' | probability: ' + orNotSet(opp.Probability)
      );
    }
    return String.join(lines, '\n');
  }

  private static String closeTiming(Date closeDate) {
    if (closeDate == null) {
      return 'no close date set';
    }
    Integer days = Date.today().daysBetween(closeDate);

    if (days < 0) {
      return 'OVERDUE by ' + Math.abs(days) + ' day(s)';
    }
    if (days == 0) {
      return 'due today';
    }
    return 'due in ' + days + ' day(s)';
  }

  // String.valueOf(Date) yields '2026-04-15 00:00:00'; the time portion gets
  // reported back as a meeting time.
  private static String asDate(Date value) {
    return value == null
      ? 'not set'
      : DateTime.newInstance(value, Time.newInstance(0, 0, 0, 0)).format('yyyy-MM-dd');
  }

  private static String orNotSet(Object value) {
    return value == null ? 'not set' : String.valueOf(value);
  }
}

Three decisions worth explaining

Related lists are capped at 10–15 rows. Grounding competes with the model's context budget, and a brief citing 200 cases is worse, not better.

The shape Prompt Builder expects. A bare @InvocableMethod, a Request inner class with one @InvocableVariable per input, and a Response inner class exposing a String Prompt. That Prompt member is what becomes the merge field. This matches the pattern in Salesforce's own Flex-template workshop.

Step 2 — The Flex prompt template

Setup → Prompt Builder → New Prompt Template.

Add three free-text inputs

Type Free Text for each. The descriptions matter more than usual — they become the MCP argument descriptions, and they are all the client ever sees.

API NameRequiredDescription
AccountNameYesName of the Salesforce Account to analyze. A partial name is acceptable.
FocusNoWhat the brief should emphasize, such as renewal risk, expansion opportunity, or support health. Defaults to an overall health summary.
OutputFormatNoShape of the result: bullets, paragraph, or JSON. Defaults to bullets.

Attach the Apex data provider

In the resources panel, add a data provider:

Parameters are mapped here, in the builder — not matched by name in Apex.

The template body

You are a Salesforce account analyst. Write a brief using ONLY the account data supplied below.

Requested focus: {!$Input:Focus}
Output format: {!$Input:OutputFormat}

Live Salesforce data for the account:
--- BEGIN SALESFORCE DATA ---
{!$Apex:AccountContext.Prompt}
--- END SALESFORCE DATA ---

Rules:
- Use only the data above. Never invent contacts, cases, amounts, or dates.
- If the data begins with NO ACCOUNT MATCHED or NO DATA AVAILABLE, say exactly that in one sentence and stop. Do not describe any account.
- Call out concrete signals: open case volume, stalled opportunities, missing contact coverage. Cite the numbers you are reasoning from.
- If the requested focus is blank, give an overall health summary.
- If Output format is blank use concise bullets; if it is JSON return valid JSON only, with no prose or code fences.

Use plain --- delimiters rather than triple quotes. Quote characters are HTML-escaped when the template is stored, so the resolved prompt ends up containing literal &quot;&quot;&quot; around the data block.

Preview, then activate

Preview with a real account name and confirm the resolved prompt shows live data between the delimiters. Then Activate.

Activation is mandatory. Salesforce is explicit that draft or inactive templates do not appear in the Setup UI — so an unpublished template is simply absent from the picker when you go to add it to the server, which reads as a broken picker rather than an unpublished template.

Step 3 — The invocable wrapper

This is what makes the template callable as a tool.

/**
 * Invocable wrapper that exposes the Account_Insight_Brief prompt template as
 * an MCP tool.
 *
 * The same template is also published on the server as an MCP prompt, which is
 * user-invoked. This class covers the model-invoked path, so an agent can call
 * the template mid-conversation without the user selecting it first.
 *
 * Declared global so that an API Catalog entry is created automatically; the
 * MCP server binds to that entry.
 */
global with sharing class AccountInsightPromptMcpAction {
  private static final String TEMPLATE_NAME = 'Account_Insight_Brief';

  global class Request {
    @InvocableVariable(
      label='Account Name'
      description='Required. Name of the Salesforce Account to analyze. A partial name works, for example "United Oil" matches "United Oil & Gas Corp.".'
      required=true
    )
    global String accountName;

    @InvocableVariable(
      label='Focus'
      description='Optional. What the brief should emphasize, such as renewal risk, expansion opportunity, or support health. Leave blank for an overall health summary.'
    )
    global String focus;

    @InvocableVariable(
      label='Output Format'
      description='Optional. Shape of the result: bullets, paragraph, or JSON. Defaults to bullets.'
    )
    global String outputFormat;
  }

  global class Response {
    @InvocableVariable(
      label='Brief'
      description='The generated account brief, written from live Salesforce data for the matched Account.'
    )
    global String brief;

    @InvocableVariable(
      label='Status Message'
      description='Explains the outcome, including whether no Account matched or the generation failed.'
    )
    global String statusMessage;
  }

  @InvocableMethod(
    label='Generate Account Insight Brief'
    description='Generates a written brief about a Salesforce Account from its live contacts, open cases, and open opportunities. Use when asked to summarize an account, assess renewal risk, or review account health. Accepts a partial account name. Does not modify any data.'
    category='Account Insights'
  )
  global static List<Response> generateBrief(List<Request> requests) {
    List<Response> results = new List<Response>();

    for (Request req : requests) {
      results.add(processOne(req));
    }
    return results;
  }

  private static Response processOne(Request req) {
    Response res = new Response();

    if (String.isBlank(req.accountName)) {
      res.brief = '';
      res.statusMessage = 'No account name was supplied. Provide the name of the Account to analyze.';
      return res;
    }

    try {
      ConnectApi.EinsteinPromptTemplateGenerationsInput input = new ConnectApi.EinsteinPromptTemplateGenerationsInput();
      input.isPreview = false;

      ConnectApi.EinsteinLlmAdditionalConfigInput config = new ConnectApi.EinsteinLlmAdditionalConfigInput();
      config.applicationName = 'PromptBuilderPreview';
      input.additionalConfig = config;

      input.inputParams = new Map<String, ConnectApi.WrappedValue>{
        'Input:AccountName' => wrap(req.accountName),
        'Input:Focus' => wrap(req.focus),
        'Input:OutputFormat' => wrap(req.outputFormat)
      };

      ConnectApi.EinsteinPromptTemplateGenerationsRepresentation result = ConnectApi.EinsteinLLM.generateMessagesForPromptTemplate(
        TEMPLATE_NAME,
        input
      );

      if (result.generations == null || result.generations.isEmpty()) {
        res.brief = '';
        res.statusMessage = 'The prompt template returned no content. It may be unpublished or blocked by a safety filter.';
        return res;
      }

      res.brief = result.generations[0].text;
      res.statusMessage =
        'Brief generated from live Salesforce data for "' +
        req.accountName.trim() +
        '".';
    } catch (Exception e) {
      // Returned as text rather than rethrown, so the calling agent sees a
      // usable reason instead of a generic tool failure.
      res.brief = '';
      res.statusMessage = 'Could not generate the brief: ' + e.getMessage();
    }
    return res;
  }

  private static ConnectApi.WrappedValue wrap(String value) {
    ConnectApi.WrappedValue wrapped = new ConnectApi.WrappedValue();
    wrapped.value = String.isBlank(value) ? '' : value.trim();
    return wrapped;
  }
}

Two details that are easy to get wrong:

Verify it before wiring anything up, in Developer Console → Debug → Open Execute Anonymous Window:

AccountInsightPromptMcpAction.Request r = new AccountInsightPromptMcpAction.Request();
r.accountName = 'United Oil';
r.focus = 'renewal risk';

System.debug(AccountInsightPromptMcpAction.generateBrief(
    new List<AccountInsightPromptMcpAction.Request>{ r })[0].brief);

Step 4 — The custom MCP server

Setup → Quick Find "MCP Servers" → Salesforce Servers tab → Create Salesforce MCP Server.

Click Create, then add both assets:

  1. Add Server Assets → Add Prompts — choose Prompt Builder as the backing type and select Account Insight Brief. Only activated Flex templates appear here.
  2. Add Server Assets → Add Tools — find AccountInsightPromptMcpAction. It appears because the class is global, which creates the API Catalog entry automatically.
  3. Activate, then use the Test button. It lists the exposed assets and confirms the server responds with no client in the path — the fastest way to tell a server problem from a client problem later.

Copy the server URL:

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

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

Set the tool description carefully. It is the entire basis on which a model decides whether to call the tool. Include the trigger phrases you expect — "summarize an account", "renewal risk", "account health" — and state plainly that it does not modify data.

Step 5 — Connect the client

In claude.ai: Settings → Connectors → Add custom connector, paste the server URL, and complete the Salesforce OAuth login. The full External Client App setup — mcp_api scope, PKCE, Consumer Key and Secret — is covered step by step in the standard-server build and is identical here; only the URL differs.

Both the prompt and the tool run as the authenticated user. Grounding data resolves under that user's permissions, so a user without read access to Cases gets a brief with no cases in it rather than an error. Confirm the connecting user has Prompt Builder access and read on Account, Contact, Case and Opportunity.

Reconnect after changing server assets. The client caches the asset manifest at connection time. A tool added afterwards is invisible until you disconnect and reconnect — and the model improvises around the gap rather than reporting an error, which is a silent failure mode.

Step 6 — Testing, with real output

Confirm the tool is visible first:

List every tool and prompt you can see from the Account Insight Prompt
connector. Just the names. Do not call anything.
Use an account name from your own org. Every example below says United Oil, which comes from the standard Developer Edition sample data. Your org may not have it — and asking for an account that doesn't exist returns the no-match refusal, which looks like a broken build when you were expecting a brief. Pick a real name first:
SELECT Name FROM Account ORDER BY AnnualRevenue DESC NULLS LAST LIMIT 5
Pick one with related contacts, open cases and open opportunities — an account with none of those produces a technically correct but very dull brief. Substitute that name everywhere United Oil appears.

Then the real request — Give me a renewal-risk brief on <your account name>. Against Developer Edition sample data, an excerpt of the actual returned brief:

United Oil & Gas Corp. — Renewal Risk Brief.

Summary assessment.
- Renewal risk is elevated based on stalled and overdue opportunities and open
  support issues. The account currently shows 4 open opportunities totaling
  $1,340,000.00 and 2 open cases.

Concrete signals.
- Open opportunity volume and status. Four open opportunities sum to
  $1,340,000.00. Two opportunities in Negotiation/Review are overdue and high
  probability: $270,000.00 (close date 2026-04-15, overdue by 111 days,
  probability 90) and $125,000.00 (close date 2026-04-19, overdue by 107 days,
  probability 90).
- Open case volume and age. Two open cases are New and low priority, both open
  12 days (00001002 and 00001024).
- Contact coverage. Four executive contacts are listed: SVP Production,
  SVP Technology, CEO, and CFO. Last activity is not set.

Every figure there is real. Cross-check it directly:

SELECT Name,
       (SELECT Id FROM Contacts),
       (SELECT CaseNumber FROM Cases WHERE IsClosed = false),
       (SELECT Name, Amount FROM Opportunities WHERE IsClosed = false)
FROM Account
WHERE Name LIKE '%<your account name>%'

The test that actually matters

Ask for an account that does not exist:

Now do the same for an account called "Definitely Not A Customer"

The verified response, in full:

NO ACCOUNT MATCHED: no Salesforce Account name contains
"Definitely Not A Customer".

An integration that isn't really reading from Salesforce invents a plausible company here. This single test is worth more than any number of successful happy-path runs.

Two more checks worth running

The format default is guidance, not a guarantee. The template says to use bullets when OutputFormat is blank, but a blank value will sometimes still come back as JSON — it's an instruction to a model, not a switch. If a downstream system depends on the shape, pass OutputFormat explicitly rather than relying on the default.

Tests

ConnectApi.EinsteinLLM can't be mocked and consumes credits, so the wrapper's generation path is verified manually with Execute Anonymous. The grounding provider, though, is ordinary Apex and deserves real coverage — it's where a malformed request from a language model does damage.

@IsTest
private class AccountInsightPromptProviderTest {
  private static Account makeAccount(String name) {
    Account acct = new Account(
      Name = name,
      Industry = 'Energy',
      AnnualRevenue = 1000000,
      BillingCity = 'Melbourne'
    );
    insert acct;
    return acct;
  }

  private static String runFor(String accountName, String focus) {
    AccountInsightPromptProvider.Request req = new AccountInsightPromptProvider.Request();
    req.AccountName = accountName;
    req.Focus = focus;

    List<AccountInsightPromptProvider.Response> results = AccountInsightPromptProvider.getAccountContext(
      new List<AccountInsightPromptProvider.Request>{ req }
    );

    Assert.areEqual(1, results.size(), 'One response per request');
    return results[0].Prompt;
  }

  @IsTest
  static void groundsWithProfileContactsCasesAndOpportunities() {
    Account acct = makeAccount('Northwind Energy Group');

    insert new Contact(
      AccountId = acct.Id,
      LastName = 'Okafor',
      Title = 'Head of Procurement'
    );
    insert new Case(
      AccountId = acct.Id,
      Subject = 'Meter reading disputed',
      Status = 'New',
      Priority = 'High'
    );
    insert new Opportunity(
      AccountId = acct.Id,
      Name = 'FY27 Renewal',
      StageName = 'Negotiation/Review',
      Amount = 250000,
      CloseDate = Date.today().addDays(45)
    );

    Test.startTest();
    String grounding = runFor('Northwind Energy Group', 'renewal risk');
    Test.stopTest();

    Assert.isTrue(grounding.contains('Northwind Energy Group'), 'Account name is present');
    Assert.isTrue(grounding.contains('Okafor'), 'Contact carried through');
    Assert.isTrue(grounding.contains('Meter reading disputed'), 'Open case carried through');
    Assert.isTrue(grounding.contains('FY27 Renewal'), 'Open opportunity carried through');
    Assert.isTrue(
      grounding.contains('REQUESTED FOCUS: renewal risk'),
      'Focus is passed through'
    );
  }

  // Past close dates are the clearest stall signal in the payload, so the
  // wording is asserted rather than just the opportunity's presence.
  @IsTest
  static void labelsPastCloseDatesAsOverdue() {
    Account acct = makeAccount('Stalled Deals Ltd');

    insert new Opportunity(
      AccountId = acct.Id,
      Name = 'Long Overdue Renewal',
      StageName = 'Negotiation/Review',
      Amount = 90000,
      CloseDate = Date.today().addDays(-30)
    );
    insert new Opportunity(
      AccountId = acct.Id,
      Name = 'Upcoming Expansion',
      StageName = 'Proposal/Price Quote',
      Amount = 40000,
      CloseDate = Date.today().addDays(21)
    );

    Test.startTest();
    String grounding = runFor('Stalled Deals Ltd', null);
    Test.stopTest();

    Assert.isTrue(
      grounding.contains('OVERDUE by 30 day(s)'),
      'A past close date reads as overdue, not as a negative number'
    );
    Assert.isTrue(
      grounding.contains('due in 21 day(s)'),
      'A future close date reads as a countdown'
    );
    Assert.isFalse(grounding.contains('00:00:00'), 'Dates render without a time portion');
  }

  @IsTest
  static void resolvesFromAPartialName() {
    makeAccount('Burlington Textiles Corp of America');

    Test.startTest();
    String grounding = runFor('Burlington', null);
    Test.stopTest();

    Assert.isTrue(
      grounding.contains('Burlington Textiles Corp of America'),
      'Partial name resolves to the full account'
    );
    Assert.isFalse(
      grounding.contains('REQUESTED FOCUS'),
      'No focus section when none was requested'
    );
  }

  @IsTest
  static void reportsNoMatchInsteadOfReturningNothing() {
    makeAccount('Real Account Pty Ltd');

    Test.startTest();
    String grounding = runFor('Definitely Not A Customer', null);
    Test.stopTest();

    Assert.isTrue(
      grounding.startsWith('NO ACCOUNT MATCHED'),
      'The miss is explicit, so the template can refuse to invent an account'
    );
    Assert.isTrue(
      grounding.contains('Definitely Not A Customer'),
      'The failed search term is echoed back'
    );
  }

  @IsTest
  static void treatsBlankNameAsAMiss() {
    Test.startTest();
    String grounding = runFor('   ', null);
    Test.stopTest();

    Assert.isTrue(
      grounding.startsWith('NO ACCOUNT MATCHED'),
      'A blank name must not fall through to a wildcard match'
    );
  }

  @IsTest
  static void statesAbsenceExplicitlyWhenRelatedListsAreEmpty() {
    makeAccount('Bare Account Ltd');

    Test.startTest();
    String grounding = runFor('Bare Account Ltd', null);
    Test.stopTest();

    Assert.isTrue(grounding.contains('CONTACTS (0 shown'), 'Empty contact list is labelled');
    Assert.isTrue(grounding.contains('OPEN CASES (0 shown'), 'Empty case list is labelled');
    Assert.isTrue(
      grounding.contains('None open.'),
      'Absence is stated in words rather than omitted'
    );
  }

  @IsTest
  static void handlesSeveralRequestsInOneInvocation() {
    makeAccount('Alpha Holdings');
    makeAccount('Beta Industries');

    AccountInsightPromptProvider.Request first = new AccountInsightPromptProvider.Request();
    first.AccountName = 'Alpha Holdings';

    AccountInsightPromptProvider.Request second = new AccountInsightPromptProvider.Request();
    second.AccountName = 'Beta Industries';

    Test.startTest();
    List<AccountInsightPromptProvider.Response> results = AccountInsightPromptProvider.getAccountContext(
      new List<AccountInsightPromptProvider.Request>{ first, second }
    );
    Test.stopTest();

    Assert.areEqual(2, results.size(), 'One response per request, in order');
    Assert.isTrue(results[0].Prompt.contains('Alpha Holdings'), 'First request resolved');
    Assert.isTrue(results[1].Prompt.contains('Beta Industries'), 'Second request resolved');
  }
}

The wrapper's own tests cover the input guards that run before the LLM call, plus the positional bulk contract:

/**
 * ConnectApi.EinsteinLLM cannot be mocked and consumes credits, so the
 * generation path is verified manually against the org. Covered here are the
 * input guards that run before that call and the positional bulk contract.
 */
@IsTest
private class AccountInsightPromptMcpActionTest {
  private static AccountInsightPromptMcpAction.Request buildRequest(String accountName) {
    AccountInsightPromptMcpAction.Request req = new AccountInsightPromptMcpAction.Request();
    req.accountName = accountName;
    return req;
  }

  @IsTest
  static void refusesBlankAccountNameWithoutCallingTheModel() {
    Test.startTest();
    List<AccountInsightPromptMcpAction.Response> results = AccountInsightPromptMcpAction.generateBrief(
      new List<AccountInsightPromptMcpAction.Request>{ buildRequest('  ') }
    );
    Test.stopTest();

    Assert.areEqual(1, results.size(), 'One response per request');
    Assert.areEqual('', results[0].brief, 'No brief is produced');
    Assert.isTrue(
      results[0].statusMessage.contains('No account name was supplied'),
      'The caller is told what to fix'
    );
  }

  @IsTest
  static void refusesNullAccountName() {
    Test.startTest();
    List<AccountInsightPromptMcpAction.Response> results = AccountInsightPromptMcpAction.generateBrief(
      new List<AccountInsightPromptMcpAction.Request>{ buildRequest(null) }
    );
    Test.stopTest();

    Assert.areEqual('', results[0].brief, 'No brief is produced');
    Assert.isTrue(
      results[0].statusMessage.contains('No account name was supplied'),
      'A null name behaves like a blank one'
    );
  }

  @IsTest
  static void returnsOneResponsePerRequestInOrder() {
    Test.startTest();
    List<AccountInsightPromptMcpAction.Response> results = AccountInsightPromptMcpAction.generateBrief(
      new List<AccountInsightPromptMcpAction.Request>{
        buildRequest(''),
        buildRequest(null),
        buildRequest('   ')
      }
    );
    Test.stopTest();

    // Invocable responses are matched to requests by index.
    Assert.areEqual(3, results.size(), 'One response per request');
    for (AccountInsightPromptMcpAction.Response res : results) {
      Assert.isNotNull(res.statusMessage, 'Every response carries a status');
    }
  }
}
sf apex run test -t AccountInsightPromptProviderTest -t AccountInsightPromptMcpActionTest -w 10

This is the third build in the custom-MCP series — after a Flow-backed tool and invocable Apex with guardrails. The pattern is the same each time: your logic stays where it is, and becomes reachable from somewhere else.

Sources: Prompt Builder on Hosted MCP Servers · Build Custom MCP Servers · Resolve a Prompt Template (Apex Developer Guide) · ConnectApi.EinsteinLLM · Workshop: Create a Flex Template · Create a Flex Prompt Template (Salesforce Help)