Skip to content

fix(mcp): scope the global /mcp tool list to the caller's organization and roles - #532

Merged
keysersoft merged 2 commits into
mainfrom
keysersoft/mcp-global-tool-scoping
Sep 8, 2026
Merged

fix(mcp): scope the global /mcp tool list to the caller's organization and roles#532
keysersoft merged 2 commits into
mainfrom
keysersoft/mcp-global-tool-scoping

Conversation

@keysersoft

Copy link
Copy Markdown
Contributor

tools/list on the global /mcp endpoint returned every tool registered in the deployment to any authenticated caller.

Why

The global registry holds one entry per tool name for the whole instance, and the transport's tools/list handler is synchronous — it can neither query the database nor know which tenant is asking, so it filtered on nothing.

Impact

1. Cross-tenant disclosure. One organization's tool names, descriptions, annotations and input schemas were readable by every other organization. Reproduced by seeding a second tenant locally:

5 tools: [... , 'othertenant_confidential_report']
CROSS-ORG LEAK: ['othertenant_confidential_report']

…returned in full, including the description and input schema. Cloud currently has 433 organizations with tools.

2. Role restriction never reached the listing. A user on a role granting two of four tools saw all four; a user on the DENY_ALL "No access (SSO)" role saw everything. Syncing roles from a directory exists precisely so people stop seeing what they may not use.

Calls were never affected — tools/call resolves by name and organization and refused a mismatch — so this is a confidentiality problem, not an access one. The per-server /mcp/<serverId> endpoint already filtered correctly because it builds a server per request, which is why the gap went unnoticed.

The fix

Each registered tool gets a synthetic tool:<name> role; the controller resolves the caller's visible set asynchronously before delegating, so the synchronous filter has something to work with.

Keyed on name, not tool id: the registry keeps one entry per name across tenants, so the id belongs to whichever tenant registered first — gating on it would hide a tool from everyone else who legitimately has one by that name.

Callers with no resolvable principal (static MCP_API_KEY / MCP_BEARER_TOKEN, or explicitly enabled anonymous mode) keep the previous "everything" answer. Those are operator credentials on a single-tenant self-hosted box; narrowing them would break those deployments for no gain.

The in-handler role check is kept as a second layer, so a tool ever registered without a visibility role is still not freely callable.

Verification

Case Before After
Org A listing org B's tool leaked in full absent
No access (SSO) role 4 tools 0 tools
Role granting 2 of 4 4 tools exactly those 2
User with no role own org's tools unchanged
Allowed tools/call works works
Per-server endpoint correct unchanged

3849 tests pass, plus a regression test pinning the contract — if attachVisibleTools stops running or the registration drops the visibility role, the listing silently reopens.

…n and roles

`tools/list` on the global `/mcp` endpoint returned EVERY tool registered in
the deployment to any authenticated caller.

The global registry holds one entry per tool name for the whole instance, and
the transport's `tools/list` handler is synchronous — it can neither query the
database nor know which tenant is asking, so it filtered on nothing. Two
consequences:

1. **Cross-tenant disclosure.** One organization's tool names, descriptions,
   annotations and input schemas were readable by every other organization.
   Reproduced with a second tenant locally: an org A user listed org B's
   `othertenant_confidential_report` in full. Cloud currently has 433
   organizations with tools.

2. **Role restriction did not reach the listing.** A user on a role granting
   two of four tools saw all four, and one on the DENY_ALL "No access (SSO)"
   role saw everything. The whole point of syncing roles from a directory is
   that people stop seeing what they may not use.

Calls were never affected: `tools/call` resolves by name AND organization and
refused a mismatch, so this is a confidentiality problem, not an access one.
The per-server `/mcp/<serverId>` endpoint already filtered correctly — it
builds a server per request — which is why the gap went unnoticed.

The fix gives each registered tool a synthetic `tool:<name>` role and resolves
the caller's visible set asynchronously in the controller, before delegating to
the transport, so the synchronous filter has an answer to work with. Keyed on
NAME rather than tool id because the registry keeps one entry per name across
tenants; the id belongs to whichever tenant registered first, so gating on it
would hide a tool from everyone else who legitimately has one by that name.

Callers with no resolvable principal — a static MCP_API_KEY or MCP_BEARER_TOKEN,
or an explicitly enabled anonymous mode — keep the previous "everything"
answer. Those are operator credentials on a single-tenant self-hosted box, and
narrowing them would break those deployments for no gain.

Verified end to end: cross-tenant tool gone from the list, DENY_ALL now yields
zero tools, a two-tool role yields exactly those two, a user with no role still
sees their own organization's tools, allowed calls still succeed, and the
per-server endpoint is unchanged.
The visibility role gates `tools/call` as well as `tools/list`, so the
transport already refused these — but with `requires any of roles:
tool:<name>`, which exposes an internal naming scheme and tells an operator
nothing about what to do next.

Answered in the controller instead, before delegating. The wording stays
deliberately ambiguous between "another workspace's tool" and "your role does
not grant it": distinguishing them would confirm that a tool of that name
exists elsewhere in the deployment, which is the disclosure this path exists
to prevent.

The transport's own check remains as the backstop, and still handles batched
requests.
@keysersoft
keysersoft merged commit 9fdc987 into main Sep 8, 2026
11 checks passed
@keysersoft
keysersoft deleted the keysersoft/mcp-global-tool-scoping branch September 8, 2026 19:03
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant