I spent a careful half hour last month on a scope list.
This was for the MCP server in my Urla Shoes org — Urla Shoes is a fictional shoe retailer that lives in a real Developer Edition org, and it is where I try things before I trust them. Configuring the External Client App that fronts the connection, I did the responsible thing: read every available scope, ticked the smallest set I could justify, left the rest alone. Then I sat back feeling like someone who had just locked up properly.
A quiet question arrived a few minutes later, and it deflated the whole feeling. Which fields does that list cover?
None. Not one. The scope list I had just agonised over does not mention fields at all, and I had spent half an hour treating it as though it did.
If you are new to this: MCP, the Model Context Protocol, is the open standard that lets an AI client call tools your Salesforce org exposes. A “tool” is a named capability the model can invoke — and in Salesforce, a custom one is Apex. This post is about what actually bounds the data those tools return, because it is not the thing most of us look at first.
What a scope actually bounds
Scopes are real security, and I do not want to talk you out of them. When you register an External Client App and grant it a narrow set of OAuth scopes, you are drawing a genuine boundary: this connection may run agent actions and query data, and it may not manage users or touch metadata. A capability that was never granted cannot be used, no matter how the model phrases its request.
But look closely at what kind of statement a scope makes. It is a statement about capabilities of the connection: which categories of operation this client may perform at all. “May this app query data” is a scope. “May this app see the field the org has labelled GDPR” is not a scope, and no combination of scopes will express it. That question is answered somewhere else entirely.
Scopes bound the connection. The Apex bounds the data. They are different fences, and only one of them is in the setup screen you were looking at.
Your tool is Apex wearing a name badge
The second half of the picture is simple once you say it out loud: an MCP tool is Apex. A custom tool is an Apex method with a name and a description that the model reads, and inputs it can fill in. Underneath the description, it is code — the same code you would write for a button, a trigger, or a batch job.
Which means every rule you already know about Apex applies, unchanged. Nothing about the letters M, C and P alters how a SOQL statement resolves permissions. The tool has no special sandbox, no separate permission layer, no additional filter between its return statement and the model.
And so the sentence that matters is short: whatever a tool’s Apex can reach is what the model can be handed — regardless of the scopes on the connection. The scope let the tool run. The Apex decided what came back.
The default underneath the tool
Now put that together with a fact about Apex that trips up nearly everyone.
In Apex written below API version 67.0, a plain SOQL query runs in system mode. Field-level security and object CRUD are not enforced by the Apex layer. A field the running user cannot see is still readable by code that user triggered, and it can be carried onward from there without anything complaining.
At API version 67.0 and above the default flipped, and plain operations run in user mode. But that version is stamped on each file, not on your org. So “am I in user mode?” has one answer per file, not one per org — and a tool that delegates to three helper classes is asking the question three more times.
The way to stop guessing is to say it on the statement: WITH USER_MODE on SOQL, AccessLevel.USER_MODE on the Database methods. Then the enforcement is visible to any reader, and does not depend on a number in an XML file nobody opens. (Security.stripInaccessible is a different tool, worth knowing: it strips inaccessible fields from records you have already read, rather than refusing the read. For a tool whose whole purpose is to hand its result to a model, refusing is the shape you want.)
Per-user OAuth is true, and it is not the answer here
Hosted MCP has a genuinely good property that gets cited a lot, including by me: every call runs as the authenticated user. Each teammate connects with their own login, so the audit trail carries a real person’s name on every action, with no shared service account quietly borrowing everyone’s authority.
That answers “who did this?” perfectly. It does not answer “what came back?”, because identity only limits the data if something in the execution path actually enforces that user’s permissions — which, below v67, plain SOQL does not. I have caught myself using the first fact as if it settled the second. The identity is the user’s; the reach belongs to the code.
There is no rendering step to hide behind
Here is what makes this sharper for MCP than for ordinary Apex.
In a normal application, an over-broad query is often invisible in practice. You selected nine fields, the page renders three, and the other six die quietly when the transaction ends. Untidy, harmless.
A tool has no rendering step. Its return value goes to a model, which will read all of it — summarise it, paraphrase it, hold it in the conversation, and restate any part of it if the next turn calls for it. Nothing falls away unused. The output is the product.
So the query hygiene you could get away with behind a page layout, you cannot get away with behind a tool description.
Two questions, per tool
When I audit a tool now, I ask two separate questions, because they are separate axes:
- Which records? That is sharing — org-wide defaults, role hierarchy, sharing rules. Governed by the class’s sharing behaviour.
- Which fields and objects? That is field-level security and CRUD. Governed by user mode on the statement.
A class that is strict about which rows while indifferent about which columns still hands the model a field it should never have seen. Both questions, every tool, every time.
Your next step
Take your shortest custom MCP tool — the one you are least worried about. Open the Apex behind it and do three small things:
- List every field in every
SELECT, including the ones inside helper methods it calls. - Check the API version on each of those files.
- Add
WITH USER_MODEand run the tests.
If nothing breaks, you have made the tool honest for free. If something breaks, the tool was reaching past its user, and you have just found it in a sandbox rather than in an audit. Start with the tool you trust most — that is usually where the assumption is oldest.