Here is a question most teams cannot answer:
How many distinct MCP tools can the key behind your production agent call right now? Not how many servers. How many tools.
The difficulty is structural. MCP specifies discovery and invocation and stops there, with no permissions field and no per-tool authorization in the tool schema. Whatever definitions land in a model’s context on a given turn are the capabilities available on that turn. The tool list is doing the job an authorization layer would normally do, and in most deployments it got assembled one server at a time by nobody in particular.
The token bill is the visible cost. Bifrost’s benchmark put 508 tools across 16 servers at 75.1M input tokens and an estimated $377.00 for a 65-query evaluation set. The sharper problem is the one Invariant Labs documented
Descriptions from one connected server can steer an agent toward tools on another, and every variant of that attack needs the tool to be in context first. A definition the model never receives cannot be invoked, shadowed, or prompted into existence.
Start by getting the number.
curl -s http://localhost:8080/api/mcp/clients \ | jq -r '.[] | "\(.config.name)\t\(.tools | length)\t\(.state)"'
Sum the second column. Minus whatever filtering you already have, that is the reach of every unscoped request in your system. Expect it to be higher than you think. Servers add tools between releases, Bifrost refreshes them on a schedule, and a wildcard subscribes you to whatever the upstream maintainer ships next.
Open source Bifrost scopes tools per credential. A virtual key carries MCP client configs, each naming what that key may execute, at tool granularity rather than server granularity.
Four semantics, worth committing to memory:
Enforcement runs twice, once while assembling the tool set for inference and again when an execution request arrives, so a caller sending their own x-bf-mcp-include-tools header cannot widen the key.
The design is sound. It runs out of road when you have more identities than credentials, because policy lives on the credential. A rule like “support can read tickets but not close them” has no name and no home. It exists as a duplicated fragment inside every key belonging to someone in support, and changing it is fourteen edits.
A tool group is a named bundle of MCP tools drawn from one or more servers. What matters is where it attaches: virtual keys, teams, customers, users, LLM providers, and API keys.
curl -X POST http://localhost:8080/api/mcp/tool-groups \ -H "Authorization: Bearer $BIFROST_API_KEY" \ -d '{ "name": "ticketing-readonly", "enabled": true, "tools": [ { "mcp_client_id": "jira", "tool_names": ["search_issues", "get_issue"] }, { "mcp_client_id": "notion" } ], "team_ids": ["team-support"], "provider_names": ["openai"] }'
Omitting tool_names, as with notion above, means every tool from that client. The create call validates that each mcp_client_id points at a deployed client and each tool name exists on its server, so typos fail at config time instead of silently shrinking an allowlist to nothing.
At request time Bifrost reads the entity identifiers on the request, collects groups attached to any of them, drops disabled ones, merges and deduplicates. Matching runs against an in-process index rather than a query, and mutations refresh it and notify other cluster nodes.
The provider dimension is the interesting one. Scope tools by provider and the same workload gets read-only tools on one model and the full set on another, which is useful when you trust one provider further than another.
Tool group resolution is a union. Groups merge across dimensions and deduplicate, so attaching a group widens the visible set and never narrows it. And when no group matches a request’s context, Bifrost applies no filter at all. Every tool is available.
Virtual key MCP config does the opposite. Nothing configured means nothing granted.
Both run in the same gateway. Provision a few tool groups and you can walk away believing the system is locked down, when the identities you covered are scoped and everyone you missed has the run of the place. From the admin console it looks identical to success.
There is a smaller version in the API. Create a group without an explicit enabled field and it comes back disabled, while the web form defaults that toggle to on. A disabled group contributes nothing even when attached to everything, so a Terraform module missing one boolean produces a group that exists, reads correctly in a list view, and drops every request it was meant to govern back into fall-through.
The constraint underneath all of this shows up in the access profile form. Grant a whole MCP server, then add an exclude override for one tool on that server, and it refuses to save. Virtual keys carry positive allowlists per client only, so “everything except this” has nowhere to live in the data model. You are writing allowlists, not rules with carve-outs.
What to build instead: a closed floor with open convenience on top.
Put a restrictive MCP config on the keys that matter so the base case denies, then let tool groups widen outward by team, user, and provider. Union semantics stop being a hazard once they union up from zero.
One more piece of the model. Tool groups union with each other, but filter levels intersect. Client tools_to_execute sets the ceiling, request headers narrow from there, and when the header and the virtual key disagree, the key wins. A client permitting read, write, and delete, a header asking for read and write, and a key restricted to read yields read alone. Hold those the wrong way round and you will debug the layer that is behaving correctly.
Tool groups still leave someone issuing keys by hand. An access profile is a policy template covering providers, models, budgets, rate limits, and MCP access.
Assign it to a user or a role and Bifrost clones it into a per-user copy and issues a virtual key from that copy, marked profile-managed so direct edits are blocked. Someone with key-edit permission cannot loosen their own tool access, because the key is an output of the policy rather than where the policy lives.
Editing the template gives you field-selective propagation, so tightening MCP access across a role is one edit plus a propagate call with three boxes ticked, and nobody’s accumulated spend resets.
Wire that to an identity provider over OIDC and inbound SCIM 2.0 at /scim/v2 and the loop closes. Every SCIM create, replace, or patch re-evaluates that user's role, team, and business unit mappings and broadcasts to all cluster nodes before responding.
Sessions refresh every fifteen minutes and imported users reconcile against the directory every twenty-four hours. Since tool groups attach to teams and teams come from IdP groups, a directory change is a tool access change.
Everything above governs traffic through the gateway. Adding an MCP server to a Claude Code or Cursor config takes thirty seconds and involves no gateway at all.
Edge reads the MCP configuration of supported AI apps per machine, builds a fleet inventory, and enforces allow or deny on the device, including for apps that had the server configured before the policy existed. New servers arrive as pending approvals, with a setting for whether pending means allowed or blocked.
It is in alpha, and it answers a different question from everything above:
Which servers may exist on a machine, rather than which tools a request was permitted to see.
None of this verifies that a tool behaves the way its description claims. Curation limits what can influence what and shrinks the blast radius when something turns hostile. Provenance is a separate problem, solved by signed tool definitions, and treating curation as a substitute for verification reads badly in an incident report.
Back to the opening question. It is only hard while the tool list is something you declare once per deployment instead of something your gateway resolves per identity.
Protocol and security research
Maxim AI engineering
Bifrost documentation
MCP Tool Groups: Curating Tool Access Across Teams, Users, and Providers was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.