Files
openworker/tests/test_mcp_connectors.py
T
Rohit C Prasad 2451486609 port aisuite#380: slack installer pre-add, mcp oauth quarantine, automation toast, e2e fixes
Slack installer joins the workspace allow-list on managed connect; MCP interactive oauth only from explicit connects.
Run-started toast over a new app-wide /ws/events socket; Automations e2e locators scoped to the account menu.
2026-07-21 12:56:36 -07:00

278 lines
12 KiB
Python

"""MCP-backed connectors (UX-DECISIONS §42): a descriptor carries a vendor-hosted MCP
URL and a PINNED tool allowlist in tool_defs. One-click connect seeds the server
config from the pin + runs the local OAuth flow; the Connectors page owns the whole
lifecycle (the Settings MCP tab hides these servers); sessions gate the tools by the
effective connector set, per-tool toggles, and the read/write approval classification.
The vendor's catalog can drift under us — every gate here fails CLOSED."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from coworker.connectors.setup import (
connector_list,
disconnect_connector,
update_connector_tools,
)
from coworker.connectors.descriptors import list_descriptors
from coworker.connectors.tool_defs import mcp_pinned_tools, mcp_tool_defs, tool_dicts
from coworker.mcp.config import put_global_server, read_global
from coworker.secrets import SecretStore
from coworker.server.manager import SessionManager
def _state(tmp_path, monkeypatch):
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
(tmp_path / "state").mkdir(parents=True, exist_ok=True)
def test_every_mcp_backed_descriptor_pins_tools():
"""The guard that keeps 'MCP-backed' meaning 'curated': a descriptor with an
mcp_url must pin at least one tool, and every pin is classified read/write."""
backed = [d for d in list_descriptors() if d.mcp_url]
assert {d.name for d in backed} >= {"monday", "jira"}
# asana is deliberately NOT backed (2026-07-20): their V2 server rejects DCR —
# needs a pre-registered MCP app + exact redirect URI vs our dynamic sidecar
# port. Its mcp__asana__* pins sit dormant until the broker-routed callback.
assert "asana" not in {d.name for d in backed}
for d in backed:
pins = mcp_pinned_tools(d.name)
assert pins, f"{d.name} is MCP-backed but pins no tools"
for t in mcp_tool_defs(d.name):
assert t.name.startswith(f"mcp__{d.name}__")
assert t.kind in ("read", "write")
def test_tool_dicts_follow_the_profile_mode(tmp_path, monkeypatch):
"""jira has BOTH a manual REST tool set and a pinned MCP set — exactly one is
live, decided by the profile mode. MCP-only connectors always show their pin."""
_state(tmp_path, monkeypatch)
secrets = SecretStore()
names = [t["name"] for t in tool_dicts(secrets, "jira")]
assert "jira_search_issues" in names
assert not any(n.startswith("mcp__") for n in names)
secrets.put("jira:default", {"mode": "mcp"})
names = [t["name"] for t in tool_dicts(secrets, "jira")]
assert names and all(n.startswith("mcp__jira__") for n in names)
names = [t["name"] for t in tool_dicts(secrets, "monday")]
assert names and all(n.startswith("mcp__monday__") for n in names)
# asana mirrors jira: manual token → asana_* REST tools; mcp → the pin.
names = [t["name"] for t in tool_dicts(secrets, "asana")]
assert "asana_search_tasks" in names
secrets.put("asana:default", {"mode": "mcp"})
names = [t["name"] for t in tool_dicts(secrets, "asana")]
assert names and all(n.startswith("mcp__asana__") for n in names)
def test_mcp_connect_seeds_pinned_config_and_profile(tmp_path, monkeypatch):
_state(tmp_path, monkeypatch)
manager = SessionManager(data_dir=tmp_path / "data")
async def fake_connect(name):
return {"ok": True, "tools": 9}
monkeypatch.setattr(manager, "connect_mcp", fake_connect)
out = asyncio.run(manager.mcp_connect_connector("monday"))
assert out["ok"]
raw = read_global()["monday"]
assert raw["url"] == "https://mcp.monday.com/mcp"
assert raw["auth"] == "oauth"
# Server-level approval is OFF — per-tool classification gates writes instead.
assert raw["requires_approval"] is False
assert set(raw["include_tools"]) == set(mcp_pinned_tools("monday"))
assert manager.secrets.get("monday:default")["mode"] == "mcp"
# A connector without an MCP path fails closed.
out = asyncio.run(manager.mcp_connect_connector("notion"))
assert not out["ok"]
def test_failed_mcp_connect_removes_the_seeded_config(tmp_path, monkeypatch):
"""A one-click that fails (DCR rejected, user closed the browser) must not leave
an enabled oauth server behind — the leftover re-arms at every session start
(owner-hit: the pulled asana attempt froze all new sessions, 2026-07-20)."""
_state(tmp_path, monkeypatch)
manager = SessionManager(data_dir=tmp_path / "data")
async def fail_connect(name):
return {"ok": False, "error": "no DCR"}
monkeypatch.setattr(manager, "connect_mcp", fail_connect)
out = asyncio.run(manager.mcp_connect_connector("monday"))
assert not out["ok"]
assert "monday" not in read_global()
assert manager.secrets.get("monday:default") is None
def test_prepare_mcp_tools_never_starts_an_oauth_flow(tmp_path, monkeypatch):
"""Token-less oauth servers are SKIPPED at turn start — connecting one would
open a browser and block the session for the whole flow timeout. Tokens
present → the server connects as usual."""
_state(tmp_path, monkeypatch)
manager = SessionManager(data_dir=tmp_path / "data")
put_global_server(
"granola",
{"url": "https://mcp.granola.ai/mcp", "auth": "oauth", "enabled": True},
)
async def must_not_connect(server):
raise AssertionError(f"ensure() reached for token-less {server.name}")
monkeypatch.setattr(manager.mcp, "ensure", must_not_connect)
assert asyncio.run(manager.prepare_mcp_tools("s1")) == []
manager.secrets.put("mcp-oauth:granola", {"tokens": {"access_token": "at"}})
seen = {}
async def fake_ensure(server):
seen["name"] = server.name
return SimpleNamespace(tools=[])
monkeypatch.setattr(manager.mcp, "ensure", fake_ensure)
asyncio.run(manager.prepare_mcp_tools("s2"))
assert seen["name"] == "granola"
def test_connected_follows_tokens_and_disconnect_forgets_everything(
tmp_path, monkeypatch
):
_state(tmp_path, monkeypatch)
secrets = SecretStore()
secrets.put("monday:default", {"mode": "mcp", "enabled": True})
put_global_server("monday", {"url": "https://mcp.monday.com/mcp", "auth": "oauth"})
# Profile alone is just a marker — no tokens, not connected.
row = {c["name"]: c for c in connector_list(secrets)}["monday"]
assert row["mcp"] is True and row["connected"] is False
secrets.put("mcp-oauth:monday", {"tokens": {"access_token": "at"}})
row = {c["name"]: c for c in connector_list(secrets)}["monday"]
assert row["connected"] is True and row["mode"] == "mcp"
# Disconnect forgets tokens + DCR registration, the seeded config, and the profile.
out = disconnect_connector(secrets, "monday")
assert out["ok"]
assert secrets.get("mcp-oauth:monday") is None
assert "monday" not in read_global()
row = {c["name"]: c for c in connector_list(secrets)}["monday"]
assert row["connected"] is False
def test_connector_backed_servers_hidden_from_mcp_tab(tmp_path, monkeypatch):
_state(tmp_path, monkeypatch)
manager = SessionManager(data_dir=tmp_path / "data")
put_global_server("monday", {"url": "https://mcp.monday.com/mcp", "auth": "oauth"})
put_global_server("granola", {"url": "https://mcp.granola.ai/mcp", "auth": "oauth"})
names = [s["name"] for s in manager.list_mcp()]
assert "granola" in names and "monday" not in names
def _fake_tool(name):
return SimpleNamespace(
name=name, description=f"vendor {name}", inputSchema={"type": "object"}
)
def test_prepare_mcp_tools_gates_by_session_pin_and_toggles(tmp_path, monkeypatch):
"""The engine path: descriptor pin overrides stale config, per-tool toggles
subtract, session gating skips connectors outside the effective set, and
approval follows the read/write classification."""
_state(tmp_path, monkeypatch)
manager = SessionManager(data_dir=tmp_path / "data")
manager.secrets.put("monday:default", {"mode": "mcp", "enabled": True})
# Tokens present — a token-less oauth server is skipped outright (see
# test_prepare_mcp_tools_never_starts_an_oauth_flow).
manager.secrets.put("mcp-oauth:monday", {"tokens": {"access_token": "at"}})
# Stale config: include_tools claims a tool we never pinned.
put_global_server(
"monday",
{
"url": "https://mcp.monday.com/mcp",
"auth": "oauth",
"include_tools": ["manage_agent"],
"enabled": True,
},
)
update_connector_tools(manager.secrets, "monday", {"mcp__monday__search": False})
seen = {}
async def fake_ensure(server):
seen["include_tools"] = list(server.include_tools or [])
allow = set(server.include_tools or [])
tools = [_fake_tool(n) for n in ["get_board_info", "create_item", "search"]]
return SimpleNamespace(tools=[t for t in tools if t.name in allow])
monkeypatch.setattr(manager.mcp, "ensure", fake_ensure)
monkeypatch.setattr(
manager, "effective_connectors", lambda sid, agent=None: {"monday"}
)
tools = asyncio.run(manager.prepare_mcp_tools("s1"))
# Pin is authoritative (no manage_agent), the toggled-off search is gone.
assert set(seen["include_tools"]) == set(mcp_pinned_tools("monday")) - {"search"}
by_name = {t.__aisuite_tool_metadata__.name: t for t in tools}
assert "mcp__monday__search" not in by_name
# Reads run free; writes ask first.
read = by_name["mcp__monday__get_board_info"].__aisuite_tool_metadata__
write = by_name["mcp__monday__create_item"].__aisuite_tool_metadata__
assert read.requires_approval is False
assert write.requires_approval is True
# Session gating: a session whose effective set excludes monday gets nothing.
monkeypatch.setattr(manager, "effective_connectors", lambda sid, agent=None: set())
assert asyncio.run(manager.prepare_mcp_tools("s2")) == []
def test_stale_token_reauth_never_opens_a_browser_mid_turn(tmp_path, monkeypatch):
"""Tokens PRESENT but the vendor rejects the refresh → the SDK wants a browser
re-auth. Non-interactive contexts refuse (InteractiveAuthRequired, often
wrapped in an ExceptionGroup by the anyio transport): the session skips the
server and the failure is recorded — owner-hit 2026-07-20: an Atlassian
authorize page opened at app LAUNCH from a background session start."""
from coworker.mcp import oauth as mcp_oauth
_state(tmp_path, monkeypatch)
manager = SessionManager(data_dir=tmp_path / "data")
put_global_server(
"granola",
{"url": "https://mcp.granola.ai/mcp", "auth": "oauth", "enabled": True},
)
manager.secrets.put("mcp-oauth:granola", {"tokens": {"access_token": "stale"}})
async def refuses(server):
raise ExceptionGroup(
"transport", [mcp_oauth.InteractiveAuthRequired("sign-in required")]
)
monkeypatch.setattr(manager.mcp, "ensure", refuses)
assert asyncio.run(manager.prepare_mcp_tools("s1")) == []
assert "sign-in required" in manager._mcp_errors["granola"]
def test_non_interactive_auth_wiring_refuses_the_browser():
"""build_auth(interactive=False) must never open a browser: its redirect
handler raises (keeping the authorize URL for the GUI's reopen affordance),
and is_auth_required() finds the marker bare, wrapped, or chained."""
import pytest
from coworker.mcp import oauth as mcp_oauth
with pytest.raises(mcp_oauth.InteractiveAuthRequired):
asyncio.run(mcp_oauth._refuse_browser("https://vendor/authorize?x=1"))
assert mcp_oauth.last_authorize_url == "https://vendor/authorize?x=1"
bare = mcp_oauth.InteractiveAuthRequired("x")
assert mcp_oauth.is_auth_required(bare)
assert mcp_oauth.is_auth_required(ExceptionGroup("g", [ValueError(), bare]))
chained = RuntimeError("wrapped")
chained.__cause__ = bare
assert mcp_oauth.is_auth_required(chained)
assert not mcp_oauth.is_auth_required(ValueError("no"))