TL;DR
MCP servers are powerful, but they can provide access to production systems if anyone on the team can connect and run tools without guardrails.
Imagine a new hire testing the app on their laptop and accidentally granting an MCP server access to the production database. Without governance, that is a realistic path to data leakage.
Bifrost addresses this with three layers:
-
Human-in-the-loop execution โ Bifrost does not auto-execute tool calls. The LLM only suggests tools; your application reviews them and explicitly calls
POST /v1/mcp/tool/execute. -
Deny-by-default tool filtering โ A virtual key with no
mcp_configsgets zero MCP tools. Unlisted clients are implicitly blocked. - Governance (optional) โ RBAC, SSO, audit logs, and MCP Tool Groups control who can configure the gateway and review administrative activity.
Bifrost covers virtual keys, budgets, rate limits, routing, and MCP tool filtering, RBAC, SSO, audit logs, and MCP Tool.
๐ง Using MCP server
First, let's open the app and set up the MCP server. To do this, I'll enter the following line in the terminal:
npx -y @maximhq/bifrost
After that, you will see the following interface (similar, depending on the version):
Go to the "MCP Library" tab and you will see a huge list of pre-configured MCP servers that you can use in your projects.
If you want to set up your own MCP server, go to MCP Gateway and click New MCP Server:
Here you can specify the connection URL, auth type, tool allowlists, and other settings including Code Mode, which can significantly reduce token usage when orchestrating many MCP servers.
โ๏ธ Human-in-the-loop tool
This is the most important security property for the scenario in the introduction.
When an LLM returns tool calls, Bifrost does not automatically execute them. Tool calls are suggestions only. Your application must explicitly approve and execute each one:
1. POST /v1/chat/completions โ LLM returns tool call suggestions (NOT executed)
2. Your app reviews tool calls โ Apply security rules, get user approval if needed
3. POST /v1/mcp/tool/execute โ Execute approved tool calls explicitly
4. POST /v1/chat/completions โ Continue the conversation with tool results
Example execution call:
curl -X POST http://localhost:8080/v1/mcp/tool/execute \
-H "Content-Type: application/json" \
-d '{
"id": "call_xyz789",
"type": "function",
"function": {
"name": "database_query",
"arguments": "{\"sql\": \"SELECT 1\"}"
}
}'
So even if a new hire's agent requests a dangerous database operation, nothing happens until your application deliberately executes it. Combined with deny-by-default virtual key filtering (below), this is Bifrost's real three-layer answer to accidental production access.
You can opt into autonomous execution for specific tools via Agent Mode, but that must be explicitly configured, it is not the default.
๐ป MCP authentication
Authentication is declared on the MCP client itself as a top-level auth_type field, posted to /api/mcp/client. There is no nested auth object.
auth_type |
Who authenticates | When to use |
|---|---|---|
none |
โ | Public MCP servers, local STDIO tools |
headers |
Admin, once | Shared API keys, bearer tokens, custom headers |
oauth |
Admin, once | Shared third-party service the whole team uses |
per_user_oauth |
Each end-user, lazily | Per-user services like Notion, GitHub, Sentry |
per_user_headers |
Each end-user, lazily | Per-user API keys, signed tokens |
OAuth (oauth and per_user_oauth) is only valid for HTTP and SSE connections. Bifrost implements the Authorization Code flow, there is no client-credentials / service-account mode.
1. No auth (development only)
{
"name": "local-tools",
"connection_type": "stdio",
"stdio_config": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-filesystem"]
},
"auth_type": "none",
"tools_to_execute": ["read_file", "list_directory"]
}
2. Static headers (shared API keys)
curl -X POST http://localhost:8080/api/mcp/client \
-H "Content-Type: application/json" \
-d '{
"name": "web_search",
"connection_type": "http",
"connection_string": "https://mcp.example.com/mcp",
"auth_type": "headers",
"headers": {
"Authorization": "Bearer your-api-key",
"X-Tenant-ID": "acme-corp"
},
"tools_to_execute": ["*"]
}'
3. Server-level OAuth (admin authorizes once)
The admin authenticates once during setup. Every subsequent request to that MCP server uses the same stored token, regardless of which caller hit Bifrost.
curl -X POST http://localhost:8080/api/mcp/client \
-H "Content-Type: application/json" \
-d '{
"name": "authenticated_service",
"connection_type": "http",
"connection_string": "https://api.example.com/mcp",
"auth_type": "oauth",
"oauth_config": {
"client_id": "your-client-id",
"client_secret": "your-client-secret",
"authorize_url": "https://auth.example.com/oauth/authorize",
"token_url": "https://auth.example.com/oauth/token",
"scopes": ["mcp:read", "mcp:write"]
},
"tools_to_execute": ["*"]
}'
The oauth_config object accepts client_id, client_secret, authorize_url, token_url, scopes, or registration_url / server_url for Dynamic Client Registration. After the admin completes the authorize step, finalize with POST /api/mcp/client/{id}/complete-oauth.
4. Per-user OAuth (each user authenticates themselves)
Use auth_type: "per_user_oauth" when each end-user must connect under their own account. Bifrost stores one OAuth token per (identity, mcp_client) and reuses it on later calls. Identity is required via virtual key, signed-in SSO user, or x-bf-mcp-session-id.
5. Per-user headers (legacy / custom per-user keys)
curl -X POST http://localhost:8080/api/mcp/client \
-H "Content-Type: application/json" \
-d '{
"name": "acme_api",
"connection_type": "http",
"connection_string": "https://api.acme.example.com/mcp",
"auth_type": "per_user_headers",
"per_user_header_keys": ["X-API-Key", "X-Tenant-ID"],
"tools_to_execute": ["*"]
}'
Identity matters: With auth_type: "oauth" or auth_type: "headers", all callers share the same upstream credential. Bifrost does not attach a per-user identity to MCP requests. To know exactly who performed an action upstream, use per_user_oauth or per_user_headers.
๐ป Runtime tool access control (Virtual Keys)
RBAC does not govern which MCP tools an agent can invoke at runtime. That is controlled by virtual keys and three stacked levels of tool filtering:
-
Client config โ
tools_to_executeon each MCP client (baseline) -
Request headers โ
x-bf-mcp-include-clientsandx-bf-mcp-include-toolsper request -
Virtual key config โ
mcp_configsarray (takes precedence over request headers)
Deny-by-default
This is built-in behavior, not a config setting: a virtual key with no mcp_configs gets zero MCP tools, and clients not listed in mcp_configs are implicitly blocked.
Virtual key configuration
curl -X POST http://localhost:8080/api/governance/virtual-keys \
-H "Content-Type: application/json" \
-d '{
"name": "new-dev-key",
"mcp_configs": [
{
"mcp_client_name": "internal_api",
"tools_to_execute": ["search", "get_article"]
},
{
"mcp_client_name": "staging_database",
"tools_to_execute": ["query"]
}
]
}'
tools_to_execute |
Result |
|---|---|
["*"] |
All tools from this client |
["a", "b"] |
Only specified tools |
[] |
No tools from this client |
Client not in mcp_configs
|
All tools blocked from that client |
This is where you enforce patterns like "backend devs can hit staging APIs but not production databases" by giving different virtual keys different mcp_configs, not by RBAC permission strings.
Per-request narrowing
For one-off restrictions within a virtual key's allowlist:
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer vk_new_dev" \
-H "x-bf-mcp-include-tools: staging_database-query" \
-d '...'
Note: when a virtual key has mcp_configs, it auto-generates x-bf-mcp-include-tools and overrides any manually sent header.
Bifrost does not parse SQL or block operations like DELETE / DROP at the query level. Restrict access by allowing only specific tool names (for example, a read-only query tool instead of an execute tool).
๐ RBAC โ administrative access
Bifrost provides Role-Based Access Control for the administrative surface who can edit MCP gateway configs, read logs, configure guardrails, manage virtual keys, and so on. RBAC is not runtime authorization for agents invoking MCP tools.
Permissions are Resource ร Operation pairs, not permission strings like mcp:tool:invoke.
System roles
| Role | Permissions | Description |
|---|---|---|
| Admin | 42 | Full access to all resources and operations |
| Developer | 27 | CRUD on technical resources, view access to logs and cluster |
| Viewer | 14 | Read-only access to all resources |
You can also create custom roles (for example, an Auditor role with AuditLogs:View and Logs:View only).
Protected resources include
Logs, VirtualKeys, MCPGateway, MCPToolGroups, MCPLogs, GuardrailsConfig, AuditLogs, Cluster, and others.
Operations include
View, Create, Update, Delete, Download, Reveal, and inference operations.
Example: a custom Auditor role might grant AuditLogs:View and AuditLogs:Download, but not MCPGateway:Update. That controls who can configure the gateway in the dashboard, not which tools an agent executes at runtime.
Roles and permissions are managed via Governance โ Roles & Permissions in the dashboard or the /api/roles endpoints:
curl -X GET http://localhost:8080/api/roles/{role_id}/permissions \
-H "Authorization: Bearer <admin_token>"
๐ฅ๏ธ User Provisioning and role mapping
There is no role_sync config block. Role assignment comes from User Provisioning over OIDC, supported for Okta, Microsoft Entra and others.
When SSO is configured:
- Users sign in with corporate credentials via OAuth 2.0 / OIDC (Authorization Code + PKCE)
- Roles are mapped from IdP groups, app roles, or custom claims to Bifrost roles (Admin, Developer, Viewer, or custom roles)
- Role and team assignments are synchronized on each session
- Background reconciliation runs every 24 hours; OIDC session refresh checks run every 15 minutes
- Inactive or deprovisioned users are decommissioned locally (including via inbound SCIM 2.0)
Configuration lives under scim_config in config.json. See the User Provisioning docs for provider-specific setup guides.
๐ Audit logs
Audit logs in Bifrost record administrative activity who changed what, when, and which resource was affected. They do not use a log_level / capture / export_to block.
Real configuration shape:
{
"audit_logs": {
"disabled": false,
"hmac_key": "env.AUDIT_HMAC_KEY",
"retention_days": 365,
"object_storage": {
"type": "s3",
"bucket": "acme-audit-archive",
"prefix": "acme-prod",
"compress": true,
"region": "us-east-1",
"access_key_id": "env.AUDIT_S3_KEY",
"secret_access_key": "env.AUDIT_S3_SECRET"
}
}
}
Key features:
- Signed events โ configure an HMAC key for verification
- Dashboard review โ filter by search text, action, outcome, and date range
-
Export โ JSON, JSON Lines, or Syslog (requires
AuditLogs:Downloadpermission) -
Retention โ
retention_dayscontrols database retention - Object storage archival โ optional mirror to S3/GCS for long-term compliance retention
View audit entries at Governance โ Audit Logs in the dashboard.
โ Implementation best practices
1. Rely on deny-by-default
Do not look for a "policy": "default_deny" setting. It does not exist. Instead:
- Create virtual keys with explicit
mcp_configsfor each team or environment - Set client-level
tools_to_executeto the minimum needed - Leave production database tools off keys used for local development
2. Keep human-in-the-loop as the default
Only enable Agent Mode auto-execution for tools you have explicitly reviewed. The default flow โ chat โ review โ /v1/mcp/tool/execute โ is your strongest safety net.
3. Separate runtime access from admin access
-
Runtime (what agents can do): virtual keys +
mcp_configs+ request headers - Administration (who can change configs): RBAC + SSO
4. Use environment-scoped virtual keys
{
"name": "production-readonly",
"mcp_configs": [
{ "mcp_client_name": "production_database", "tools_to_execute": ["query"] }
]
}
{
"name": "staging-full",
"mcp_configs": [
{ "mcp_client_name": "staging_database", "tools_to_execute": ["*"] }
]
}
5. Configure audit logging early
Enable HMAC signing, set retention_days comfortably above your archival window, and optionally mirror to object storage for compliance.
6. Perform regular access reviews
Schedule quarterly reviews to answer:
- Which virtual keys grant access to production MCP clients?
- Who has Admin or Developer RBAC roles in Enterprise?
- Are there overprivileged virtual keys or dormant SSO accounts?
Use the dashboard and /api/roles endpoints, there is no bifrost audit CLI command. The @maximhq/bifrost-cli package is an interactive launcher for coding agents (Claude Code, Codex CLI, Gemini CLI, Opencode), not an audit tool.
๐๏ธ Conclusion
With Bifrost, you can configure your company's MCP server much more securely. This ready-made solution will save you not only money but also time, which can be spent on product development.
๐ Resources:
- Bifrost GitHub: https://github.com/maximhq/bifrost
- Bifrost Docs: https://docs.getbifrost.ai
-
Bifrost CLI:
npx -y @maximhq/bifrost-cli
Thanks for reading this article! โค๏ธ
I'd love to hear your thoughts on this mode in the comments!



