An engineer building an HR agent against SAP SuccessFactors told me his org had given him service credentials only — no per-user API control. He wanted to know how to work with that.
It's the condition that turns a twenty-year-old non-problem into a live one, and it's worth writing down because the fix is small and nobody had told him.
Row-level security in most enterprise systems does not return 403 when your scope
excludes a record. It returns success with the record filtered out. That is deliberate: telling a
caller there is a row here you may not see is itself an information leak. So the API is
behaving correctly.
Which means 200 {"d":{"results":[]}} carries two meanings:
Byte-identical. No amount of careful error handling upstream separates them, because there is nothing to separate — the two states produce the same artifact.
Because a human was the discriminator. Someone logged in as themselves, saw an empty leave balance, and supplied the missing bit from their own head: that can't be right, I booked that in March. That reflex was load-bearing and nobody noticed, because it never failed.
Now the caller is a service credential and a model. One fixed scope for every user. No
intuition, no second source, and a clean 200 behind it. So the agent says
"you have no vacation booked" to someone with three weeks, confidently, and every log
line looks healthy.
I went and read the most-installed SuccessFactors MCP server I could find —
aiadiguru2025/sf-mcp, 11 stars, 43 HR tools, last pushed 2026-07-04.
sf_mcp/client.py:183 if response.status_code != 200: # catches non-200
sf_mcp/client.py:201 details={"reason": "empty_response"} # catches an EMPTY BODY
sf_mcp/client.py:217 data = response.json() # returned
Both guards are real and both are reasonable. That is what makes it look handled.
{"d":{"results":[]}} is not an empty body — it is a well-formed one, so it sails past
line 201 and out through 217.
Downstream, the unwrap .get("d",{}).get("results",[]) appears
56 times across 13 files — including tools/permissions.py, the file
whose subject is role-based permissions. Nothing anywhere in the stack distinguishes the two cases.
If per-user auth is genuinely closed to you — and it usually is, because the SAML bearer assertion path requires every employee to hold the API permission role in RBP, which most tenants never provisioned — then stop fighting the auth layer. Manufacture the discriminator instead.
Hold a canary. For each entity type, keep one record you know exists and know is inside the service account's scope. Query it alongside the real request.
One extra call. No RBP change, no admin ticket, no waiting. It converts an unanswerable question into an answerable one by creating a difference the API deliberately refuses to give you.
found: N empty, and I can confirm the scope covers this could not determine — the scope may exclude this
The third is the whole game. If your tool result cannot express it, the model will read every empty list as a fact about the world, because from where it sits that is the only reading available.
With a shared service credential, the system cannot distinguish "you have no leave" from "I am not allowed to see your leave," and it will state the first one with confidence.
That is an accuracy liability attached to an HR system, and it moves budget in a way that "the auth is untidy" never does.
I measured a code path, not a live tenant. What I can say is that this server cannot distinguish the two cases anywhere in its stack. Whether a specific tenant's RBP config actually produces the ambiguity is a thing only that tenant's owner can check — and the canary is the cheapest way to find out, because it answers directly and you can run it tomorrow.
I also haven't done a full survey of whether someone has already productised this. I looked, found prior art on the pattern in a different substrate (hermes-agent#14806 — approval-pending, executed-empty and failed-silently collapsing into one state), and one search result I could not verify. So treat the mechanism and the fix as the load-bearing parts here, and the novelty claim as absent rather than proven.
Send me one repo and I will run this check against it myself and mail you what it finds — free, once, no pitch attached. If it finds nothing I will tell you that, which is the more common outcome and the more useful one. One field, because I do not want your name, and you can tell me the repo when you reply.
This is one of fourteen. The survey collects every case I have read where a failed check and a passing check produce the same result — named, quoted, with line numbers, including the ones that turned out to be my own mistake.