← Back to blog
A pictogram figure pushes a request past three barriers of three different shapes: a solid slab, an open grille, and a third drawn only as a dashed outline

MCP Security Starts With Knowing Who Said No

Three layers can refuse an MCP tool call. See why the source field matters for debugging, least privilege, useful audit trails, and AI agent governance.

Roughly 30 ordinary, read-only MCP calls gave me three different kinds of no.

That was the useful result.

I was running an introspection session against a Salesforce Commerce sandbox through the platform's REST API. The MCP server registered 20 tools for that domain: 12 reads and 8 writes. I called none of the writes; nothing exotic was attempted.

Yet three independent layers refused calls, and each spoke a different dialect.

The system of record returned a typed 404. The MCP tool layer returned a configuration gate with a 503. Claude Code's permission classifier blocked two calls before either lower layer could see them.

So which system said no?

The question sounds basic. It is also the one that makes every other answer useful, and it is where MCP security and AI agent governance actually begin.

The system answered: the resource was missing

The first refusal came from asking the platform for a type that did not exist. The MCP server returned it as a genuine tool error, rather than a success payload containing a fault:

{
  "error": {
    "code": "OCAPI_FAULT",
    "status": 404,
    "message": "There's no system object with object type 'NoSuchObjectType_verify_20260813'.",
    "source": "ocapi",
    "details": {
      "_v": "25.6",
      "fault": {
        "arguments": {
          "systemobjectId": "NoSuchObjectType_verify_20260813"
        },
        "type": "SystemObjectNotFoundException",
        "message": "There's no system object with object type 'NoSuchObjectType_verify_20260813'."
      }
    }
  }
}

The platform's own message was preserved verbatim: “There's no system object with object type 'NoSuchObjectType_verify_20260813'.” More important, its vocabulary survived the trip through MCP. The raw fault remained under details, including the typed exception, SystemObjectNotFoundException.

That is good error design. The tool layer did not sand a precise platform response into “request failed,” leaving the agent to guess what happened.

The field doing the most work is easy to overlook: source: "ocapi". It says the request reached the system of record, the system answered, and the named thing was not there.

Verdict: fix the query.

The gate answered: the request never left

The next refusal looked nothing like the first. I called a log-query tool whose credentials had never been configured:

{
  "error": {
    "code": "NOT_CONFIGURED",
    "status": 503,
    "message": "SFCC WebDAV log credentials are not configured for this repository. Add the Business Manager username, 40-character WebDAV access key, and hostname in the Bridge project settings.",
    "source": "gate",
    "details": {
      "failure_class": "missing",
      "limits": {
        "supported_environments": [
          "production",
          "staging",
          "development"
        ],
        "max_query_range_hours": 24,
        "high_volume_max_range_hours": 6,
        "high_volume_prefixes": [
          "customdebug",
          "debug",
          "info",
          "jobs"
        ],
        "max_selected_prefixes": 5,
        "default_max_entries": 500,
        "max_max_entries": 2000,
        "max_findings": 100,
        "max_snippet_length": 2000
      }
    }
  }
}

There are three useful design choices packed into that refusal.

First, source: "gate" says the request never left the toolchain. The system of record did not reject it because the system of record never received it.

Second, failure_class: "missing" distinguishes credentials that were never configured from credentials that were configured and then rejected. Both may stop the same tool call; they do not call for the same fix.

Third, the refusal returns the limits that would have applied. The query could cover at most 24 hours, or 6 hours for high-volume log classes. It could select up to 5 prefixes, with 500 entries by default and 2,000 at most.

A refusal that gives the agent its operating rules is far more useful than a locked door and a shrug. It turns a dead end into a small specification.

Verdict: fix the configuration.

source is the load-bearing field

Put the first two errors side by side and the distinction becomes plain:

Question System-of-record refusal Tool-layer refusal
source "ocapi" "gate"
code "OCAPI_FAULT" "NOT_CONFIGURED"
status 404 503
Meaning The system answered; the resource was absent The request stopped inside the toolchain
Next move Fix the query Fix the configuration

Status and code help, of course. source carries the security meaning because it identifies the boundary that made the decision.

