Last week I opened a test class I had written months ago — the one behind the lead-scoring tool in my public Urla Shoes org — and read it again from the first line. Nothing in the class had changed. But the caller had. When I wrote those tests, I pictured a person: someone clicking a button, or a fellow developer calling the method from a Flow. Today the caller is a language model, deciding on its own whether to call my Apex at all, which arguments to pass, and what to do with whatever comes back.

So I read with one question in mind: do these tests still prove what this new caller needs proven?

Mostly, yes — and that is the comforting part. The Apex is the same Apex. But three things had quietly become more important than they used to be, and one thing — security — deserved a sharper proof than I had originally given it. Let me walk you through it slowly.

The good news: it is still just an invocable

Earlier in this series we set up a custom tool on Salesforce Hosted MCP Servers, so here is the one-line reminder: a custom tool is an Apex method carrying the @InvocableMethod annotation — the annotation that makes a static method callable from declarative tools — exposed through an McpServerDefinition, the metadata that publishes it as a tool an AI model can call over MCP (the Model Context Protocol, the open standard for connecting models to tools).

Because a tool is just an invocable, everything you already know about testing invocables applies, unchanged:

  • Pure Apex tests. No new framework, no new runner.
  • Bulk safety. Invocables take a List in and return a List out, so tests should pass many inputs in one call, not just one.
  • Null and edge inputs. Empty lists, missing fields, values at the boundary.
  • HttpCalloutMock when the tool calls out — the test interface that lets you fake an external system’s HTTP response, since real callouts are not allowed inside Apex tests.

If you have written solid invocable tests before, you are most of the way there. What changes is not the mechanics. It is the standard.

Change one: determinism stops being optional

Determinism is a plain idea: the same input always produces the same answer. Human callers are forgiving about small wobbles — they retry, shrug, move on.

A model is not, because of what it does between tool calls. It looks at the answer it just received and decides what to do next: call again, call something else, or reply to the user. If your tool answers differently for the same input, the model cannot tell whether calling again will help. You have turned its next decision into a coin flip.

For your tests, this means asserting exact outputs for fixed inputs — not just “a result came back,” but this result. And it means reading your own code hunting for hidden sources of variation. Where behaviour genuinely depends on org data or configuration, know it — and verify in your org that the variation is the kind you intend.

Change two: error messages become part of the interface

I spent twenty years in education before Salesforce, and if that work teaches you one thing, it is this: how you deliver a “no” decides whether the person can do anything with it.

Your MCP tool is now in the same position. When it fails, the model reads the error message — literally reads it — and uses it to decide what happens next. Compare:

  • “Lead not found for email X.” The model can recover: check the address with the user, try a corrected one, or report the miss clearly.
  • “Attempt to de-reference a null object.” This teaches the model nothing. It cannot correct anything, because the message describes your code’s internals, not the caller’s mistake.

So error paths are no longer second-class citizens that merely need to throw something. Your tests should assert the message itself — that a bad input produces a sentence a reader could act on.

A model reads your error messages. Write them so it can recover, and test them as carefully as you test your successes.

Change three: a small contract is a testable contract

Here is a constraint that is easy to forget: the model chooses your tool’s arguments from your parameter names and descriptions alone. Nobody walks it through the code. There is no meeting. If a parameter’s purpose is not obvious from its name, the model will guess — and guesses become bad inputs.

The design answer is a small contract: few parameters, clearly named. The testing answer follows directly, because every parameter multiplies the combinations your edge-input tests must cover. Three well-named parameters can be tested honestly; ten vague ones cannot. These days, when I am tempted to add a parameter, I ask whether I am also prepared to test every combination it creates. Usually the parameter loses.

Prove the boundary: runAs plus USER_MODE

Now the part I sharpened in my own tests. An AI caller gets no security bypass — your tool runs with the permissions of its running user, provided you wrote it that way. That “provided” is exactly what a test should prove, not assume.

Two pieces do the work. System.runAs lets a test execute a block of code as a specific user. WITH USER_MODE is a clause in SOQL — Salesforce’s query language — that makes a query respect the running user’s object and field permissions instead of ignoring them. Put them together: create a minimal-permission user in your test, run the tool as that user, and assert that the query honored that user’s access.

@IsTest
static void toolRespectsUserPermissions() {
    // A user with only the access the tool truly needs
    User minimal = buildMinimalPermissionUser();
    System.runAs(minimal) {
        Test.startTest();
        List<ScoreOutput> results = LeadScoringTool.score(knownInputs());
        Test.stopTest();
        // Assert: nothing this user cannot see appears in the output,
        // and any denial surfaces as a clear, readable message.
    }
}

What “minimal” means lives in your profiles and permission sets, and those differ everywhere — so verify in your org before you trust the test.

What this looks like in a real org

My public Urla Shoes org exposes two invocable actions as MCP tools: a DACH-aware lead score (DACH is Germany, Austria and Switzerland — the German-speaking markets the demo sells into) and a reseller onboarding action. Each tool’s test class walks the same four paths — success, empty input, error, and bulk — with the external callouts mocked. Four paths, every tool, no exceptions.

It is not glamorous, and that is the point. By the time a model is allowed to call either tool, there is no answer it could receive that a test has not already received first.

Before a model may call your code, your tests must already know every answer the model could receive — including the failures.

Your next step

Pick one invocable method you are thinking of exposing as an MCP tool — just one — and open its test class with four questions:

  1. Does the same input always produce the same asserted output?
  2. Are all four paths covered: success, empty, error, bulk?
  3. Is every error message a sentence a model could act on — and asserted word for word?
  4. Is there a runAs test with a minimal-permission user proving your WITH USER_MODE queries hold the line?

If any answer is no, you have found this week’s work. Mine was the fourth question. Same Apex — sharper contract.

Mustafa Aksu

Salesforce developer & ISV builder focused on Revenue Cloud, Agentforce, and Data Cloud. I write from real, shipped work.