Someone on Hacker News asked last week what people's biggest concern is when building an MCP server. The top answer is permissions: what a user may see on the site and what the agent may see through MCP are two different things, and nobody quite owns the gap between them.
It's a fair worry, and it isn't hypothetical. A survey of 20 reachable public MCP servers published this month found 8 with no authentication at all, and only 2 advertising OAuth discovery.
So here's the question I'd ask of any MCP server that searches company documents, ours included: when the agent calls search, whose permissions does that search run with? There are three parts to a good answer, and most servers have one or two of them.
Part one: who is asking
Most MCP servers that sit in front of a document store authenticate with one key. It goes in a config file, it belongs to the integration, and it can usually read everything the integration was set up to read. Every person who talks to the agent searches with that key.
That works until two people with different access use the same agent. Then the agent shows the intern whatever the key can reach, and the only thing standing between the intern and the board minutes is the system prompt.
The fix is to make the search run as the person. The MCP spec already has the plumbing: the server answers an unauthenticated call with 401 and a pointer to its protected-resource metadata (RFC 9728), and the client runs an OAuth flow against the authorization server named there. That part is the spec's, not ours, and any server can do it. You can see it on our public stand:
curl -si -X POST https://playground.nacre.work/mcp \
-H 'content-type: application/json' -d '{}' | grep -i www-authenticate
# www-authenticate: Bearer resource_metadata="https://playground.nacre.work/.well-known/oauth-protected-resource"
What OAuth gives you is an identity. It doesn't decide what that identity may read. That's the second part, and it's where the interesting failures are.
In Nacre, a connection approved through OAuth is a delegation: it acts as the person who approved it, and the token carries their id rather than a copy of what they can see. The person can narrow it further when they approve it, to some layers or to read-only, but never widen it (docs/authz.md, "Delegated authority"). Service account keys still exist, for unattended pipelines that belong to the organization rather than to anyone in it. They're just not how a person's agent should search.
Part two: where the filter runs
Say the search does run as the right person. The next question is when their permissions are applied.
The easy way is a post-filter: ask the index for the 10 nearest chunks, then drop the ones this person can't read. It's easy because the vector index doesn't need to know anything about permissions. It also breaks in two ways that are hard to spot in testing.
- The count. Ask for 10, get 3, because 7 of the nearest neighbours belonged to a layer you can't read. The agent writes its answer from 3 chunks and has no idea anything is missing. On a small test corpus where everybody can read everything, this never shows up.
- The leak. The 7 dropped chunks still shaped the response. Fewer results than asked for, or a slower answer, or a strange ranking, tells a careful caller that something it can't see matched the query. That's information.
The alternative is a pre-filter: the permission check runs inside the index traversal, so a chunk the person can't read is never a candidate in the first place. Our authorization spec writes the property down as an invariant:
I2. The number of results does not depend on whether the filter ran before or after ranking. A user with access to 1 layer of 20 asking for
top_k=10gets 10 results — not 10 minus whatever was stripped.
And the companion to it, for fetching a single document:
I6. A request without permission is indistinguishable from a request for something that does not exist:
404, never403.
I re-ran the I6 check against the public stand while writing this. A contractor asking for an engineering document that exists gets 404. The same contractor asking for an id that never existed also gets 404, and the two response bodies are identical once the request id is removed. The control is what makes that worth anything: the engineer asking for the same document gets 200. A server that answers 404 to everything would pass the first half.
Part three: how fast a revocation bites
Someone leaves a project. Their access is removed. How long can their agent keep finding the project's documents?
If the server copies permissions into the index, as metadata on each vector, the answer is "until the copy is refreshed". That might be a minute, or it might be the next nightly job.
Nacre doesn't copy them. The permitted set is computed per request from the grants table, and the one cache in front of it is keyed on a version number that every write to grants and groups bumps. So a revoked grant shows up in the very next search, because the next search computes a different key, not because something expired.
We measured it on a local stack in August, holding one bearer token throughout:
| change | next search | time to take effect |
|---|---|---|
| a direct grant revoked | narrower | 79 ms |
| added to a group that holds a grant | 3 → 8 results | 65 ms |
| removed from that group | 8 → 3 results | 58 ms |
Two caveats that belong with those numbers. They cover groups managed through Nacre's own /v1/groups API. Syncing groups from your identity provider over SCIM is a commercial module and isn't in the Apache-2.0 build, so if your directory is the source of truth, the delay that matters is your sync's, and these numbers say nothing about it. And they're one machine on one day. The guarantee is structural, the milliseconds are just what it looked like.
Where this doesn't help
Grants live in Nacre. They aren't mirrored from the source. This is the gap I'd want to know about before anything else, so it goes first.
Nacre doesn't read the sharing settings of the system a document came from. You push documents into layers, through the API, the CLI or one of the reference connectors, and you decide who may read each layer. The connectors map a folder, a bucket prefix or a query to a layer. They don't carry per-file sharing across. The Google Drive one, for instance, walks a folder as a service account and sees what was shared with that account, not who else the file was shared with.
So if your permissions today live in a dozen per-file sharing settings, someone has to turn them into layers and grants, and keep that mapping honest as the source changes. That's real work and it's yours. It's also where systems like this usually go wrong, so if a vendor tells you their connector "inherits permissions automatically", ask what happens when the source changes them and how long until search agrees.
It works best when access already follows a structure you can name (a team, a project, a client, a clearance level) rather than a thousand individual decisions.
A few other things per-user permissions don't fix:
- Prompt injection. A document the person may read can still contain instructions. Scoping what the agent reads to what the person may read limits the damage. It doesn't remove it.
- Grants that are too broad. If everyone has been given read on everything, a perfect pre-filter faithfully returns everything.
- What the model already saw. Revoking a grant stops the next search. It doesn't reach into a conversation that already has the document in it.
Try it: two readers, one query
The public stand has a demo company with two read-only logins printed on demo.nacre.work. They rotate, so copy them from the page rather than from here.
#!/usr/bin/env bash
# Two readers, one query. Passwords: copy them from https://demo.nacre.work
set -euo pipefail
API=https://playground.nacre.work/v1
ORG_DOMAIN=demo.nacre.work
Q='what is the contract number for Northwind'
search_as() { # $1 the login's name (engineer, contractor), $2 its password
tok=$(curl -s "$API/auth/login" -H 'content-type: application/json' \
-d "{\"email\":\"$1@$ORG_DOMAIN\",\"password\":\"$2\",\"organization\":\"demo\"}" | jq -r .access_token)
curl -s "$API/search" -H "authorization: Bearer $tok" -H 'content-type: application/json' \
-d "{\"query\":\"$Q\",\"top_k\":10}" \
| jq -r --arg who "$1" '"\($who): \(.items|length) results from \([.items[].layer]|unique|join(", "))"'
}
search_as engineer "$ENGINEER_PASSWORD"
search_as contractor "$CONTRACTOR_PASSWORD"
What it printed today:
engineer: 8 results from engineering, handbook
contractor: 3 results from handbook
Same query, same index, two different answers, and neither one knows the other exists. Neither login gets the contract number itself, by the way. That's the point of the query.
To do the same through an agent, connect an MCP client to the stand and sign in when the browser opens:
claude mcp add --transport http nacre https://playground.nacre.work/mcp
The stand is a demo, not a hosted product: it's capped at 200 documents and erases itself every 24 hours. The real thing is the Apache-2.0 build you run yourself, on Docker Compose or Kubernetes, and the authorization spec is the document to read before you trust any of the above, including from us.
Originally published at https://nacre.work/blog/whose-permissions-does-your-mcp-search-run-with.
Top comments (1)
The third bullet under "What the model already saw" gets worse once an agent writes session summaries or extracted notes into persistent memory. Even if Nacre cuts off
/v1/searchin under 80 ms after a grant revocation, any derived summary sitting in an unversioned agent memory store still surfaces that text on the next turn unless the memory cache carries the source grant version and re-checks it on read.Your I6 invariant (
404instead of403with matching response bodies) is also worth calling out. Returning403from an MCP tool gives the model a confirmation signal that the document exists, which often triggers retry loops where the agent asks the user to paste the file manually or hunts for adjacent fragments via search.