Files
hermes-agent/tools/connectors/tool.py
alt-glitch 31e59b9441 feat: setup agent can search the catalog and install plugins and skills through the approval card
The setup profile's `setup` toolset was empty. It now carries one tool, manage_catalog:

- search: catalog plugins (the Plugins tab's live catalog resolver) and hub skills, with
  whether each is already installed in the default profile. Read-only.
- install: opens the same connection operation manage_connections opens, with rows of kind
  plugin / skill. Nothing installs until the user approves a row. An approved row installs
  into `default` (or the profile the Advanced modal named) through dashboard_install_plugin /
  the hub's headless install, so the catalog pin, kill list, security scan and live
  activation (#119644) are the host's. The row settles with the live MCP tool names and the
  plugin's skill.

The model sends catalog ids and an action only; every other key is refused before anything
runs. An unknown id or a plugin this OS cannot run is drawn failed with the installer's own
text. Anywhere a catalog card cannot be drawn (TUI, CLI, messaging, registry dispatch) the
result is the `hermes plugins install` / `hermes skills install` pointer.

- contract: plugin/skill targets follow the MCP transitions.
- run.apply_answer / reissue route a card answer to the module that owns the operation.
- tool_search: `setup` joins the direct-surface toolsets, so the guide's one tool is never
  deferred behind tool_search.
- docs: tools reference, toolsets reference, plugin catalog page.

Linear NS-964.
2026-09-23 08:04:33 +05:30

157 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Connection lifecycle tool for managed gateway accounts and local MCP servers.
Disconnecting accounts remains a portal-only user decision.
"""
from typing import Any, Callable, Dict, Optional
from tools.connectors.catalog_tool import MANAGE_CATALOG_SCHEMA, manage_catalog
from tools.connectors.gateway import config as gateway_config
from tools.connectors.managed import run_managed_action
from tools.connectors.mcp import run_mcp_operation
from tools.connectors.targets import ALL_ACTIONS, MCP_ACTIONS, normalize_targets, validate_action
from tools.registry import registry, tool_error
def manage_connections(
args: Dict[str, Any],
*,
client_factory: Optional[Callable[[], Any]] = None,
mcp_backend: Optional[Any] = None,
session_id: Optional[str] = None,
tool_call_id: Optional[str] = None,
connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None,
connectors_available: Optional[Callable[[], bool]] = None,
) -> str:
action = str(args.get("action") or "status").strip().lower()
managed, mcp_targets, target_error = normalize_targets(args.get("connectors"))
if target_error:
return tool_error(target_error)
action_error = validate_action(action, managed, mcp_targets)
if action_error:
return tool_error(action_error)
if action in MCP_ACTIONS:
return run_mcp_operation(
mcp_targets, action, backend=mcp_backend,
connection_callback=connection_callback, session_id=session_id, tool_call_id=tool_call_id,
)
return run_managed_action(
action, managed, args,
client_factory=client_factory, session_id=session_id, tool_call_id=tool_call_id,
connection_callback=connection_callback, connectors_available=connectors_available,
)
MANAGE_CONNECTIONS_SCHEMA = {
"name": "manage_connections",
"description": (
"Connect the user to apps. Two kinds: a hosted connector account (Gmail, Notion, ...) "
"served through the tool gateway, and a local MCP server from the bundled catalog. "
"Targets go in 'connectors': a bare slug or {\"name\": \"gmail\"} is a hosted connector "
"account; {\"name\": \"linear\", \"mcp\": true} is a local MCP server. Many names exist on "
"both sides, so the user's own words decide. Pass \"mcp\": true only when the user asks "
"for an MCP server, a local server or an install, or when the name exists only as a "
"catalog entry; otherwise the target is hosted. 'connect' and 'reconnect' are hosted "
"actions; 'install', 'enable' and 'authorize' are MCP actions. A target the other side "
"owns is refused with the call that does work. "
"Hosted actions: 'status' lists connectors and whether each is connected; 'connect' "
"starts an authorization for the given connectors; 'reconnect' checks each one and "
"repairs only what is not connected ('force': true restarts even a working one, for an "
"account switch). Pass SEVERAL slugs in one call. In the desktop app, the terminal UI and "
"the interactive CLI the call shows the user a card and blocks until every app is "
"connected, skipped, or the deadline passes; the result lists each target as connected / "
"skipped / not_connected and never carries a link. Where no card exists (a one-shot run, "
"a scheduled job, a messaging platform) the result carries a connect_url per app for the "
"USER to open in a browser (never open it yourself); ask them to say when they are done, "
"then use 'status'. "
"When a hosted connector tool call returns CONNECTION_REQUIRED, use 'connect'. "
"MCP actions (every target must carry \"mcp\": true): 'install' adds a catalog entry, "
"'enable' re-enables a disabled configured server, 'authorize' runs its OAuth. "
"They show the user an approval card and block until it settles. Never hand-edit "
"mcp_servers config — always use this tool. After a skip or a timeout, do not re-ask on "
"your own: continue without the app or ask in chat. A later request from the USER for that "
"same app is not a re-ask — run it. A connected server's tools are named in the result and are "
"callable at once through tool_describe/tool_call. Where no card exists an MCP target runs at once and the result says what "
"happened, with a link for the user to open when one is needed. This tool can NOT "
"disconnect, delete, or revoke an account — that is deliberately user-only. When asked, say so and direct the user to the "
"Nous Portal (their org's Connectors page) or the desktop app."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": list(ALL_ACTIONS),
"description": (
"Defaults to status. connect and reconnect take hosted connector slugs only. "
"install, enable and authorize take mcp:true targets only."
),
},
"connectors": {
"type": "array",
"items": {
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"name": {"type": "string"},
"mcp": {
"type": "boolean",
"description": (
"true = a local MCP server from the catalog; absent or "
"false = a hosted connector account."
),
},
},
"required": ["name"],
"additionalProperties": False,
},
]
},
"description": (
"Targets. REQUIRED for every action but status "
"(e.g. [\"gmail\", {\"name\": \"linear\", \"mcp\": true}]); optional filter for "
"status. A bare slug is a hosted connector, so an MCP server needs the object "
"form with \"mcp\": true."
),
},
"force": {
"type": "boolean",
"description": "reconnect only: restart the authorization even if the app is connected (account switch).",
},
},
"required": [],
},
}
registry.register(
name="manage_connections",
toolset="connections",
schema=MANAGE_CONNECTIONS_SCHEMA,
# The portal gate decides schema presence: an account the portal has not enabled for
# connectors never sees the tool, so the model cannot call it and read the gateway's
# 404 back to them. The handler runs the same gate so the RPC path (methods_connectors) and
# a cached schema agree. Read as a module attribute so tests patch
# ``gateway.config.connectors_available`` at one seam.
handler=lambda args, **kw: manage_connections(
args, session_id=kw.get("session_id"), connectors_available=gateway_config.connectors_available,
),
check_fn=lambda: gateway_config.connectors_available(),
emoji="🔗",
)
# The setup profile's catalog install. Reachable only through the ``setup`` toolset, which the
# profile's role grants; registry dispatch has no card callback, so it answers with the CLI pointer.
registry.register(
name="manage_catalog",
toolset="setup",
schema=MANAGE_CATALOG_SCHEMA,
handler=lambda args, **kw: manage_catalog(args, session_id=kw.get("session_id")),
emoji="🧩",
)