Without that field, a developer debugging an agent is debugging several systems through one smudged symptom. Was the request malformed? Did the MCP server block it? Did the upstream system apply its own policy? A generic permission error cannot answer any of those questions.

Least privilege assumes attributable denials. You need to know which boundary enforced the rule before you can judge whether that rule was the intended one.

Audit assumes the same thing. A denial you cannot attribute cannot be audited or alerted on, and it cannot be told apart from a bug. Recording that “something failed” is bookkeeping; recording who refused, and why, is evidence.

The harness can refuse before MCP exists

Then the third layer stepped in.

Claude Code's own permission classifier blocked two calls. One was a preference read aimed at a production scope. The other requested a full projection; the same tool succeeded when I asked for a lean projection instead.

I handled that second refusal by narrowing the question, rather than trying to route around the classifier. That is the useful behavior. The harness saw a broader request, stopped it, and allowed the smaller one.

There is a catch, though: neither the MCP server nor the upstream platform knows this happened.

The harness refusal has its own error shape. It never becomes a tool payload that application code can branch on, log, or audit. It exists in the agent transcript and nowhere below that layer.

That makes the harness both real and invisible. It is a security boundary, and a useful one, but any audit design that starts at the MCP server will miss its decisions entirely.

This matters when an engineering lead asks for a list of denied agent actions. Which list? The server can report requests it received. The upstream API can report requests that reached it. Neither can report calls the harness stopped before dispatch.

If those records are later merged, they need to preserve their source. Otherwise, the audit trail turns three separate decision points into one bucket named “denied,” which is about as useful as a smoke alarm that refuses to say which building it is in.

Two nearby mistakes expose another fault line

The same session produced a smaller example that is worth keeping beside the refusal layers.

Name a type that does not exist, and the platform returns the hard 404 shown earlier, complete with a typed exception.

Name an attribute that does not exist on a valid type, and the platform returns HTTP 200:

{
  "_type": "object_attribute_definition_search_result",
  "count": 0,
  "select": "(**)",
  "start": 0,
  "total": 0
}

There is no hits key. It is absent, rather than present as an empty array.

That means result.hits.length works when the query finds something and throws only on the miss path. Two adjacent naming mistakes fail in unrelated ways, and only one reaches error handling.

This is why preserving exact error shapes matters. An agent can be smart enough to repair code and still make the wrong move when every miss, gate, and sparse success gets flattened into the same vague result. Intelligence does not rescue an interface that threw away the distinction.

The 403 I did not produce

No true platform 403 was produced.

The configured client's grants covered every read resource exposed by the 12 read tools. Manufacturing an authorization denial would have required changing permissions in the platform's admin console, which is a write; the session was read-only by rule.

The layer-two example is therefore a capability gate at the MCP boundary, not a platform authorization denial. That distinction belongs in the record. The source finding still holds because it concerns the envelope's ability to name the refusing layer, regardless of which refusal triggered it.

Every layer behaved correctly. This was not a vulnerability, and the different error shapes do not point to broken enforcement. The issue is legibility: can an engineer, agent, or auditor tell which boundary made the decision?

Give every no an address

An enterprise MCP server should make denials attributable. Preserve upstream faults when the upstream system answered. Mark tool-layer gates when the request stopped locally. Return useful limits when they can teach the caller how to form a valid request.

Then account for the layer above MCP. Harness decisions need their own audit path because the server cannot record traffic it never sees.

That is the standard we hold ourselves to on Bridge GPT's AI Accelerator. Coding agents can move quickly, but their boundaries have to stay readable to the humans answerable for the work. A fast refusal with no provenance only starts a faster debugging session.

The session began with roughly 30 routine reads and three kinds of no. The lasting rule is simpler: before fixing the call, find out who said no.

Observed with @bridge_gpt/mcp-server 0.2.38 on Claude Code, August 2026, against a vendor REST API at version v25_6. Error envelope shapes are version-specific and will drift.

About the author

Brian Case headshot

Brian Case

Principal Salesforce Architect & AI Strategist

Brian Case is a Salesforce CTA and AI architect helping Salesforce orgs adopt LLMs, Data Cloud, and Agentforce.