Connector code lives in one package, tools/connectors/ (move only; NS-868 prep) (#110368)

* refactor(tools): discovery also scans tools/<pkg>/tool.py

A tool family that is a whole package had no way to register: discovery
globbed tools/*.py only and derived the module name from the filename.
Now the candidate list is tools/*.py plus tools/*/tool.py, merged and
sorted once so import order does not depend on depth (register() lets a
same-name duplicate overwrite silently), and the module name comes from
the path relative to tools/. Only tool.py is scanned inside a package,
so its siblings are libraries by construction. A package without an
__init__.py is skipped with a warning rather than registering from a
checkout and vanishing from the installed wheel.

The AST prefilter and the (mtime, size) disk cache are per absolute path
and work unchanged. The two hand-rolled tools/*.py enumerators in tests
now use the same candidate helper.

* refactor(connectors): one package for the connector domain, tools/connectors/

The connector code was spread across six flat files and a root-level
module that was a sibling of model_tools.py only by address:

  tools/connections_tool.py            -> tools/connectors/tool.py     (schema, register, dispatcher)
                                          tools/connectors/managed.py  (the managed leg, split out)
  tools/connections_tool_mcp.py        -> tools/connectors/mcp.py      (validation split out ->)
                                          tools/connectors/targets.py  (normalize_targets, validate_action)
  tools/connections_tool_operation.py  -> tools/connectors/operation.py
  tools/connector_search.py            -> tools/connectors/search.py
  model_tools_connectors.py            -> tools/connectors/dispatch.py
  tools/tool_gateway/                  -> tools/connectors/gateway/

Move only; every function body is unchanged. tools/connectors/__init__.py
is the door: nine names, the whole cross-package surface. model_tools and
tool_search deep-import a few helpers past it on purpose and the docstring
says so. The two split files make the import graph one-directional
(tool -> mcp -> targets, tool -> managed) where the old layout had
connections_tool importing validation out of the MCP file.

One behaviour-neutral seam change: the _connectors_available try/except
wrapper is gone. connectors_available() already fails closed, and both
the registry handler and the inline executor now read it as a module
attribute (gateway.config.connectors_available), so tests patch it in
one place instead of two. _default_client lives in managed.py, the only
module that calls it.

tools/managed_tool_gateway.py and tools/managed_gateway_auth.py stay:
they are gateway identity shared by tts, transcription, image and modal.

Test files follow their modules. No docs referenced the old paths; no
compat pointer is added (in-tree moves get none).

* ci: retrigger (zero-job dispatch on 142466de6b)
This commit is contained in:
Siddharth Balyan authored and GitHub committed 2026-09-15 00:41:13 +05:30
1 parent e0ef0eb9c3
commit d105376b21
36 files changed
+471 -312

No files matched your search

+3 -2
View File
@@ -152,12 +152,13 @@ def _desktop_preview(agent, args: dict, ctx: InlineToolContext) -> Any:
def _manage_connections(agent, args: dict, ctx: InlineToolContext) -> Any:
# The GUI callback lives on the agent; registry dispatch never forwards it.
from tools.connections_tool import _connectors_available, manage_connections
from tools.connectors import manage_connections
from tools.connectors.gateway import config as gateway_config
return manage_connections(
args, session_id=getattr(agent, "session_id", None),
connection_callback=getattr(agent, "connection_callback", None),
connectors_available=_connectors_available,
connectors_available=gateway_config.connectors_available,
)
+4 -5
View File
@@ -817,9 +817,8 @@ def _execute_tool(function_name: str, function_args: Dict[str, Any], original_ar
dispatch_kwargs["user_task"] = user_task
def _dispatch(next_args: Dict[str, Any]) -> Any:
from tools.tool_gateway.names import is_connector_name
from tools.connectors import dispatch_connector_call, is_connector_name
if is_connector_name(function_name):
from model_tools_connectors import dispatch_connector_call
return dispatch_connector_call(function_name, next_args, ids.tool_call_id)
return registry.dispatch(function_name, next_args, **dispatch_kwargs)
@@ -893,9 +892,8 @@ def handle_function_call(
result, underlying = bridged
if underlying is None:
return _emit(result, duration_ms=_elapsed_ms(start))
from tools.tool_gateway.names import CONNECTOR_BATCH_SENTINEL
from tools.connectors import CONNECTOR_BATCH_SENTINEL, dispatch_connector_batch
if underlying[0] == CONNECTOR_BATCH_SENTINEL:
from model_tools_connectors import dispatch_connector_batch
return _emit(dispatch_connector_batch(
underlying[1]["calls"], ids, user_task=user_task,
enabled_tools=enabled_tools, middleware_trace=trace,
@@ -908,7 +906,8 @@ def handle_function_call(
enabled_toolsets=enabled_toolsets, disabled_toolsets=disabled_toolsets,
)
from tools.tool_gateway.names import is_connector_name, parse_connector_name
from tools.connectors import is_connector_name
from tools.connectors.gateway.names import parse_connector_name
if function_name == "manage_connections" or is_connector_name(function_name):
if "manage_connections" not in _select_tool_names(enabled_toolsets, disabled_toolsets, quiet_mode=True):
return _emit(tool_error("Connectors are not available in this session."))
+2 -2
View File
@@ -2549,8 +2549,8 @@ class TestAgentRuntimePostHookOwnershipSync:
)
# manage_connections / setup_mcp shim: no GUI callback on this fake agent, so the MCP
# leg settles `unavailable` without a card; pin the catalog so the run is hermetic.
monkeypatch.setattr("tools.connections_tool_mcp._catalog_names", lambda: ["linear"])
monkeypatch.setattr("tools.connections_tool_mcp._configured_names", lambda: [])
monkeypatch.setattr("tools.connectors.mcp._catalog_names", lambda: ["linear"])
monkeypatch.setattr("tools.connectors.mcp._configured_names", lambda: [])
monkeypatch.setattr(agent, "_get_session_db_for_recall", lambda: None)
monkeypatch.setattr(
agent,
+4 -4
View File
@@ -11,7 +11,7 @@ import logging
import pytest
from agent.tool_dispatch_helpers import _peel_bridge_call
from tools.tool_gateway.bridge import connector_describe
from tools.connectors.gateway.bridge import connector_describe
from tools.tool_search import (
CONNECTOR_BATCH_SENTINEL,
ToolSearchConfig,
@@ -278,7 +278,7 @@ def test_search_keeps_only_the_twin_a_colliding_name_reaches(order, caplog):
},
}
with caplog.at_level(logging.WARNING, logger="tools.connector_search"):
with caplog.at_level(logging.WARNING, logger="tools.connectors.search"):
out = json.loads(dispatch_tool_search(
{"queries": ["gmail fetch profile"]},
current_tool_defs=_local_defs(),
@@ -459,7 +459,7 @@ def test_peel_keeps_mixed_and_local_batches_as_sequential_barrier():
def _connectors_on(monkeypatch, client_factory):
from tools.registry import invalidate_check_fn_cache
from tools.tool_gateway import bridge, config
from tools.connectors.gateway import bridge, config
monkeypatch.setattr(config, "connectors_available", lambda: True)
monkeypatch.setattr(bridge, "connectors_available", lambda: True)
@@ -568,7 +568,7 @@ class _RecordingTransport:
def _recording_client_factory(transport):
from tools.tool_gateway.client import ConnectorClient
from tools.connectors.gateway.client import ConnectorClient
return lambda: ConnectorClient(
transport=transport,
@@ -10,7 +10,7 @@ def test_remote_entries_run_request_hook_and_execution_policies(monkeypatch, blo
import model_tools
import hermes_cli.plugins as plugins
from tools.registry import invalidate_check_fn_cache
from tools.tool_gateway import bridge, config
from tools.connectors.gateway import bridge, config
monkeypatch.setattr(config, "connectors_available", lambda: True)
monkeypatch.setattr(bridge, "connectors_available", lambda: True)
@@ -81,7 +81,7 @@ def test_stop_during_a_connector_batch_leaves_unstarted_entries_unsent(monkeypat
import model_tools
from tools.interrupt import set_interrupt
from tools.registry import invalidate_check_fn_cache
from tools.tool_gateway import bridge, config
from tools.connectors.gateway import bridge, config
monkeypatch.setattr(config, "connectors_available", lambda: True)
monkeypatch.setattr(bridge, "connectors_available", lambda: True)
@@ -112,11 +112,12 @@ def test_stop_during_a_connector_batch_leaves_unstarted_entries_unsent(monkeypat
def test_disabled_connections_cannot_be_called_through_a_stale_schema(monkeypatch):
from tools import connections_tool
from tools.connectors import managed
from tools.connectors.gateway import config
from tools.registry import registry
monkeypatch.setattr(connections_tool, "_connectors_available", lambda: False)
monkeypatch.setattr(connections_tool, "_default_client",
monkeypatch.setattr(config, "connectors_available", lambda: False)
monkeypatch.setattr(managed, "_default_client",
lambda: (_ for _ in ()).throw(AssertionError("disabled connector attempted I/O")))
result = json.loads(registry.dispatch("manage_connections", {"action": "connect", "connectors": ["gmail"]}))
assert "not available" in result["error"]
+1 -1
View File
@@ -10,7 +10,7 @@ import pytest
def test_local_batches_rejected_before_any_entry_executes(monkeypatch, mixed):
import model_tools
from tools.tool_search import resolve_underlying_call
from tools.tool_gateway import bridge, config
from tools.connectors.gateway import bridge, config
from tools.registry import invalidate_check_fn_cache
monkeypatch.setattr(config, "connectors_available", lambda: True)
+1 -1
View File
@@ -2,7 +2,7 @@
import pytest
from tools.tool_gateway.names import (
from tools.connectors.gateway.names import (
format_connector_name,
parse_connector_name,
vendor_slug_candidates,
+3 -3
View File
@@ -17,8 +17,8 @@ import pytest
])
def test_connector_scope_controls_schema_discovery_and_execution(monkeypatch, enabled, disabled, allowed):
import model_tools
from tools.tool_gateway import bridge, config
from tools import connections_tool
from tools.connectors import managed
from tools.connectors.gateway import bridge, config
monkeypatch.setattr(config, "connectors_available", lambda: True)
monkeypatch.setattr(bridge, "connectors_available", lambda: True)
@@ -46,7 +46,7 @@ def test_connector_scope_controls_schema_discovery_and_execution(monkeypatch, en
return []
monkeypatch.setattr(bridge, "_default_client_factory", Client)
monkeypatch.setattr(connections_tool, "_default_client", Client)
monkeypatch.setattr(managed, "_default_client", Client)
scope = {"enabled_toolsets": enabled, "disabled_toolsets": disabled}
defs = model_tools.get_tool_definitions(**scope, quiet_mode=True, skip_tool_search_assembly=True)
assert ("manage_connections" in {td["function"]["name"] for td in defs}) is allowed
@@ -10,15 +10,15 @@ from dataclasses import replace as dataclass_replace
import pytest
from tools.tool_gateway.bridge import connector_search_hits
from tools.tool_gateway.client import ConnectorClient
from tools.tool_gateway.errors import (
from tools.connectors.gateway.bridge import connector_search_hits
from tools.connectors.gateway.client import ConnectorClient
from tools.connectors.gateway.errors import (
GatewayAuthError,
GatewayUnavailable,
IdempotencyConflict,
ToolGatewayError,
)
from tools.tool_gateway.names import vendor_slug_candidates
from tools.connectors.gateway.names import vendor_slug_candidates
class FakeResponse:
@@ -73,7 +73,7 @@ PLAN_CALLS = [
def planned(calls=PLAN_CALLS):
from tools.tool_gateway.merge import partition_calls
from tools.connectors.gateway.merge import partition_calls
return tuple(
dataclass_replace(
@@ -297,7 +297,7 @@ def _resolve_with_env(**overrides):
import os
from unittest.mock import patch
from tools.tool_gateway.client import _default_endpoint_resolver
from tools.connectors.gateway.client import _default_endpoint_resolver
env = {k: v for k, v in os.environ.items() if k not in _GATEWAY_ENV_KEYS}
env.update(overrides)
@@ -1,4 +1,4 @@
"""Behavior tests for the pure tool_gateway merge/partition/name logic.
"""Behavior tests for the pure connectors.gateway merge/partition/name logic.
Pure functions, zero fakes, no I/O — matching the DI-callable test idiom
(``test_managed_tool_gateway.py``). Wire/client behavior is covered in the
@@ -7,21 +7,21 @@ client PR; this file owns partition → splice → assemble and the name codec.
import pytest
from tools.tool_gateway.config import ConnectorConfig, connectors_available
from tools.tool_gateway.errors import (
from tools.connectors.gateway.config import ConnectorConfig, connectors_available
from tools.connectors.gateway.errors import (
GatewayAuthError,
GatewayUnavailable,
IdempotencyConflict,
ToolGatewayError,
parse_gateway_error,
)
from tools.tool_gateway.merge import (
from tools.connectors.gateway.merge import (
assemble_results,
fill_remote_failure,
partition_calls,
splice_remote_results,
)
from tools.tool_gateway.names import (
from tools.connectors.gateway.names import (
CONNECTOR_BATCH_SENTINEL,
format_connector_name,
parse_connector_name,
@@ -16,9 +16,9 @@ from unittest.mock import patch
import pytest
import tools.connections_tool # registers the tool
from tools import connections_tool_operation as op
from tools.connections_tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections
import tools.connectors.tool # registers the tool
from tools.connectors import operation as op
from tools.connectors.tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections
from tools.registry import registry
CATALOG = ["figma", "linear", "notion"]
@@ -27,8 +27,8 @@ CONFIGURED = {"paper": {"command": "paper-mcp"}, "linear": {"url": "https://mcp.
@pytest.fixture(autouse=True)
def _catalog():
with patch("tools.connections_tool_mcp._catalog_names", return_value=CATALOG), \
patch("tools.connections_tool_mcp._configured_names", return_value=sorted(CONFIGURED)):
with patch("tools.connectors.mcp._catalog_names", return_value=CATALOG), \
patch("tools.connectors.mcp._configured_names", return_value=sorted(CONFIGURED)):
yield
@@ -220,7 +220,7 @@ def test_the_bounded_wait_owns_the_deadline_not_the_sequential_guard():
def test_default_wait_comes_from_the_config_key(monkeypatch):
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "3")
seen = {}
with patch("tools.connections_tool_mcp.resolve_wait_timeout", return_value=77.0):
with patch("tools.connectors.mcp.resolve_wait_timeout", return_value=77.0):
manage_connections({"action": "install", "connectors": [_linear()]},
connection_callback=lambda p: seen.update(p) or "")
assert seen["timeout_seconds"] == 77.0
@@ -2,7 +2,7 @@
import pytest
from tools import connections_tool_operation as op
from tools.connectors import operation as op
def _two_targets():
@@ -10,8 +10,8 @@ from unittest.mock import patch
import pytest
import tools.connections_tool # registers the tool
from tools.connections_tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections
import tools.connectors.tool # registers the tool
from tools.connectors.tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections
class FakeClient:
@@ -434,7 +434,7 @@ def _session_tool_names(enabled_toolsets, *, connectors, disabled_toolsets=None)
from model_tools import _compute_tool_definitions
from tools.registry import invalidate_check_fn_cache
with patch("tools.tool_gateway.config.connectors_available",
with patch("tools.connectors.gateway.config.connectors_available",
return_value=connectors):
invalidate_check_fn_cache()
try:
@@ -515,7 +515,7 @@ def test_signed_out_session_keeps_the_tool_but_the_managed_leg_refuses(tmp_path,
for selection in selections:
assert "manage_connections" in _session_tool_names(selection, connectors=False), selection
with patch("tools.connections_tool._connectors_available", return_value=False):
with patch("tools.connectors.gateway.config.connectors_available", return_value=False):
out = json.loads(registry.dispatch("manage_connections", {"action": "status"}))
assert "not available in this session" in out["error"]
+61 -2
View File
@@ -13,6 +13,7 @@ from tools.registry import (
_MAX_LOGGED_ERROR_CHARS,
_MAX_TOOL_ERROR_CHARS,
_module_registers_tools,
_tool_module_candidates,
discover_builtin_tools,
tool_error,
)
@@ -353,8 +354,8 @@ class TestBuiltinDiscovery:
def test_discovers_all_real_self_registering_builtin_tool_modules(self):
tools_dir = Path(__file__).resolve().parents[2] / "tools"
expected = [
f"tools.{path.stem}"
for path in sorted(tools_dir.glob("*.py"))
".".join(("tools", *path.relative_to(tools_dir).with_suffix("").parts))
for path in _tool_module_candidates(tools_dir)
if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"}
and _module_registers_tools(path)
]
@@ -385,6 +386,64 @@ class TestBuiltinDiscovery:
mock_import.assert_called_once_with("tools.alpha")
_REGISTERING_SOURCE = (
"from tools.registry import registry\n"
"registry.register(name='conn', toolset='x', schema={}, handler=lambda *_a, **_k: '{}')\n"
)
class TestPackageToolDiscovery:
"""A package under tools/ registers its model tool from ``<pkg>/tool.py`` and nothing else."""
@staticmethod
def _make_pkg(tmp_path, *, init=True):
tools_dir = tmp_path / "tools"
pkg = tools_dir / "connectors"
pkg.mkdir(parents=True)
(tools_dir / "__init__.py").write_text("", encoding="utf-8")
if init:
(pkg / "__init__.py").write_text("", encoding="utf-8")
(pkg / "tool.py").write_text(_REGISTERING_SOURCE, encoding="utf-8")
return tools_dir, pkg
def test_package_tool_py_is_imported_under_its_dotted_name(self, tmp_path):
tools_dir, _ = self._make_pkg(tmp_path)
with patch("tools.registry.importlib.import_module") as mock_import:
imported = discover_builtin_tools(tools_dir)
assert imported == ["tools.connectors.tool"]
mock_import.assert_called_once_with("tools.connectors.tool")
def test_package_siblings_are_libraries_not_scanned(self, tmp_path):
tools_dir, pkg = self._make_pkg(tmp_path)
# Registers at module level, but is not the package's tool.py: discovery must not import it.
(pkg / "operation.py").write_text(_REGISTERING_SOURCE, encoding="utf-8")
with patch("tools.registry.importlib.import_module") as mock_import:
imported = discover_builtin_tools(tools_dir)
assert imported == ["tools.connectors.tool"]
assert {c.args[0] for c in mock_import.call_args_list} == {"tools.connectors.tool"}
def test_package_without_init_is_skipped_loudly(self, tmp_path, caplog):
tools_dir, _ = self._make_pkg(tmp_path, init=False)
with patch("tools.registry.importlib.import_module") as mock_import, caplog.at_level(
logging.WARNING, logger="tools.registry"
):
imported = discover_builtin_tools(tools_dir)
assert imported == []
mock_import.assert_not_called()
assert any("__init__.py" in rec.getMessage() for rec in caplog.records)
def test_cache_round_trips_the_nested_verdict(self, tmp_path):
tools_dir, _ = self._make_pkg(tmp_path)
with patch("tools.registry.importlib.import_module"):
discover_builtin_tools(tools_dir)
with patch(
"tools.registry._module_registers_tools",
side_effect=AssertionError("nested file was re-scanned despite a cache hit"),
):
imported = discover_builtin_tools(tools_dir)
assert imported == ["tools.connectors.tool"]
class TestEmojiMetadata:
"""Verify per-tool emoji registration and lookup."""
@@ -66,7 +66,9 @@ def emitted_event_names() -> set[str]:
for src in (REPO / "tools").glob("delegate_tool*.py"):
names.update(_SUBAGENT_RELAY.findall(_read(src)))
names.discard("subagent.text") # mirrored into the watch window as message.delta, never emitted
for src in (REPO / "tools").glob("*.py"):
from tools.registry import _tool_module_candidates
for src in _tool_module_candidates(REPO / "tools"):
names.update(_DESKTOP_UI_EMIT.findall(_read(src)))
names.update(_BROKER_FRAME.findall(_read(REPO / "gateway" / "browser_control_broker.py")))
names.update(_SETUP_READY.findall(_read(REPO / "hermes_cli" / "free_tier_bootstrap.py")))
+4 -1
View File
@@ -10,7 +10,10 @@ Most capabilities should NOT be core tools. Long-form: `website/docs/developer-g
`registry.register()` at import time; `model_tools.py` imports the registry and triggers discovery
(`discover_builtin_tools()`), then `run_agent.py`, `cli.py`, `batch_runner.py`, `environments/`
consume it. Any `tools/*.py` with a top-level `registry.register()` is imported automatically — no
manual import list. The registry handles schema collection, dispatch (`handle_function_call()`),
manual import list. A tool that is a whole package (`tools/connectors/`) registers from
`tools/<pkg>/tool.py`, the only file discovery scans inside a package; every sibling in the package
is a library by construction, and the package needs an `__init__.py` or discovery skips it with a
warning (setuptools would drop it from the wheel). The registry handles schema collection, dispatch (`handle_function_call()`),
availability (`check_fn`, TTL-cached process-wide), and error wrapping. **All handlers return a JSON
string.**
+41
View File
@@ -0,0 +1,41 @@
"""Connectors: managed connector accounts (Nous tool gateway) and local MCP servers behind one
model tool, ``manage_connections``.
Layout (a deep module with a narrow door):
- ``tool.py`` — the model tool: schema, ``registry.register`` (the one file discovery scans in this
package), and the action dispatcher.
- ``targets.py`` — target and action validation shared by every leg. Pure.
- ``operation.py`` — ``ConnectionOperation`` / ``Target``: targets, server-owned deadline,
exactly-once settlement. Pure data, no I/O.
- ``mcp.py`` — the local-MCP leg (install / enable / authorize through the approval card).
- ``managed.py`` — the managed-connector leg (status / connect / reconnect / wait).
- ``search.py`` — the ``tool_search`` / ``tool_describe`` adapter for remote connector tools.
- ``dispatch.py`` — remote connector calls re-entering ``model_tools.handle_function_call`` so
per-tool policy runs against the composed ``connectors__<connector>__<tool>`` name.
- ``gateway/`` — the typed client for the tool gateway's connector routes; see its own docstring
for the layering rules inside it.
The names below are the whole cross-package surface. ``model_tools`` and ``tool_search`` are
core plumbing and deep-import a few helpers past this door on purpose; nothing else should. An
importer outside this package needing a name not listed here is a design question, not a reason
to add the name.
"""
from tools.connectors.dispatch import dispatch_connector_batch, dispatch_connector_call
from tools.connectors.gateway.bridge import connector_describe, connector_search_hits
from tools.connectors.gateway.config import connectors_available
from tools.connectors.gateway.names import CONNECTOR_BATCH_SENTINEL, is_connector_name
from tools.connectors.tool import MANAGE_CONNECTIONS_SCHEMA, manage_connections
__all__ = [
"CONNECTOR_BATCH_SENTINEL",
"MANAGE_CONNECTIONS_SCHEMA",
"connector_describe",
"connector_search_hits",
"connectors_available",
"dispatch_connector_batch",
"dispatch_connector_call",
"is_connector_name",
"manage_connections",
]
@@ -4,8 +4,8 @@ import json
from dataclasses import asdict
from tools.registry import tool_error
from tools.tool_gateway.config import MAX_CALLS_PER_DISPATCH
from tools.tool_gateway.merge import assemble_results, fill_remote_failure, partition_calls
from tools.connectors.gateway.config import MAX_CALLS_PER_DISPATCH
from tools.connectors.gateway.merge import assemble_results, fill_remote_failure, partition_calls
def dispatch_connector_call(name, arguments, tool_call_id):
@@ -14,7 +14,7 @@ def dispatch_connector_call(name, arguments, tool_call_id):
Execution middleware wraps the actual I/O, so connector entries execute
individually rather than queuing side effects after a policy callback returns.
"""
from tools.tool_gateway.bridge import run_remote
from tools.connectors.gateway.bridge import run_remote
partition = partition_calls([{"name": name, "arguments": arguments}])
entries = run_remote(partition.remote, tool_call_id, availability=None, client_factory=None)
@@ -26,18 +26,18 @@ Layering rules (enforced by review, not imports — keep them true):
Approval is settled by the core BEFORE the bridge is called; denied
entries never reach it.
Core reaches this package through ``model_tools_connectors.py``, which
Core reaches this package through ``tools.connectors.dispatch.py``, which
dispatches one gateway request per connector entry via ``bridge.run_remote``
and re-enters core dispatch for each entry so per-tool policy fires against
the composed ``connectors__`` name.
"""
from tools.tool_gateway.config import (
from tools.connectors.gateway.config import (
MAX_CALLS_PER_DISPATCH,
ConnectorConfig,
connectors_available,
)
from tools.tool_gateway.errors import (
from tools.connectors.gateway.errors import (
GatewayAuthError,
GatewayUnavailable,
IdempotencyConflict,
@@ -45,7 +45,7 @@ from tools.tool_gateway.errors import (
parse_gateway_error,
render_connection_required,
)
from tools.tool_gateway.names import (
from tools.connectors.gateway.names import (
CONNECTOR_BATCH_SENTINEL,
CONNECTOR_NAME_PREFIX,
ConnectorName,
@@ -11,7 +11,7 @@ search behaving exactly as it does today.
Transport leg: :func:`run_remote` sends one gateway execute request for the
planned entries it is handed and splices the results back by slot.
``model_tools_connectors`` calls it once per connector entry, after core
``tools.connectors.dispatch`` calls it once per connector entry, after core
dispatch has already run scope, hook, approval and middleware policy against
that entry's composed ``connectors__`` name. Vendor slug restoration and the
single literal-slug retry live here; partition and envelope assembly live in
@@ -24,10 +24,10 @@ import logging
from dataclasses import replace as dataclass_replace
from typing import Any, Callable, Optional, Sequence
from tools.tool_gateway.config import connectors_available
from tools.tool_gateway.errors import GatewayUnavailable, ToolGatewayError
from tools.tool_gateway.merge import fill_remote_failure, splice_remote_results
from tools.tool_gateway.names import parse_connector_name, vendor_slug_candidates
from tools.connectors.gateway.config import connectors_available
from tools.connectors.gateway.errors import GatewayUnavailable, ToolGatewayError
from tools.connectors.gateway.merge import fill_remote_failure, splice_remote_results
from tools.connectors.gateway.names import parse_connector_name, vendor_slug_candidates
logger = logging.getLogger(__name__)
@@ -35,7 +35,7 @@ __all__ = ["connector_describe", "connector_search_hits", "run_remote"]
def _default_client_factory():
from tools.tool_gateway.client import ConnectorClient
from tools.connectors.gateway.client import ConnectorClient
return ConnectorClient()
@@ -34,14 +34,14 @@ from typing import Any, Callable, Optional, Protocol, Sequence
import requests
from tools.tool_gateway import wire
from tools.tool_gateway.errors import (
from tools.connectors.gateway import wire
from tools.connectors.gateway.errors import (
GatewayAuthError,
GatewayUnavailable,
ToolGatewayError,
parse_gateway_error,
)
from tools.tool_gateway.merge import PlannedCall
from tools.connectors.gateway.merge import PlannedCall
logger = logging.getLogger(__name__)
File renamed without changes.
File renamed without changes.
@@ -30,8 +30,8 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Mapping, Optional, Sequence
from tools.tool_gateway.errors import render_connection_required
from tools.tool_gateway.names import parse_connector_name
from tools.connectors.gateway.errors import render_connection_required
from tools.connectors.gateway.names import parse_connector_name
__all__ = [
"Partition",
File renamed without changes.
File renamed without changes.
@@ -1,30 +1,12 @@
#!/usr/bin/env python3
"""Manage remote connector accounts served through the tool gateway.
"""Managed connectors (Nous tool gateway): status, connect / reconnect, and the in-call ``wait``.
``manage_connections`` is the never-deferred surface for connection
lifecycle:
- ``status`` — which connectors exist for this account and whether each is
connected (read-only).
- ``connect`` / ``reconnect`` — start (or restart) an authorization flow.
The gateway returns a connect link, passed through UN-redacted: the model
shows it to the user, who opens it in a browser. Each connector's
``instruction`` text is surfaced once per session, not on every call.
- ``wait`` — block inside the call until the named connectors report
connected, or the budget runs out. A model has no clock: told to wait it
says "I'll check back in a minute" and its next action lands immediately,
so guidance produced a burst of polls rather than a paced one. Waiting
inside the call cannot be skipped and works the same on every platform.
Scope: managed connectors AND local MCP servers. ``{"name": ..., "mcp": true}`` targets take
``install`` / ``enable`` / ``authorize`` through one connection operation
(``connections_tool_operation.py``, ``connections_tool_mcp.py``); the approval card is reached
only via the inline executor, so non-GUI surfaces get ``unavailable`` for MCP targets.
De-authentication is deliberately NOT exposed to the model: disconnecting
an account is a user decision, made in the portal dashboard.
Availability: the managed leg is portal-gated in the handler; MCP targets need no sign-in.
``connect`` / ``reconnect`` start (or restart) an authorization flow. The gateway returns a connect
link, passed through UN-redacted: the model shows it to the user, who opens it in a browser. Each
connector's ``instruction`` text is surfaced once per session, not on every call. ``wait`` blocks
inside the call until the named connectors report connected, or the budget runs out. A model has
no clock: told to wait it says "I'll check back in a minute" and its next action lands
immediately, so guidance produced a burst of polls rather than a paced one. Waiting inside the
call cannot be skipped and works the same on every platform.
"""
import json
@@ -33,19 +15,10 @@ import threading
import time
from typing import Any, Callable, Dict, List, Optional
from tools.connections_tool_mcp import (
ALL_ACTIONS,
MCP_ACTIONS,
normalize_targets,
run_mcp_operation,
validate_action,
)
from tools.registry import registry, tool_error
from tools.registry import tool_error
logger = logging.getLogger(__name__)
_CONNECTOR_ACTIONS = ("status", "connect", "reconnect", "wait")
# (session_id, connector) pairs whose `instruction` text has already been
# shown. Keyed per session, not per process: the gateway multiplexes many
# sessions through one process, and guidance suppressed for session A must
@@ -106,18 +79,8 @@ _WAIT_UNFINISHED_NOTE = (
"broken."
)
def _connectors_available() -> bool:
try:
from tools.tool_gateway.config import connectors_available
return connectors_available()
except Exception:
return False
def _default_client():
from tools.tool_gateway.client import ConnectorClient
from tools.connectors.gateway.client import ConnectorClient
return ConnectorClient()
@@ -293,37 +256,21 @@ def _wait_for_connections(
)
spent += gap
def manage_connections(
def run_managed_action(
action: str,
connectors: List[str],
args: Dict[str, Any],
*,
client_factory: Optional[Callable[[], Any]] = None,
seen_instructions: Optional[set] = None,
rendered_links: Optional[Dict[str, Dict[str, float]]] = None,
session_id: Optional[str] = None,
connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None,
connectors_available: Optional[Callable[[], bool]] = None,
wait_seconds: Optional[float] = None,
) -> str:
"""Dispatch one ``manage_connections`` action. Returns a JSON string."""
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, str(args.get("reason") or "").strip(),
connection_callback=connection_callback, session_id=session_id, wait_seconds=wait_seconds,
)
# Managed leg. Callers that pass a gate (registry handler, inline executor) are portal-gated.
"""One managed-connector action (status / connect / reconnect / wait). Returns the tool's
JSON string. ``connectors`` are the normalized managed target names."""
if connectors_available is not None and not connectors_available():
return tool_error("Connectors are not available in this session.")
connectors: List[str] = managed
try:
client = (client_factory or _default_client)()
@@ -441,101 +388,3 @@ def manage_connections(
f"The connector gateway request failed: {exc}. "
"If this persists, the user can manage connections in the Nous Portal."
)
MANAGE_CONNECTIONS_SCHEMA = {
"name": "manage_connections",
"description": (
"Connect the user to apps: managed connector accounts (Gmail, Notion, ...) served "
"through the tool gateway, and local MCP servers from the catalog. Targets go in "
"'connectors': a bare slug or {\"name\": \"gmail\"} is a managed connector; "
"{\"name\": \"linear\", \"mcp\": true} is a local MCP server. "
"Managed actions: 'status' lists connectors and whether each is connected; 'connect' "
"starts an authorization for the given connectors and returns a link "
"for the USER to open in a browser (never open it yourself); "
"'reconnect' restarts a broken authorization; "
"'wait' blocks until the given connectors report connected. Pass "
"SEVERAL slugs in one call to get all authorization links at once. "
"When a connector tool "
"call returns CONNECTION_REQUIRED, use 'connect' and show the link. "
"Send the message that shows the user the links FIRST; on your NEXT "
"turn call 'wait' with those same slugs instead of guessing when the "
"user is done — it polls for you (a wait in the same turn as the "
"connect is bounced, because the user cannot have seen the links "
"yet). 'wait' requires 'connectors', and only accepts connectors this "
"session already addressed with 'connect' (already-connected apps "
"count). A 'timeout' or 'interrupted' result is NOT an "
"error: the user has not finished connecting, so ask them whether to "
"keep waiting, continue without those apps, or get fresh links. "
"MCP actions (targets 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; the result lists "
"each target as connected / skipped / not_connected. Never hand-edit mcp_servers "
"config — always use this tool. Never re-ask after a skip or timeout: continue "
"without the server or ask in chat. A newly installed or authorized server's tools "
"arrive on your next turn. Off the desktop app the MCP targets come back "
"'unavailable' with the terminal commands to give the user. "
"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. install/enable/authorize need mcp:true targets.",
},
"connectors": {
"type": "array",
"items": {
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"name": {"type": "string"},
"mcp": {"type": "boolean", "description": "true = local MCP server."},
},
"required": ["name"],
"additionalProperties": False,
},
]
},
"description": (
"Targets. REQUIRED for every action but status "
"(e.g. [\"gmail\", {\"name\": \"linear\", \"mcp\": true}]); optional filter for status."
),
},
"reason": {
"type": "string",
"description": "MCP actions: one sentence on the approval card — why this helps right now.",
},
"timeout_seconds": {
"type": "integer",
"description": (
"For action 'wait' only: how long to hold the call open. "
f"Defaults to {int(_WAIT_DEFAULT_SECONDS)}, clamped to "
f"{int(_WAIT_MIN_SECONDS)}-{int(_WAIT_MAX_SECONDS)}. Ask for "
"more and the result carries a 'timeout_note' saying the cap "
"was applied; call wait again to keep waiting."
),
},
},
"required": [],
},
}
registry.register(
name="manage_connections",
toolset="connections",
schema=MANAGE_CONNECTIONS_SCHEMA,
# The portal gate is in the handler, not check_fn, so signed-out sessions keep the tool for
# MCP approvals. The registry path has no GUI callback.
handler=lambda args, **kw: manage_connections(
args, session_id=kw.get("session_id"), connectors_available=_connectors_available,
),
emoji="🔗",
)
@@ -1,14 +1,14 @@
"""MCP targets of ``manage_connections``: target/action/catalog validation and the approval
leg. The card is reached via ``agent.connection_callback`` through the inline executor;
registry dispatch has no callback and settles targets ``unavailable``."""
"""MCP targets of ``manage_connections``: catalog validation and the approval leg. The card is
reached via ``agent.connection_callback`` through the inline executor; registry dispatch has no
callback and settles targets ``unavailable``."""
from __future__ import annotations
import json
import logging
from typing import Any, Callable, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional
from tools.connections_tool_operation import (
from tools.connectors.operation import (
CONNECTED,
FAILED,
SETTLED_ALL_RESOLVED,
@@ -26,12 +26,6 @@ from tools.registry import tool_error
logger = logging.getLogger(__name__)
CONNECTOR_ACTIONS = ("status", "connect", "reconnect", "wait")
MCP_ACTIONS = ("install", "enable", "authorize")
ALL_ACTIONS = CONNECTOR_ACTIONS + MCP_ACTIONS
_TARGET_FIELDS = frozenset({"name", "mcp"})
# Renderer outcome → operation state. declined = Not now; error = recoverable, operation stays open.
_OUTCOME_STATES = {
"installed": CONNECTED, "enabled": CONNECTED, "authorized": CONNECTED, "connected": CONNECTED,
@@ -42,66 +36,8 @@ _OUTCOME_STATES = {
UNAVAILABLE_HINT = "hermes mcp install {name} / hermes mcp login {name}"
def normalize_targets(raw: Any) -> Tuple[List[str], List[str], Optional[str]]:
"""``connectors`` → ``(managed names, mcp names, error)``. Bare strings and ``{name}`` are
managed; ``{name, mcp: true}`` is a local MCP. Any other field is an error."""
if raw is None:
return [], [], None
if isinstance(raw, (str, dict)):
raw = [raw]
if not isinstance(raw, list):
return [], [], "'connectors' must be a list of names or {name, mcp} objects."
managed: List[str] = []
mcp: List[str] = []
for item in raw:
if isinstance(item, dict):
unknown = sorted(set(item) - _TARGET_FIELDS)
if unknown:
return [], [], (
f"unknown target field(s) {', '.join(unknown)}: a target is "
"{\"name\": \"<slug>\"} or {\"name\": \"<server>\", \"mcp\": true}. Transport, "
"URLs and credentials come from the catalog manifest, never from the call."
)
name = str(item.get("name") or "").strip().lower()
is_mcp = bool(item.get("mcp", False))
else:
name, is_mcp = str(item or "").strip().lower(), False
if not name:
return [], [], "every target needs a non-empty 'name'."
bucket = mcp if is_mcp else managed
if name not in bucket:
bucket.append(name)
return managed, mcp, None
def validate_action(action: str, managed: List[str], mcp: List[str]) -> Optional[str]:
"""MCP verbs need ``mcp:true`` targets; connector verbs need managed targets."""
if action not in ALL_ACTIONS:
return (
f"action must be one of {', '.join(ALL_ACTIONS)}. "
f"{', '.join(MCP_ACTIONS)} apply to local MCP servers "
"(targets {\"name\": ..., \"mcp\": true}); the rest apply to managed connectors. "
"Disconnecting an account is done by the user in the Nous Portal dashboard, not "
"through this tool."
)
if action in MCP_ACTIONS:
if managed:
return (
f"'{action}' is an MCP action: every target must carry \"mcp\": true "
f"(got managed connector(s) {', '.join(managed)}). Managed connectors use "
"connect / reconnect / wait / status."
)
if not mcp:
return (
f"'{action}' requires 'connectors': the MCP server name(s), e.g. "
"[{\"name\": \"linear\", \"mcp\": true}]."
)
elif mcp:
return (
f"'{action}' is a managed-connector action; MCP targets ({', '.join(mcp)}) use "
f"{', '.join(MCP_ACTIONS)}."
)
return None
def _catalog_names() -> List[str]:
File renamed without changes.
@@ -12,7 +12,7 @@ from __future__ import annotations
import logging
from typing import Any, Dict, Iterable, List, Optional
from tools.tool_gateway.names import format_connector_name, is_connector_name, vendor_slug_candidates
from tools.connectors.gateway.names import format_connector_name, is_connector_name, vendor_slug_candidates
from tools.tool_search_catalog import CatalogEntry, _fn, _tokenize
logger = logging.getLogger(__name__)
@@ -50,7 +50,7 @@ def connector_entries_by_group(
per_query: List[List[CatalogEntry]] = [[] for _ in queries]
try:
if connector_search is None:
from tools.tool_gateway.bridge import connector_search_hits as connector_search
from tools.connectors.gateway.bridge import connector_search_hits as connector_search
hits = connector_search([{"use_case": q} for q in queries]) or {}
schemas = hits.get("schemas")
groups = hits.get("results")
@@ -119,7 +119,7 @@ def remote_schemas_for(
return {}
try:
if connector_describe is None:
from tools.tool_gateway.bridge import connector_describe
from tools.connectors.gateway.bridge import connector_describe
remote = connector_describe(connector_names)
if isinstance(remote, dict) and isinstance(remote.get("tools"), dict):
return remote["tools"]
+78
View File
@@ -0,0 +1,78 @@
"""Target and action validation shared by every leg of ``manage_connections``.
``connectors`` entries are bare slugs or ``{name, mcp}`` objects; bare strings and ``{name}`` are
managed connectors, ``{name, mcp: true}`` is a local MCP server. Connector verbs need managed
targets, MCP verbs need MCP targets. Pure functions, no I/O.
"""
from __future__ import annotations
from typing import Any, List, Optional, Tuple
CONNECTOR_ACTIONS = ("status", "connect", "reconnect", "wait")
MCP_ACTIONS = ("install", "enable", "authorize")
ALL_ACTIONS = CONNECTOR_ACTIONS + MCP_ACTIONS
_TARGET_FIELDS = frozenset({"name", "mcp"})
def normalize_targets(raw: Any) -> Tuple[List[str], List[str], Optional[str]]:
"""``connectors`` → ``(managed names, mcp names, error)``. Bare strings and ``{name}`` are
managed; ``{name, mcp: true}`` is a local MCP. Any other field is an error."""
if raw is None:
return [], [], None
if isinstance(raw, (str, dict)):
raw = [raw]
if not isinstance(raw, list):
return [], [], "'connectors' must be a list of names or {name, mcp} objects."
managed: List[str] = []
mcp: List[str] = []
for item in raw:
if isinstance(item, dict):
unknown = sorted(set(item) - _TARGET_FIELDS)
if unknown:
return [], [], (
f"unknown target field(s) {', '.join(unknown)}: a target is "
"{\"name\": \"<slug>\"} or {\"name\": \"<server>\", \"mcp\": true}. Transport, "
"URLs and credentials come from the catalog manifest, never from the call."
)
name = str(item.get("name") or "").strip().lower()
is_mcp = bool(item.get("mcp", False))
else:
name, is_mcp = str(item or "").strip().lower(), False
if not name:
return [], [], "every target needs a non-empty 'name'."
bucket = mcp if is_mcp else managed
if name not in bucket:
bucket.append(name)
return managed, mcp, None
def validate_action(action: str, managed: List[str], mcp: List[str]) -> Optional[str]:
"""MCP verbs need ``mcp:true`` targets; connector verbs need managed targets."""
if action not in ALL_ACTIONS:
return (
f"action must be one of {', '.join(ALL_ACTIONS)}. "
f"{', '.join(MCP_ACTIONS)} apply to local MCP servers "
"(targets {\"name\": ..., \"mcp\": true}); the rest apply to managed connectors. "
"Disconnecting an account is done by the user in the Nous Portal dashboard, not "
"through this tool."
)
if action in MCP_ACTIONS:
if managed:
return (
f"'{action}' is an MCP action: every target must carry \"mcp\": true "
f"(got managed connector(s) {', '.join(managed)}). Managed connectors use "
"connect / reconnect / wait / status."
)
if not mcp:
return (
f"'{action}' requires 'connectors': the MCP server name(s), e.g. "
"[{\"name\": \"linear\", \"mcp\": true}]."
)
elif mcp:
return (
f"'{action}' is a managed-connector action; MCP targets ({', '.join(mcp)}) use "
f"{', '.join(MCP_ACTIONS)}."
)
return None
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Manage remote connector accounts served through the tool gateway.
``manage_connections`` is the never-deferred surface for connection
lifecycle:
- ``status`` — which connectors exist for this account and whether each is
connected (read-only).
- ``connect`` / ``reconnect`` — start (or restart) an authorization flow.
The gateway returns a connect link, passed through UN-redacted: the model
shows it to the user, who opens it in a browser. Each connector's
``instruction`` text is surfaced once per session, not on every call.
- ``wait`` — block inside the call until the named connectors report
connected, or the budget runs out. A model has no clock: told to wait it
says "I'll check back in a minute" and its next action lands immediately,
so guidance produced a burst of polls rather than a paced one. Waiting
inside the call cannot be skipped and works the same on every platform.
Scope: managed connectors AND local MCP servers. ``{"name": ..., "mcp": true}`` targets take
``install`` / ``enable`` / ``authorize`` through one connection operation (``operation.py``,
``mcp.py``); the approval card is reached only via the inline executor, so non-GUI surfaces get
``unavailable`` for MCP targets. Managed actions live in ``managed.py``; target and action
validation in ``targets.py``.
De-authentication is deliberately NOT exposed to the model: disconnecting
an account is a user decision, made in the portal dashboard.
Availability: the managed leg is portal-gated in the handler; MCP targets need no sign-in.
"""
from typing import Any, Callable, Dict, Optional
from tools.connectors.gateway import config as gateway_config
from tools.connectors.managed import (
_WAIT_DEFAULT_SECONDS,
_WAIT_MAX_SECONDS,
_WAIT_MIN_SECONDS,
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,
seen_instructions: Optional[set] = None,
rendered_links: Optional[Dict[str, Dict[str, float]]] = None,
session_id: Optional[str] = None,
connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None,
connectors_available: Optional[Callable[[], bool]] = None,
wait_seconds: Optional[float] = None,
) -> str:
"""Dispatch one ``manage_connections`` action. Returns a JSON string."""
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, str(args.get("reason") or "").strip(),
connection_callback=connection_callback, session_id=session_id, wait_seconds=wait_seconds,
)
return run_managed_action(
action, managed, args,
client_factory=client_factory, seen_instructions=seen_instructions,
rendered_links=rendered_links, session_id=session_id,
connectors_available=connectors_available,
)
MANAGE_CONNECTIONS_SCHEMA = {
"name": "manage_connections",
"description": (
"Connect the user to apps: managed connector accounts (Gmail, Notion, ...) served "
"through the tool gateway, and local MCP servers from the catalog. Targets go in "
"'connectors': a bare slug or {\"name\": \"gmail\"} is a managed connector; "
"{\"name\": \"linear\", \"mcp\": true} is a local MCP server. "
"Managed actions: 'status' lists connectors and whether each is connected; 'connect' "
"starts an authorization for the given connectors and returns a link "
"for the USER to open in a browser (never open it yourself); "
"'reconnect' restarts a broken authorization; "
"'wait' blocks until the given connectors report connected. Pass "
"SEVERAL slugs in one call to get all authorization links at once. "
"When a connector tool "
"call returns CONNECTION_REQUIRED, use 'connect' and show the link. "
"Send the message that shows the user the links FIRST; on your NEXT "
"turn call 'wait' with those same slugs instead of guessing when the "
"user is done — it polls for you (a wait in the same turn as the "
"connect is bounced, because the user cannot have seen the links "
"yet). 'wait' requires 'connectors', and only accepts connectors this "
"session already addressed with 'connect' (already-connected apps "
"count). A 'timeout' or 'interrupted' result is NOT an "
"error: the user has not finished connecting, so ask them whether to "
"keep waiting, continue without those apps, or get fresh links. "
"MCP actions (targets 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; the result lists "
"each target as connected / skipped / not_connected. Never hand-edit mcp_servers "
"config — always use this tool. Never re-ask after a skip or timeout: continue "
"without the server or ask in chat. A newly installed or authorized server's tools "
"arrive on your next turn. Off the desktop app the MCP targets come back "
"'unavailable' with the terminal commands to give the user. "
"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. install/enable/authorize need mcp:true targets.",
},
"connectors": {
"type": "array",
"items": {
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"name": {"type": "string"},
"mcp": {"type": "boolean", "description": "true = local MCP server."},
},
"required": ["name"],
"additionalProperties": False,
},
]
},
"description": (
"Targets. REQUIRED for every action but status "
"(e.g. [\"gmail\", {\"name\": \"linear\", \"mcp\": true}]); optional filter for status."
),
},
"reason": {
"type": "string",
"description": "MCP actions: one sentence on the approval card — why this helps right now.",
},
"timeout_seconds": {
"type": "integer",
"description": (
"For action 'wait' only: how long to hold the call open. "
f"Defaults to {int(_WAIT_DEFAULT_SECONDS)}, clamped to "
f"{int(_WAIT_MIN_SECONDS)}-{int(_WAIT_MAX_SECONDS)}. Ask for "
"more and the result carries a 'timeout_note' saying the cap "
"was applied; call wait again to keep waiting."
),
},
},
"required": [],
},
}
registry.register(
name="manage_connections",
toolset="connections",
schema=MANAGE_CONNECTIONS_SCHEMA,
# The portal gate is in the handler, not check_fn, so signed-out sessions keep the tool for
# MCP approvals. The registry path has no GUI callback. Module attribute, not a bound name, so
# tests patch ``gateway.config.connectors_available`` once and every reader sees it.
handler=lambda args, **kw: manage_connections(
args, session_id=kw.get("session_id"), connectors_available=gateway_config.connectors_available,
),
emoji="🔗",
)
+18 -3
View File
@@ -83,19 +83,34 @@ def _module_registers_tools(module_path: Path) -> bool:
for stmt in tree.body)
def _tool_module_candidates(tools_path: Path) -> List[Path]:
"""Flat ``tools/*.py`` modules plus the package entry point ``tools/<pkg>/tool.py``, in one
sorted list. Only ``tool.py`` is scanned in a package, so every other file in it is a library
by construction. Sorted after merging: ``register()`` lets a same-name, same-toolset duplicate
overwrite silently, so the import order must not depend on file depth."""
candidates = list(tools_path.glob("*.py")) + list(tools_path.glob("*/tool.py"))
return sorted(candidates)
def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]:
"""Import built-in self-registering tool modules and return their module names. The
per-file AST scan costs ~145 ms over ~100 files, so verdicts are memoized on disk keyed
by ``(mtime_ns, size)``; a mismatch or corrupt cache re-scans that file. The write is
best-effort and atomic, so concurrent processes race harmlessly."""
tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent
tools_path = (Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent).resolve()
cache = _load_discovery_cache()
fresh_cache: Dict[str, list] = {}
cache_dirty = False
module_names: List[str] = []
for path in sorted(tools_path.glob("*.py")):
for path in _tool_module_candidates(tools_path):
if path.name in {"__init__.py", "registry.py", "mcp_tool.py"}:
continue
rel_parts = path.relative_to(tools_path).with_suffix("").parts
if len(rel_parts) > 1 and not (path.parent / "__init__.py").exists():
# setuptools' package finder drops a directory without __init__.py, so this tool would
# register from a checkout and vanish from an installed wheel.
logger.warning("Skipping %s: package %s has no __init__.py", path, path.parent.name)
continue
abs_path = str(path.resolve())
try:
st = path.stat()
@@ -110,7 +125,7 @@ def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]:
cache_dirty = True
fresh_cache[abs_path] = [stat_key[0], stat_key[1], registers]
if registers:
module_names.append(f"tools.{path.stem}")
module_names.append(".".join(("tools", *rel_parts)))
# Drop entries for files that no longer exist; rewrite only when changed.
if cache_dirty or set(fresh_cache) != set(cache):
+2 -2
View File
@@ -22,8 +22,8 @@ from tools.tool_search_catalog import (
CatalogEntry, _fn, _listing_group_label, _registry_entry, _registry_toolset,
build_catalog, build_catalog_listing_with_form, search_catalog)
from tools.tool_search_validation import normalize_tool_call_entries, validate_deferred_call_args
from tools.connector_search import connections_in_scope, connector_entries_by_group, remote_schemas_for
from tools.tool_gateway.names import CONNECTOR_BATCH_SENTINEL, is_connector_name
from tools.connectors import CONNECTOR_BATCH_SENTINEL, is_connector_name
from tools.connectors.search import connections_in_scope, connector_entries_by_group, remote_schemas_for
logger = logging.getLogger("tools.tool_search")
# Bound the work one bridge call requests. Search is capped at the gateway's
+1 -1
View File
@@ -82,7 +82,7 @@ def _connector_rpc(rid, params, action):
def _dispatch_connector_rpc(rid, sid, owner, profile_home, args):
import model_tools
from tools.tool_gateway.config import connectors_available
from tools.connectors import connectors_available
from tui_gateway.connector_payload import connector_ui_payload
agent = owner.get("agent")
+1 -1
View File
@@ -189,7 +189,7 @@ def _todo_state_from_history(history) -> dict | None:
def _connector_tool_lifecycle(name: str, args: dict) -> bool:
from tools.tool_gateway.names import is_connector_name
from tools.connectors import is_connector_name
if name == "manage_connections" or is_connector_name(name):
return True