Top comments (13)
The distinction between runtime tool access and administrative RBAC is especially useful. Deny-by-default controls whether an agent can invoke a tool, but there is still another question before execution: whether the proposed action is justified by the available evidence, applicable rules, exceptions, and approval conditions.
For example, a production payment tool may be correctly allow-listed, yet the agent may still lack the required evidence or human authorization for this particular transaction. Have you considered a policy or decision-evaluation step between tool suggestion and /v1/mcp/tool/execute, with explicit outcomes such as approve, deny, unresolved, or escalate?
That's a useful distinction. Bifrost determines what an agent is allowed to access, while the application decides whether a particular request satisfies the organization's policies.
Exactly. The scaling challenge I see is that when this decision remains entirely application-specific, every team ends up inventing its own representation for required evidence, exceptions, missing information, approvals, escalation, and audit reasons.
That is the boundary Iโm exploring with the open-source Judgment Pack Specification (JPS): a portable, testable decision contract between tool suggestion and execution.
JPS would not replace Bifrostโs access controls, credentials, or execution layer. A JPS evaluator could review the proposed action and its context, then return an explicit disposition such as approve, deny, unresolved, or escalate, with the supporting evidence and rules, before Bifrost executes the tool.
Would a pluggable pre-execution decision-evaluator hook fit Bifrostโs architecture?
Something like: proposed tool call + context > external judgment evaluator > disposition >
/v1/mcp/tool/executePer-request narrowing looks like it cancels itself out as written: the note under the example says a virtual key with
mcp_configsauto-generatesx-bf-mcp-include-toolsand overrides anything sent manually, and deny-by-default means a key withoutmcp_configshas zero tools to narrow in the first place. That leaves the manual header with no obvious window. Is it meant for callers who authenticate through SSO orx-bf-mcp-session-idrather than a virtual key, or does the manual header actually intersect with the key's allowlist instead of being replaced?Good observation.
the deny by default virtual key design is the right call. the more common pattern we see is allowlist based keys where you have to explicitly block tools โ any new MCP server added surfaces all its tools to all clients until someone remembers to update every key. deny by default flips that to safe.
the per_user_oauth auth type is what makes enterprise rollout actually viable. shared OAuth tokens mean any team member rotating credentials blocks everyone. per user lazy auth means you can onboard without coordinating a shared secret.
curious how you're handling token refresh for per_user_oauth when the upstream provider (Notion, Sentry) expires tokens mid session. retry transparent to the agent, or surface the 401 upward?
I didn't realize the difference between RBAC and runtime tool access before reading this.
Do you use MCP servers in your work?
Yes.
Interesting little project. We've been working on something similar. Also open sauce, and 100% free of charge! ^_^
Etc, etc, etc ...
Thanks for sharing Thomas. I took a closer look at the repo. The hallucination-resistant part is interesting: Hyperlambda generates a constrained AST, rejects functions that do not exist, and uses RBAC to restrict which functions can execute. I also appreciate that the README clearly distinguishes this from logical correctness - the generated workflow can still make the wrong decision.
That seems highly complementary to what Iโm exploring with JPS. Hyperlambda can guarantee that an operation exists and is permitted, while a Judgment Pack can evaluate whether that operation is justified by the available evidence, rules, exceptions, and approval conditions.
Iโd be interested to know whether Magic supports a pluggable decision-evaluation step before executing the generated AST.
Of course, it's just an MCP server, so you can plug anything into both pre and post processing, and execution. Exactly what did you have in mind?
This is a fantastic breakdown of RBAC and tool access control! ๐
One massive bottleneck we noticed when building enterprise gateways for AI agents is the latency overhead. Traditional middleware often chokes the agent's reasoning loop. We ended up building a zero-latency runtime proxy (Aegisora) specifically to enforce these kinds of least-privilege policies without slowing down the agent. Curious to know what your average latency overhead looks like with this setup?