Skip to content

security: Org-level MCP server disable is not enforced at runtime #501

Description

@Banald

Description

Disabling an MCP server at the organization level (MCPServers.is_enabled = false) does not stop assistants that already have it attached from using it at chat time. Disabling makes the server disappear from pickers and blocks new attachments, but existing assistant → server associations are never pruned and the runtime never re-checks is_enabled. The disabled server keeps connecting and its tools keep being exposed to the model.

The only ways to actually revoke a server are to delete it (the assistant_mcp_servers rows are removed by FK cascade) or to manually remove it from every assistant.

Steps to reproduce

  1. As admin, enable an MCP server and attach it (with at least one tool enabled) to an assistant in a shared space.
  2. Chat with the assistant and confirm the tool runs.
  3. As admin, disable the server in Organization settings (is_enabled = false).
  4. Chat with the same assistant again.
  5. The tool still executes — the disabled server is still connected and its tools are still offered to the model.

Expected behavior

Disabling a server should revoke it everywhere: assistants should immediately stop being able to call it, regardless of stored associations. (This is already how the governed personal default assistant path behaves — see the asymmetry below — so the two paths are inconsistent.)

Screenshots / logs

No error is produced — that is the problem; the disabled server is used silently. The proxy still counts the disabled server's tools for the request:

[MCPProxy] Built registry with N tools from M servers

Severity

Major (security/governance control broken, no workaround other than deleting the server).

Root cause

is_enabled is enforced at assignment / listing time but not at runtime. The full runtime chain never filters by server.is_enabled:

  1. Disabling only updates the server row; assistant_mcp_servers rows remain (mcp_server_settings_service.py:182-183).
  2. Assistants.mcp_servers is a plain join with no is_enabled filter (assistant_table.py:66-67).
  3. The assistant MCP loader attaches tool overrides but never drops a server for being disabled (space_repo.py:788-920).
  4. ask() loads the assistant via the space and uses its attached servers (assistant_service.py:1513-1514).
  5. For any non-governed assistant, mcp_servers_override stays None (assistant_service.py:1560-1596), so assistant.ask uses self.mcp_servers as-is (assistant.py:385-388, 407).
  6. completion_service passes the list straight to the proxy with no filter (completion_service.py:259-264).
  7. The proxy factory builds credentials for, and connects, every server it is handed (mcp_proxy_factory.py:82-96); its "already filtered by permissions" comment is an unenforced assumption.
  8. The proxy tool registry exposes a server's tools gating only on the tool flag, never server.is_enabled (mcp_proxy_session.py:191-210).

Asymmetry confirming this is a bug, not a design choice: the governed personal-default path re-resolves its allowed server set every turn and does filter is_enabled (effective_config_service.py:101-105[s for s in servers if s.is_enabled]). So the same disable is honored for governed personal default assistants but ignored for all shared/org-space assistants and non-default personal assistants.

Impact

A security/governance control that silently fails to revoke. An admin who disables a server believing it has been cut off (e.g. it was found to be exfiltrating data, its operator was deprovisioned, or it is being decommissioned) has not actually stopped already-attached assistants from continuing to connect to it and send data to it. Precondition: the server was attached (with tools enabled) before being disabled.

Suggested fix

Filter by is_enabled at runtime. The cheapest spots are the ask path (the override computation in assistant_service.py / assistant.py:385-388) or the loader (space_repo.py:788-920): drop any server where not server.is_enabled. The MCPServer entity already carries the flag (mcp_server_table.py:39), so it is a one-line guard.

Environment

  • Branch / commit: develop @ 084614c2
  • Backend: intric (FastAPI / SQLAlchemy)

Related: #500 (same underlying inconsistency — MCP authorization is computed differently across the assignment, listing, and runtime layers).

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend changesbugSomething isn't workingsecuritySecurity-sensitive work or vulnerability tracking

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions