fix(mcp): scope the global /mcp tool list to the caller's organization and roles - #532
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
tools/liston the global/mcpendpoint 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/listhandler 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:
…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/callresolves 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
No access (SSO)roletools/call3849 tests pass, plus a regression test pinning the contract — if
attachVisibleToolsstops running or the registration drops the visibility role, the listing silently reopens.