feat(connectors): per-agent MCP tool-visibility activations (#1005)#1219
Conversation
|
@claude review this PR. |
itomek
left a comment
There was a problem hiding this comment.
@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:
-
SSE only fires from the HTTP router.
connector.activation.changedis 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 onactivations.json. -
The "Active for" panel filters by
REQUIRED_CONNECTORS. The UI only surfaces agents that declare the connector inREQUIRED_CONNECTORS, sobuiltin:chatnever 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
|
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 |
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
ChatAgentwith 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
@toolfunctions that callget_credential_syncdirectly — 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. Callingactivate()/deactivate()for an OAuth connector is rejected at every boundary (HTTP 400, SDKConfigurationError, CLI exit 3); the UI hides the "Active for" section on OAuth tiles for the same reason.Linked issue
Closes #1005
Changes
src/gaia/connectors/activations.py— ledger at~/.gaia/connectors/activations.json, mode 0600, atomic-replace writes, per-process write lock, corruption recovery with actionablermhint. Mirrorsgrants.pyone-to-one in structure and rigour. Low-level API namedactivate_agent/deactivate_agent/is_agent_active/list_agent_activations/revoke_all_activations_for— matches thegrants.pynaming pattern (grant_agent,revoke_agent_grant).versionfield so future evolution has a forward-compat lever from day one:{ "version": 1, "activations": { "<connector_id>": { "<namespaced_agent_id>": true } } }[agent_id]-list values; this PR ships the object form so per-pair lookups are O(1) and a futurefalsevalue can carry meaning if needed.src/gaia/connectors/api.py— public orchestration helpersactivate/deactivate.activateauto-grants using the agent's declaredREQUIRED_CONNECTORSscopes 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— newtools_for_agent(agent_id)andservers_for_agent(agent_id). Pure filter over the existing per-server tool catalogue. Withagent_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.py—ChatAgentConfig.namespaced_agent_idplus an early-init stamp so_register_toolssees the correct identity. Necessary because the registry wrapper used to stamp_gaia_namespaced_agent_idafter__init__returned — meaning the filter never actually fired for any UI Chat session. The wrapper now also injects via kwarg, and_chat_helpersstamps the config before each direct construction. Source-grep guard test catches any future directChatAgent(config)site that forgets the stamp.src/gaia/ui/routers/connectors.py—PUT/DELETE /api/connectors/{id}/activations/{agent_id:path}(CSRF-guarded, type-checked, SSE-emitting).GET /api/connectors/{id}/activations. Existing connector summary gains anactivations: Dict[str, bool]field.src/gaia/connectors/cli.py—gaia connectors activations {list|activate|deactivate}with--scopesfor explicit auto-grant overrides and--jsonfor machine-readable output. Same exit-code map as the rest ofgaia connectors(0/1/2/3/5).mcp_server.pydisconnect paths — re-adding a connector_id never silently inherits prior tool visibility.src/gaia/apps/webui/src/components/ConnectorsSection.tsx— new "Active for" subsection rendered only whenconnector.type === 'mcp_server'. NewAgentActivationCardmirrors the existing scope-grant card with a single toggle. Optimistic update + rollback on error. Live updates viauseConnectorsSSE.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, andtests/unit/agents/test_tool_visibility_filter.py(the canonical filter probe). Plustests/unit/chat/ui/test_chat_helpers.py::TestStampBuiltinChatIdentity— four tests including a source-grep structural guard.docs/sdk/infrastructure/connectors.mdx; revocation table updated indocs/security/connections.mdx. Both explicitly call out the MCP-only scope of activations.Why this matters
ChatAgentwith 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.mcp-githubto ChatAgent so the credential is available, but leave activation off so a routine "summarise this document" turn cannot reach amcp_github_create_pull_requesttool even if the model wanted to call it.(connector, agent)from Settings → Connectors → "Active for".TestActivationsMultiCallerSmokeproves 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.gaia connectors activations activate google builtin:connectors-demo --scopes openid # exit 3; stderr: "Configuration error: Activations apply to MCP-server connectors only…"PUT /api/connectors/google/activations/builtin:chat→ 400; CSRF still wins (no header → 403); unknown connector → 404.mcp_github_*tools when activated, says "I don't have any GitHub MCP tools" when not.mcp-githubfor two agents and disconnecting via UI, both~/.gaia/connectors/grants.jsonand~/.gaia/connectors/activations.jsonare empty formcp-github.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)
connector.activation.changedonly 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 onactivations.jsonthat emits SSE on change.REQUIRED_CONNECTORS. The UI surfaces only agents that declare the connector in theirREQUIRED_CONNECTORSclass attribute. Formcp-githubthat's onlyConnectors Demo—Chat(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.pyhangs. Running the full unit suite (pytest tests/unit/) hangs on this file. Pre-existing — not introduced by this PR — but worth a separate bug..env.exampleandLEMONADE_BASE_URL. Both this repo's and a sibling checkout's.env.examplestill pinLEMONADE_BASE_URL=http://localhost:8000/api/v1, the pre-v10.1 port. Anyone copying.env.example→.envagainst Lemonade v10.1+ gets a silent connection-refused that surfaces as "Lemonade server is not running" — actionable error wording would help, but updating.env.exampleto13305(or removing the line so the built-in default applies) is the cleanest fix.lemonade_manager.py:353logs that exact string whenhealth_checkraises, without naming the URL it tried. Includingstatus.url+status.errorwould have surfaced the wrong-port mismatch immediately.tests/fixtures/eval_baselines/gemma-4-e4b-d71cd914/scorecard_rag_quality.jsonwas captured against a now-removed scenario list. A refresh with--save-baselinewould make future deltas meaningful.CONTRIBUTING.mdis silent on fork workflow. Step 2 says "Branch offmain" 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
Closes #1005).python util/lint.py --all,pytest tests/unit/connectors/ …).docs/sdk/infrastructure/connectors.mdxanddocs/security/connections.mdxupdated.