Skip to content

feat(connectors): per-agent MCP tool-visibility activations (#1005)#1219

Merged
itomek merged 3 commits into
amd:mainfrom
alexey-tyurin:feat/bind-connectors-to-agents
May 28, 2026
Merged

feat(connectors): per-agent MCP tool-visibility activations (#1005)#1219
itomek merged 3 commits into
amd:mainfrom
alexey-tyurin:feat/bind-connectors-to-agents

Conversation

@alexey-tyurin
Copy link
Copy Markdown
Contributor

Summary

Per-agent MCP tool-visibility activations, layered on top of the PR #926 connectors framework. Users now have two independent axes of control: grants (credential access, already shipped) and activations (which MCP-server tools land in an agent's prompt). Activations default to OFF — explicit opt-in per (connector, agent).

Why

Today, every granted MCP connector's tools surface to every agent that holds a grant. A ChatAgent with several MCP servers granted sees ~30 extra tool descriptions in its system prompt — bloating context and degrading small-model tool selection. Issue #1005 asks for a separate signal between "this agent is allowed to use this connector" (grant) and "this agent currently sees its tools" (activation). Activation also gives users a least-privilege opt-in posture: a granted-but-inactive agent cannot select the tools at all, even if a prompt-injection tried to coax it into trying.

Activations apply to MCP-server connectors only. OAuth connectors (Google, etc.) reach providers through native Python @tool functions that call get_credential_sync directly — there is no MCP tool surface for activations to gate. Per-agent control of OAuth access stays on the per-scope grant toggles already shipped by #926. Calling activate() / deactivate() for an OAuth connector is rejected at every boundary (HTTP 400, SDK ConfigurationError, CLI exit 3); the UI hides the "Active for" section on OAuth tiles for the same reason.

Linked issue

Closes #1005

Changes

  • New module src/gaia/connectors/activations.py — ledger at ~/.gaia/connectors/activations.json, mode 0600, atomic-replace writes, per-process write lock, corruption recovery with actionable rm hint. Mirrors grants.py one-to-one in structure and rigour. Low-level API named activate_agent / deactivate_agent / is_agent_active / list_agent_activations / revoke_all_activations_for — matches the grants.py naming pattern (grant_agent, revoke_agent_grant).
  • Schema includes a version field so future evolution has a forward-compat lever from day one:
    {
      "version": 1,
      "activations": {
        "<connector_id>": {
          "<namespaced_agent_id>": true
        }
      }
    }
    Issue feat(connectors): bind MCP connectors to specific agents (per-agent activation) #1005 sketched [agent_id]-list values; this PR ships the object form so per-pair lookups are O(1) and a future false value can carry meaning if needed.
  • src/gaia/connectors/api.py — public orchestration helpers activate / deactivate. activate auto-grants using the agent's declared REQUIRED_CONNECTORS scopes when no prior grant exists (one-click convenience). MCP-only type guard at this layer — every caller (HTTP, CLI, SDK) flows through it. Auto-imports the catalog so bare-CLI callers can't trip "Unknown connector 'mcp-github'" on a cold REGISTRY.
  • src/gaia/mcp/client/mcp_client_manager.py — new tools_for_agent(agent_id) and servers_for_agent(agent_id). Pure filter over the existing per-server tool catalogue. With agent_id=None (CLI/debug context) the unfiltered list is returned — activations only gate the agent-tool path.
  • src/gaia/agents/base/agent.py_active_mcp_servers(manager) helper consumed by ChatAgent's _register_tools. Built-in @tool-decorated functions bypass the filter entirely — activations are an MCP-connector concept, not a Python-decorator one.
  • src/gaia/agents/chat/agent.py + src/gaia/agents/registry.py + src/gaia/ui/_chat_helpers.pyChatAgentConfig.namespaced_agent_id plus an early-init stamp so _register_tools sees the correct identity. Necessary because the registry wrapper used to stamp _gaia_namespaced_agent_id after __init__ returned — meaning the filter never actually fired for any UI Chat session. The wrapper now also injects via kwarg, and _chat_helpers stamps the config before each direct construction. Source-grep guard test catches any future direct ChatAgent(config) site that forgets the stamp.
  • HTTP routes in src/gaia/ui/routers/connectors.pyPUT/DELETE /api/connectors/{id}/activations/{agent_id:path} (CSRF-guarded, type-checked, SSE-emitting). GET /api/connectors/{id}/activations. Existing connector summary gains an activations: Dict[str, bool] field.
  • CLI in src/gaia/connectors/cli.pygaia connectors activations {list|activate|deactivate} with --scopes for explicit auto-grant overrides and --json for machine-readable output. Same exit-code map as the rest of gaia connectors (0/1/2/3/5).
  • Disconnect wipes activations alongside grants in mcp_server.py disconnect paths — re-adding a connector_id never silently inherits prior tool visibility.
  • Frontend src/gaia/apps/webui/src/components/ConnectorsSection.tsx — new "Active for" subsection rendered only when connector.type === 'mcp_server'. New AgentActivationCard mirrors the existing scope-grant card with a single toggle. Optimistic update + rollback on error. Live updates via useConnectorsSSE.
  • Tests (24 new + several migrated)tests/unit/connectors/test_activations.py (file mode, atomicity, corruption, namespacing), test_activation_api.py (orchestration + rejection + cold-start regression), test_disconnect_clears_activations.py (wipe symmetry), router/CLI/E2E extensions, and tests/unit/agents/test_tool_visibility_filter.py (the canonical filter probe). Plus tests/unit/chat/ui/test_chat_helpers.py::TestStampBuiltinChatIdentity — four tests including a source-grep structural guard.
  • Docs — new "Per-agent activation" section in docs/sdk/infrastructure/connectors.mdx; revocation table updated in docs/security/connections.mdx. Both explicitly call out the MCP-only scope of activations.

Why this matters

  • Smaller prompts, better tool selection. A ChatAgent with mcp-github granted + activated for it gets the 26 GitHub tools in its prompt; without activation, those 26 lines never enter the prompt. Small-model tool-call accuracy improves directly with prompt density reduction.
  • Real least-privilege. A user can grant mcp-github to ChatAgent so the credential is available, but leave activation off so a routine "summarise this document" turn cannot reach a mcp_github_create_pull_request tool even if the model wanted to call it.
  • Activations default OFF. Migration story is the strictest reading of the issue: no implicit "everything granted is now active" on upgrade. Users opt in per (connector, agent) from Settings → Connectors → "Active for".
  • Multi-caller equivalence preserved. Whatever rigour PR feat(connections): OAuth PKCE for Google (#915, baseline for connectors framework) #926 set for grants (SDK / CLI / HTTP all see the same ledger, atomic writes, mode 0600), this PR mirrors for activations. New TestActivationsMultiCallerSmoke proves it.

Test plan

  • python util/lint.py --all — clean.
  • python -m pytest tests/unit/connectors/ tests/unit/agents/test_tool_visibility_filter.py tests/unit/chat/ui/test_chat_helpers.py — 480 passed, 3 skipped.
  • cd src/gaia/apps/webui && ./node_modules/.bin/tsc --noEmit — zero errors.
  • Manual CLI round-trip:
    gaia connectors activations deactivate mcp-github builtin:chat			        # clean up
    gaia connectors grants revoke mcp-github builtin:chat					    	# clean up
    gaia connectors activations list mcp-github                                     # empty
    gaia connectors activations activate mcp-github builtin:chat --scopes use       # auto-grants
    gaia connectors activations list mcp-github                                     # builtin:chat: active
    gaia connectors activations deactivate mcp-github builtin:chat
    gaia connectors activations list mcp-github                                     # inactive; grant survives
  • OAuth rejection:
    gaia connectors activations activate google builtin:connectors-demo --scopes openid
    # exit 3; stderr: "Configuration error: Activations apply to MCP-server connectors only…"
  • HTTP rejection: PUT /api/connectors/google/activations/builtin:chat → 400; CSRF still wins (no header → 403); unknown connector → 404.
  • End-to-end LLM-visible effect: with Lemonade + Gemma-4-E4B-it-GGUF, ChatAgent in the UI lists mcp_github_* tools when activated, says "I don't have any GitHub MCP tools" when not.
  • Disconnect-wipe: after granting + activating mcp-github for two agents and disconnecting via UI, both ~/.gaia/connectors/grants.json and ~/.gaia/connectors/activations.json are empty for mcp-github.
  • Agent eval (gaia eval agent --category rag_quality --agent-type doc) — 100% pass (7/7), avg score 9.4. The diff vs. the committed baseline reports +33%/+1.2 but the two scorecards share zero scenarios (baseline pre-dates the current scenario set), so the relevant signal is the absolute number — no regression introduced.

Follow-ups (not in this PR — will file as separate issues)

  • SSE gap for non-router writes. connector.activation.changed only fires from the HTTP router's PUT/DELETE handlers. CLI / SDK toggles write the same ledger but emit nothing, so an open Settings tab won't reflect CLI-driven state changes until the user navigates away/back. Two reasonable fixes: (a) have the CLI POST through the router instead of calling the SDK directly, or (b) add a file-watcher on activations.json that emits SSE on change.
  • "Active for" panel filters by REQUIRED_CONNECTORS. The UI surfaces only agents that declare the connector in their REQUIRED_CONNECTORS class attribute. For mcp-github that's only Connectors DemoChat (builtin:chat) does not appear even though activations can be set for it via CLI/SDK and the runtime filter honours them. Worth widening the panel for MCP-consuming agents that load servers from ~/.gaia/mcp_servers.json.
  • tests/unit/test_file_tools.py hangs. Running the full unit suite (pytest tests/unit/) hangs on this file. Pre-existing — not introduced by this PR — but worth a separate bug.
  • Stale .env.example and LEMONADE_BASE_URL. Both this repo's and a sibling checkout's .env.example still pin LEMONADE_BASE_URL=http://localhost:8000/api/v1, the pre-v10.1 port. Anyone copying .env.example.env against Lemonade v10.1+ gets a silent connection-refused that surfaces as "Lemonade server is not running" — actionable error wording would help, but updating .env.example to 13305 (or removing the line so the built-in default applies) is the cleanest fix.
  • Misleading "Lemonade server is not running" warning. lemonade_manager.py:353 logs that exact string when health_check raises, without naming the URL it tried. Including status.url + status.error would have surfaced the wrong-port mismatch immediately.
  • Stale eval baseline. tests/fixtures/eval_baselines/gemma-4-e4b-d71cd914/scorecard_rag_quality.json was captured against a now-removed scenario list. A refresh with --save-baseline would make future deltas meaningful.
  • CONTRIBUTING.md is silent on fork workflow. Step 2 says "Branch off main" without mentioning that external contributors need to fork first (only AMD maintainers have direct push access). A one-line clarification would help the next external contributor avoid the same "Permission to amd/gaia.git denied" surprise.

Checklist

  • I have linked a GitHub issue above (Closes #1005).
  • I have described why this change is being made, not just what changed.
  • I have run linting and tests locally (python util/lint.py --all, pytest tests/unit/connectors/ …).
  • I have updated documentation if user-visible behavior changed — docs/sdk/infrastructure/connectors.mdx and docs/security/connections.mdx updated.

@kovtcharov
Copy link
Copy Markdown
Collaborator

@claude review this PR.

Copy link
Copy Markdown
Collaborator

@itomek itomek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexey-tyurin — thorough, well-tested PR. The activations design cleanly mirrors the grants ledger (mode 0600, atomic writes, per-process lock), the MCP-only guard is enforced at the shared api.py layer every caller flows through, and the filter is wired into ChatAgent's tool registration with a guard test for the identity stamp. Closes #1005 with docs and 24 new tests. Approving.

Two follow-ups — please track in a follow-up PR or file issues so they aren't lost:

  1. SSE only fires from the HTTP router. connector.activation.changed is emitted from the PUT/DELETE handlers, but CLI/SDK toggles write the same ledger silently — so an open Settings tab won't reflect a CLI-driven change until the user navigates away and back. Worth routing CLI writes through the router, or adding a file-watcher on activations.json.

  2. The "Active for" panel filters by REQUIRED_CONNECTORS. The UI only surfaces agents that declare the connector in REQUIRED_CONNECTORS, so builtin:chat never appears even though activations work for it via CLI/SDK and the runtime filter honors them — a UI/runtime mismatch worth widening for MCP-consuming agents that load servers from ~/.gaia/mcp_servers.json.


Generated by Claude Code

@itomek itomek added this pull request to the merge queue May 28, 2026
Merged via the queue into amd:main with commit 97948f7 May 28, 2026
37 checks passed
@alexey-tyurin
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review and approval, @itomek — really appreciate it.

Filed both follow-ups so they don't get lost:

#1226 — emit connector.activation.changed for CLI/SDK activation writes so the Settings tab stays in sync with CLI toggles
#1227 — widen the "Active for" panel beyond REQUIRED_CONNECTORS so MCP-consuming agents like builtin:chat show up alongside agents that statically declare connectors

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents documentation Documentation changes mcp MCP integration changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(connectors): bind MCP connectors to specific agents (per-agent activation)

3 participants