feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager

EPIC R05 (Team Hoa) - one security/approval path for every tool call.

R05-T01 domain/tools/{tool_descriptor,tool_registry}.py
  ToolCapability (READ/WRITE/EXECUTE/NETWORK, composable) + ToolDescriptor +
  ToolRegistry, replacing three independently-maintained gating lists
  (core/tools.py::WRITE_TOOLS, code_agent.py's WRITE_TOOLS|MS365_WRITE_TOOLS,
  chat_agent.py's literal ("run_command","install_package") tuple) with one
  capability lookup.

R05-T02 infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py
  core/tools.py's execute_tool if/elif chain split into per-concern modules.
  core/tools.py is now a strangler-fig shim: re-exports ToolContext/ToolError,
  dispatches through a {name: handler} dict built from the split modules.
  core/tools.py: 566 -> 291 lines.

R05-T03 application/conversations/tool_policy_gateway.py
  ToolPolicyGateway.allow(name, gate, payload) - capability-driven ALLOW vs
  ask-the-gate decision. Wired into both chat_agent.py::run_cowork and
  code_agent.py::run_code, replacing their separate hand-rolled checks.
  Verified equivalent to the old hardcoded sets by test.

R05-T04 (behavior change, not just refactor)
  MCP/connector tools (core/mcp_client.py, core/ext_connectors.py) reached
  chat_agent.py via extra_executor(name, args) with NO permission check at
  all. They are now tagged with a conservative default capability
  (WRITE|EXECUTE|NETWORK - no MCP tool self-declares risk) and routed through
  the SAME ToolPolicyGateway as built-ins. When "confirm before running
  commands" is on, MCP/connector calls now prompt like run_command already
  did - a real gap closed, and a user-visible change worth calling out.

R05-T05 infrastructure/mcp/mcp_source_manager.py
  McpToolSourceManager extracts the connection cache/lock/start-or-skip
  lifecycle out of state.py::AppContext (_mcp_connections/_conn_lock) into a
  standalone, directly-testable class. AppContext.build_mcp_tools and
  _ms365_builtin_connection now call ensure()/stop(); _ext_connections
  (unified Connectors) is out of scope for this task and keeps its own lock.

New tests: tests/unit/test_tool_registry_and_policy.py,
test_code_agent_tool_policy.py, test_cowork_extra_tool_policy.py,
test_mcp_source_manager.py (26 new tests).

Suite: 254 passed, 4 pre-existing failures unrelated to R05 (2 EPIC R02
config-security, 2 environment-dependent routing tests - see checklist).
check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:20:57 +09:00
co-authored by Claude Sonnet 5
parent 6d3217e0b5
commit ae4fe72b2e
22 changed files with 1355 additions and 412 deletions
+47 -74
View File
@@ -6,6 +6,7 @@ import time
from typing import TYPE_CHECKING, Optional, Tuple
from .config import AppConfig
from .infrastructure.mcp import McpToolSourceManager
def resolve_agent_default(
@@ -38,18 +39,18 @@ class AppContext:
def __init__(self, config: AppConfig):
self.config = config
self.started_at = time.time() # for Monitoring's Sandbox Details "Created"/"Uptime"
self._mcp_connections: dict = {} # server name -> McpServerConnection
# Admin-configured MCP servers + the built-in MS365 server (R05-T05):
# connection caching/lifecycle (check-then-create, restart, shutdown)
# now lives in McpToolSourceManager, extracted so it is testable
# without an AppContext/Qt. See its docstring for why the check-then-
# create race matters — several turns (multiple Cowork tabs, parallel
# Co4E flows, scheduled tasks) can call build_mcp_tools() at once.
self._mcp_manager = McpToolSourceManager()
self._ext_connections: dict = {} # connector id -> McpServerConnection (mcp_stdio mode only)
# Guards the two connection caches above. build_mcp_tools() runs on EVERY
# chat turn's own AgentWorker thread, so several turns (multiple Cowork
# tabs, parallel Co4E flows, scheduled tasks) can enter it at once. The
# cache is populated check-then-create ("conn is None → spawn → store");
# without this lock two concurrent turns both see None and each spawns a
# subprocess for the SAME server — one leaks as an orphan and the wrong
# object may be handed out. The lock makes connection setup atomic; the
# provider/HTTP path itself is already thread-safe (a fresh provider per
# call, module-level `requests`, MCP calls multiplexed on the server's
# own event loop), so concurrent model calls never needed serializing.
# Guards ``_ext_connections`` only now — unified Connectors (CAD/CAE/
# MS365/Other) aren't covered by McpToolSourceManager (R05-T05 scoped
# to MCP servers), so this cache still needs its own check-then-create
# lock, the same race McpToolSourceManager guards against internally.
self._conn_lock = threading.Lock()
self._routing_service = None # lazy RoutingService (Auto Model Routing)
# Lazy RoutingApplicationService (R03-T03) — the Qt-free decision layer
@@ -237,48 +238,41 @@ class AppContext:
if not self.config.connect_external:
return [], None
from .core.ext_connectors import build_ext_connector_tools
from .core.mcp_client import McpServerConnection
from .core.mcp_client import build_mcp_tools as _merge_mcp_tools
from .core.tools import combine_tool_sources
# Serialize the check-then-create against the connection caches so
# concurrent turns share one subprocess per server instead of racing to
# spawn duplicates (see _conn_lock in __init__). The lock is held while
# connections are established (a one-time cost per server per app run);
# once warm, every turn just finds the cached connection and returns.
with self._conn_lock:
active = []
for entry in self.config.mcp_servers:
if not entry.get("enabled", True):
continue
name = entry.get("name", "")
command = entry.get("command", "")
if not name or not command:
continue
conn = self._mcp_connections.get(name)
if conn is None:
conn = McpServerConnection(name, command, entry.get("args") or [],
entry.get("env") or None)
try:
conn.start()
except Exception: # noqa: BLE001 - one broken server must not block the turn
continue
self._mcp_connections[name] = conn
# R05-T05: connection caching/check-then-create for admin-configured
# servers + the MS365 builtin now lives in McpToolSourceManager (its
# own lock guards the race — see its docstring).
active = []
for entry in self.config.mcp_servers:
if not entry.get("enabled", True):
continue
name = entry.get("name", "")
command = entry.get("command", "")
if not name or not command:
continue
conn = self._mcp_manager.ensure(name, command, entry.get("args") or [],
entry.get("env") or None)
if conn is not None:
active.append(conn)
builtin = self._ms365_builtin_connection(skip={c.name for c in active})
if builtin is not None:
active.append(builtin)
mcp_tools, mcp_executor = _merge_mcp_tools(active)
builtin = self._ms365_builtin_connection(skip={c.name for c in active})
if builtin is not None:
active.append(builtin)
mcp_tools, mcp_executor = _merge_mcp_tools(active)
# ``_ext_connections`` isn't covered by McpToolSourceManager (T05
# scoped to MCP servers) — still serialized under ``_conn_lock``.
with self._conn_lock:
ext = self.config.ext_connectors
all_connectors = [*ext.get("cad", []), *ext.get("cae", []),
*ext.get("ms365", []), *ext.get("other", [])]
ext_tools, ext_executor = build_ext_connector_tools(all_connectors, self._ext_connections)
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
from .core.ms365_local import build_ms365_local_tools
local_tools, local_executor = build_ms365_local_tools(self.config)
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
# OneDrive-desktop-synced folders directly, gated on ms365.connectors.
from .core.ms365_local import build_ms365_local_tools
local_tools, local_executor = build_ms365_local_tools(self.config)
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
(local_tools, local_executor))
@@ -308,36 +302,20 @@ class AppContext:
import sys
from pathlib import Path
from .core.mcp_client import McpServerConnection
name = self._MS365_BUILTIN
if name in skip:
return None
if not self._ms365_available():
stale = self._mcp_connections.pop(name, None)
if stale is not None:
try:
stale.stop()
except Exception: # noqa: BLE001
pass
self._mcp_manager.stop(name)
return None
conn = self._mcp_connections.get(name)
if conn is None:
# The subprocess must import cowork_local even in a from-source run
# (PYTHONPATH=src) — prepend this package's parent dir explicitly.
env = dict(os.environ)
src_root = str(Path(__file__).resolve().parent.parent)
env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"]
if env.get("PYTHONPATH") else src_root)
conn = McpServerConnection(
name, sys.executable,
["-m", "cowork_local.mcp_servers.ms365_server"], env)
try:
conn.start()
except Exception: # noqa: BLE001 - MS365 down must not block the turn
return None
self._mcp_connections[name] = conn
return conn
# The subprocess must import cowork_local even in a from-source run
# (PYTHONPATH=src) — prepend this package's parent dir explicitly.
env = dict(os.environ)
src_root = str(Path(__file__).resolve().parent.parent)
env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"]
if env.get("PYTHONPATH") else src_root)
return self._mcp_manager.ensure(
name, sys.executable, ["-m", "cowork_local.mcp_servers.ms365_server"], env)
def stop_mcp_connections(self) -> None:
"""Terminate every connected MCP server's subprocess (incl. External
@@ -345,11 +323,6 @@ class AppContext:
them linger as orphan processes."""
from .core.ext_connectors import stop_ext_connections
self._mcp_manager.stop_all()
with self._conn_lock:
for conn in self._mcp_connections.values():
try:
conn.stop()
except Exception: # noqa: BLE001
pass
self._mcp_connections.clear()
stop_ext_connections(self._ext_connections)