Files
beardthelion 9cae47fd90 fix(mcp): scope late OAuth attempts by profile
_LATE_ATTEMPTS was keyed by session key alone while live.py keys the
open-operation table by (profile_key, session_key) for exactly this
collision: two multiplexed profiles can carry the same session key (the
api_server binds X-Hermes-Session-Key verbatim, and it is client-chosen).

A parked OAuth attempt could therefore be adopted by a different
profile's next turn: adopt_late_connections would poll it, register the
same-named server from that profile's config, and enable it - a grant
authorized under profile A materializing under profile B, or being
discarded so the owning profile never adopts it.

Key the table by (profile_key, session_key), matching live.py's
documented identity pairing. The detached no-card path never opens an
operation, so its profile_key is empty; fall back to the calling
thread's home, which close() runs under the turn's profile scope.
2026-09-23 06:46:02 -07:00

820 lines
38 KiB
Python

from __future__ import annotations
import contextlib
import contextvars
import json
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
from hermes_constants import hermes_home_key
from tools.connectors.contract import Actor, SettleReason, TargetState
from tools.connectors.gateway.config import operation_session_key
from tools.connectors.operation import ConnectionOperation, DetachedOperation, IllegalTransition, Target
from tools.connectors.run import Kind, run_operation
from tools.connectors.targets import hosted_names, misrouted_to_mcp_error
from tools.registry import tool_error
logger = logging.getLogger(__name__)
# One wait for every authorization URL of a call, not one per target: the flows are started
# together, and a provider that is slow to publish its URL must not delay the others.
PREPARE_WAIT_SECONDS = 30.0
NOTE = (
"Settled once; do not re-ask on your own for any target the user skipped or that timed out, but "
"a later request from the USER for that same app is not a re-ask — run it. Connected "
"targets' tools are available now through tool_describe/tool_call and are named under "
"tools_listing. A target with discovery_error is authorized but its tools are unavailable; "
"retry discovery with manage_connections using that target's authorize or install action "
"without asking for consent again."
)
NO_CARD_NOTE = (
"No connection card is drawn for this turn. Show any connect_url to the user so they "
"open it in a browser, then ask them to say when they are done. Connected targets' tools are "
"available now through tool_describe/tool_call and are named under tools_listing. A target with "
"discovery_error is authorized but its tools are unavailable; retry discovery with "
"manage_connections using that target's authorize or install action without asking for consent "
"again. Do not re-ask on your own for skipped or timed-out targets, but a later request from "
"the USER for that same app is not a re-ask — run it."
)
def _catalog_names() -> List[str]:
from hermes_cli.mcp_catalog import list_catalog
return sorted(e.name for e in list_catalog())
def _configured_names() -> List[str]:
from hermes_cli.mcp_catalog import installed_servers
return sorted(installed_servers())
def validate_mcp_names(action: str, names: List[str]) -> Optional[str]:
try:
catalog = _catalog_names()
configured = _configured_names()
except Exception as exc:
return f"could not read the MCP catalog: {exc}"
allowed = set(catalog) if action == "install" else set(configured)
unknown = [n for n in names if n not in allowed]
if not unknown:
return None
foreign = [n for n in unknown if n not in catalog and n not in configured]
if foreign:
hosted = hosted_names() or set()
misrouted = [n for n in foreign if n in hosted]
if misrouted:
return " ".join(misrouted_to_mcp_error(action, name) for name in misrouted)
if action == "install":
return (
f"unknown MCP server(s) for install: {', '.join(unknown)}. Install works for "
f"catalog entries only: {', '.join(catalog) or '(empty catalog)'}."
+ (f" Already configured (use enable/authorize): {', '.join(configured)}." if configured else "")
)
return (
f"unknown MCP server(s) for {action}: {', '.join(unknown)}. {action} works for servers "
f"already in mcp_servers: {', '.join(configured) or '(none configured)'}."
+ (f" Catalog entries you can install: {', '.join(catalog)}." if catalog else "")
)
# ---------------------------------------------------------------------------
# the backend: the catalog, the installer, the OAuth flow
# ---------------------------------------------------------------------------
def _catalog_entry(name: str):
from hermes_cli.mcp_catalog import get_entry
entry = get_entry(name)
if entry is None:
raise ValueError(f"no catalog entry '{name}'")
return entry
class _CatalogBackend:
"""The real work behind an MCP target. One object so a caller can pass another one in."""
def required_env(self, name: str) -> List[Dict[str, Any]]:
"""The credentials the catalog entry declares that have no value yet."""
from hermes_cli.config import get_env_value
return [{"name": spec.name, "prompt": spec.prompt, "required": spec.required,
"secret": spec.secret, "default": "" if spec.secret else spec.default}
for spec in (_catalog_entry(name).auth.env or []) if not get_env_value(spec.name)]
def start_oauth(self, name: str) -> Any:
from tools.connectors import mcp_oauth
# The TUI/Desktop card path supplies its advertised client redirect in a later part.
return mcp_oauth.start(name, client_redirect_uri=None)
def installs_with_oauth(self, name: str) -> bool:
"""A catalog entry whose own OAuth the card must run. Provider-mediated OAuth is not one:
its token comes from ``hermes auth <provider>``, so the plain probe covers it."""
auth = _catalog_entry(name).auth
return auth.type == "oauth" and not auth.provider
def start_install_oauth(self, name: str, env: Dict[str, str]) -> Any:
"""Install an OAuth entry through the card's flow. The configuration is built in memory and
lands, together with the setup values, only when ``initialize`` accepts the token."""
from hermes_cli.mcp_catalog import card_install_config
from tools.connectors import mcp_oauth
entry = _catalog_entry(name)
_check_declared(name, entry, env)
return mcp_oauth.start(name, cfg=card_install_config(entry), env=env,
on_commit=lambda: _save_env(env))
def install(self, name: str, env: Dict[str, str]) -> List[str]:
"""Probe the entry's in-memory configuration with ephemeral credentials; save both only
after the server answered. A failure writes nothing, so a failed reinstall keeps the
previous configuration."""
from agent.secret_scope import (
current_secret_scope, current_secret_scope_home, reset_secret_scope, set_secret_scope)
from hermes_cli.mcp_catalog import _inline_non_secret_value, card_install_config
from hermes_cli.mcp_config import _probe_single_server, _save_mcp_server
entry = _catalog_entry(name)
_check_declared(name, entry, env)
cfg = card_install_config(entry)
# `.env` is secrets-only: non-secret values (hostnames, client ids, workspace names) are
# inlined into the server block, the same split `install_entry` makes for the terminal path.
secret_names = {spec.name for spec in (entry.auth.env or []) if spec.secret}
for key, value in env.items():
if key not in secret_names and value:
cfg = _inline_non_secret_value(cfg, key, value)
# The merged scope keeps the bound scope's home stamp: dropping it would
# reopen the env fallthrough under a routed profile with multiplex off.
token = set_secret_scope(
{**dict(current_secret_scope() or {}), **env},
profile_home=current_secret_scope_home())
try:
tools = [str(tool[0]) for tool in (_probe_single_server(name, cfg) or [])]
finally:
reset_secret_scope(token)
if not _save_mcp_server(name, cfg):
raise RuntimeError(f"'{name}' was rejected: suspicious command/args configuration")
_save_env({k: v for k, v in env.items() if k in secret_names})
return tools
def enable(self, name: str) -> None:
"""Flip ``enabled`` under the scope and lock the dashboard's toggle route uses
(``PUT /api/mcp/servers/{name}/enabled``): the two read-modify-write paths run in one
process, so an unserialised write here drops whichever landed first."""
from hermes_cli.config import load_config, save_config
from hermes_cli.web_routers._common import config_write_scope
with config_write_scope(None):
config = load_config()
servers = config.get("mcp_servers")
if not isinstance(servers, dict) or not isinstance(servers.get(name), dict):
raise ValueError(f"'{name}' is not a configured MCP server")
servers[name]["enabled"] = True
save_config(config)
def _check_declared(name: str, entry: Any, env: Dict[str, str]) -> None:
"""Configuring one MCP is not a general env-writing primitive: refuse the whole map before the
first write if any key is undeclared or unwritable."""
from hermes_cli.config import validate_env_var_name_for_write
declared = {spec.name for spec in (entry.auth.env or [])}
for key in env:
if key not in declared:
raise ValueError(f"'{name}' does not declare the environment variable {key}")
validate_env_var_name_for_write(key)
def _save_env(env: Dict[str, str]) -> None:
from hermes_cli.config import save_env_value
for key, value in env.items():
if value:
save_env_value(key, value)
def _default_backend() -> Any:
return _CatalogBackend()
# ---------------------------------------------------------------------------
# the runner: per-operation work, reachable from the RPC thread by op_id
# ---------------------------------------------------------------------------
@dataclass
class _Work:
"""One target's work in flight: an OAuth attempt the watcher polls, or a worker's outcome."""
attempt: Any = None
done: threading.Event = field(default_factory=threading.Event)
tools: List[str] = field(default_factory=list)
error: str = ""
class _Runner:
"""The backend plus the work for one operation's targets."""
def __init__(self, action: str, backend: Any):
self.action = action
self.backend = backend
self.op_id: Optional[str] = None
self.work: Dict[str, _Work] = {}
# The credentials the card approved, per target. Try again carries none (a failed row has
# no fields), so the install that runs again is the one the user approved. Kept here and
# not on the target: the values are secrets, and the runner is the one object whose life
# is exactly the operation's.
self.approved_env: Dict[str, Dict[str, str]] = {}
def run(self, table: Dict[str, Callable], operation: ConnectionOperation, target: Target,
env: Optional[Dict[str, str]] = None) -> None:
table[self.action](self, operation, target, env or {})
def spawn(self, operation: ConnectionOperation, target: Target, call: Callable[[], Any]) -> None:
"""Run one blocking backend call on a worker thread; ``observe`` reports its outcome."""
if operation.settled: # Continue landed between the state read and here
logger.debug("mcp %s %s: not started, the operation settled first", self.action, target.name)
return
work = _Work()
self.work[target.name] = work
def body() -> None:
tools: List[str] = []
error = ""
try:
tools = [str(name) for name in (call() or [])]
except Exception as exc:
error = _detail(exc, self, target)
if operation.settled:
# The result froze while the work ran; there is no row left to report into.
logger.debug("mcp %s %s: outcome dropped, the operation settled first",
self.action, target.name)
return
work.tools, work.error = tools, error
work.done.set()
operation.wake.set()
# The worker runs in a copy of the calling thread's context: a named-profile turn binds its
# home through a contextvar, and the install must write the credentials into that home.
threading.Thread(target=contextvars.copy_context().run, args=(body,), daemon=True,
name=f"mcp-{self.action}-{target.name}").start()
def prepare(self, operation: ConnectionOperation) -> None:
_RUNNERS[operation.op_id] = self
self.op_id = operation.op_id
self.operation = operation
if self.action == "authorize" and len(operation.targets) > 1:
self._prepare_together(operation)
return
for target in operation.targets:
self.run(_PREPARE, operation, target)
def _prepare_together(self, operation: ConnectionOperation) -> None:
"""Start every OAuth flow at once and wait for the URLs once. Each flow blocks until its
provider publishes an authorization URL, so a sequential prepare would keep the card empty
for one wait per target.
The wait bounds how long prepare blocks, not how long a provider may take: a row still
pending afterwards is left to its own thread, which is the only writer of that row and
ends with the URL or the flow's own failure. Failing it here as well would make two
writers of one row, and a URL that arrives a moment later would have no row to land on.
Each thread runs in its own copy of the calling thread's context: a named-profile turn
binds its home through a contextvar, and the flow resolves ``mcp_servers`` and stores the
token by that home."""
threads = [threading.Thread(target=contextvars.copy_context().run,
args=(self.run, _PREPARE, operation, target), daemon=True,
name=f"mcp-prepare-{target.name}") for target in operation.targets]
for thread in threads:
thread.start()
deadline = time.time() + PREPARE_WAIT_SECONDS
for thread in threads:
thread.join(max(0.0, deadline - time.time()))
def observe(self, operation: ConnectionOperation) -> None:
for target in operation.targets:
# Only a live target can be advanced by a read; a failed one waits for Try again.
if operation.settled or target.state not in (TargetState.pending, TargetState.initiated):
continue
_OBSERVE[self.action](self, operation, target)
def close(self) -> None:
"""The operation is over. An attempt still waiting on the browser is stopped when the user
ended the turn, and otherwise left to finish: the card closed, not the authorization. What
it commits is picked up before the session's next turn (``adopt_late_connections``)."""
if self.op_id is not None:
_RUNNERS.pop(self.op_id, None)
operation = getattr(self, "operation", None)
for name, work in list(self.work.items()):
if work.attempt is None or operation is None:
continue
if operation.settled_by == SettleReason.interrupt:
from tools.connectors.mcp_oauth import cancel_attempt
cancel_attempt(work.attempt.flow)
else:
_LATE_ATTEMPTS.setdefault(_late_key(operation), {})[name] = work.attempt
self.work.clear()
def _late_key(operation: ConnectionOperation) -> Tuple[str, str]:
"""The ``(profile, session)`` pairing ``live.open`` keys an operation by. ``profile_key``
is stamped there; the detached no-card path never opens, so fall back to the calling
thread's home — ``close`` runs on the tool thread under the turn's profile scope."""
return (operation.profile_key or hermes_home_key(), operation.session_key)
# (profile key, session key) -> {server: attempt} for OAuth attempts that outlived their card.
# The profile is part of the key for the same reason live.py keys _open by it: two multiplexed
# profiles can carry the same session key, and an attempt must only ever be adopted by the
# profile whose card authorized it.
_LATE_ATTEMPTS: Dict[Tuple[str, str], Dict[str, Any]] = {}
def adopt_late_connections(agent: Any) -> List[str]:
"""Register the servers whose authorization committed after their card had closed, and add
them to the agent's toolset selection. Runs between turns, so the result that said "not
connected" is followed by a turn in which the tools are there."""
session_key = operation_session_key(getattr(agent, "session_id", None))
key = (hermes_home_key(), session_key)
attempts = _LATE_ATTEMPTS.get(key)
if not attempts:
return []
adopted: List[str] = []
for name, attempt in list(attempts.items()):
snapshot = attempt.poll()
if snapshot["status"] == "pending":
continue
attempts.pop(name, None)
if snapshot["status"] != "approved" or snapshot.get("discovery_error"):
continue
try:
from tools.mcp_tool_config import _load_mcp_config
from tools.mcp_tool_discovery import register_mcp_servers
config = _load_mcp_config().get(name)
if isinstance(config, dict):
register_mcp_servers({name: config})
adopted.append(name)
except Exception:
logger.debug("late MCP connection %s was not adopted", name, exc_info=True)
if not attempts:
_LATE_ATTEMPTS.pop(key, None)
enabled = getattr(agent, "enabled_toolsets", None)
if adopted and enabled is not None and "no_mcp" not in enabled:
agent.enabled_toolsets = [*enabled, *(n for n in adopted if n not in enabled)]
return adopted
# op_id -> the runner driving it, so the card's answer and Try again (RPC thread) find the work.
_RUNNERS: Dict[str, _Runner] = {}
def open_runner(action: str, backend: Any = None) -> _Runner:
"""The runner for one MCP operation. ``prepare`` binds it to the operation, so the card's
answer and its Try again — both of which arrive on another thread — find the same work."""
return _Runner(action, backend or _default_backend())
# Errors that carry no message reach the user as their class name; say what happened instead.
_BARE_ERRORS = {
"CancelledError": "tool discovery was interrupted; run the same action again to list the tools",
"TimeoutError": "the server did not answer in time",
}
def _detail(exc: Any, runner: _Runner, target: Target) -> str:
"""The user-facing text of a failure. Every value the card submitted for this target is
replaced by exact match before the pattern redactor runs: an opaque credential has no
recognizable shape, so only the runner knows what to remove."""
from agent.redact import redact_sensitive_text
text = str(exc) or (_BARE_ERRORS.get(exc.__class__.__name__, exc.__class__.__name__)
if isinstance(exc, BaseException) else "error")
text = _BARE_ERRORS.get(text, text) # a worker hands over the class name of a message-less error
for value in runner.approved_env.get(target.name, {}).values():
if value:
text = text.replace(value, "[REDACTED]")
return redact_sensitive_text(text, force=True) or "error"
def _catalog_instructions(name: str) -> str:
"""The manifest's ``post_install`` text for a catalog name; a custom configured server has none."""
from hermes_cli.mcp_catalog import get_entry
entry = get_entry(name)
return str(entry.post_install or "") if entry is not None else ""
def _move(operation: ConnectionOperation, target: Target, to: TargetState, actor: Actor, **fields: Any) -> bool:
"""Move one target from the prepare, worker-outcome or observe path.
Continue on the RPC thread can settle the operation between any read of the target's state and
this call. A settled operation has a frozen result, so the lost move is dropped rather than
raised into the tool result; anything else is a real contract violation."""
try:
operation.transition(target.name, to, actor, **fields)
return True
except IllegalTransition:
if not operation.settled:
raise
logger.debug("mcp target %s: %s dropped, the operation settled first", target.name, to.value)
return False
def _fail(operation: ConnectionOperation, target: Target, detail: str) -> None:
"""Report a failure, whatever the row was doing: a repeated failure has no state change to
emit, only newer text."""
if operation.settled:
logger.debug("mcp target %s: failure dropped, the operation settled first", target.name)
return
if target.state == TargetState.failed:
operation.refresh(target.name, connect_url=None, detail=detail, actor=Actor.backend_watcher)
return
target.connect_url = None # whatever link the row was offering is dead
_move(operation, target, TargetState.failed, Actor.backend_watcher, detail=detail)
def _register_connected(runner: _Runner, target: Target, name: str) -> tuple[List[str], str]:
"""Register one committed server in the current profile scope and report its callable names."""
try:
from tools.mcp_tool_config import _load_mcp_config
from tools.mcp_tool_discovery import register_mcp_servers
config = _load_mcp_config().get(name)
if not isinstance(config, dict):
raise RuntimeError(f"no committed MCP configuration for '{name}'")
register_mcp_servers({name: config})
return _registered_tool_names(name), ""
except Exception as exc:
return [], _detail(exc, runner, target)
def _registered_tool_names(name: str, wait_seconds: float = 30.0) -> List[str]:
"""The server's callable names, read from the registry once its registration has finished.
Registration is a no-op for a server the process already holds, and that includes one another
task is still connecting: saving the configuration wakes the config watcher, which starts its
own connect, so a large server (hundreds of tools) was reported with no tools three seconds
before they were registered. A server that failed discovery earlier is parked with no tools
and is woken once. A server that finished registering with no tools is a valid empty list."""
from tools import mcp_tool as _core
from tools.mcp_tool_loop import reconnect_mcp_server
from tools.mcp_tool_scope import _resolve_server_key
from tools.registry import registry
key = _resolve_server_key(name)
deadline = time.time() + wait_seconds
woken = False
while True:
names = registry.get_tool_names_for_toolset(f"mcp-{name}")
if names or time.time() >= deadline:
return names
if key not in _core._server_connecting:
server = _core._servers.get(key)
finished = server is not None and getattr(server, "session", None) is not None \
and hasattr(server, "_registered_tool_names")
if finished:
return names
if woken or server is None or not reconnect_mcp_server(name):
return names
woken = True
time.sleep(0.25)
def _connect(operation: ConnectionOperation, target: Target, tools: List[str], discovery_error: str = "") -> None:
extra: Dict[str, Any] = {"tools": tools}
if discovery_error:
extra["discovery_error"] = discovery_error
_move(operation, target, TargetState.connected, Actor.backend_watcher, **extra)
def _actor(target: Target) -> Actor:
"""Try again is the user's move; a first attempt is the backend's."""
return Actor.user if target.state == TargetState.failed else Actor.backend_watcher
def _start_oauth(runner: _Runner, operation: ConnectionOperation, target: Target, env: Dict[str, str]) -> None:
actor = _actor(target)
target.instructions = _catalog_instructions(target.name)
try:
attempt = runner.backend.start_oauth(target.name)
except Exception as exc:
_fail(operation, target, _detail(exc, runner, target))
return
runner.work[target.name] = _Work(attempt=attempt)
_move(operation, target, TargetState.initiated, actor, connect_url=attempt.auth_url,
detail=getattr(attempt, "detail", ""))
def _declare_env(runner: _Runner, operation: ConnectionOperation, target: Target, env: Dict[str, str]) -> None:
"""The install row waits pending; the card draws a field per credential it still needs."""
target.instructions = _catalog_instructions(target.name)
try:
required = runner.backend.required_env(target.name)
except Exception as exc:
_fail(operation, target, _detail(exc, runner, target))
return
target.required_env = required
def _missing_required(runner: _Runner, target: Target, env: Dict[str, str]) -> List[Dict[str, Any]]:
"""The declared credentials that still have no value. The install runs on a worker thread,
where ``install_entry``'s prompt for a missing credential would block on stdin forever."""
declared = runner.backend.required_env(target.name)
return [spec for spec in declared
if spec.get("required", True) and not env.get(str(spec.get("name") or ""))]
def _start_install(runner: _Runner, operation: ConnectionOperation, target: Target, env: Dict[str, str]) -> None:
approved = {**runner.approved_env.get(target.name, {}), **env}
try:
missing = _missing_required(runner, target, approved)
except Exception as exc:
_fail(operation, target, _detail(exc, runner, target))
return
if missing:
# The row stays pending and the card draws a field per credential it still needs; the
# refresh is what tells the renderer to ask again.
target.required_env = missing
operation.refresh(target.name, connect_url=target.connect_url, actor=Actor.backend_watcher,
detail=f"waiting for {', '.join(str(spec['name']) for spec in missing)}")
return
runner.approved_env[target.name] = approved
actor = _actor(target)
target.required_env = [] # the credentials are written by the install; the row stops asking
if not _move(operation, target, TargetState.initiated, actor, detail=""):
return
if _installs_with_oauth(runner, target):
_start_install_oauth(runner, operation, target, approved)
return
runner.spawn(operation, target, lambda: runner.backend.install(target.name, approved))
def _installs_with_oauth(runner: _Runner, target: Target) -> bool:
try:
return bool(runner.backend.installs_with_oauth(target.name))
except Exception:
return False # the install itself reports a bad entry
def _start_install_oauth(runner: _Runner, operation: ConnectionOperation, target: Target,
env: Dict[str, str]) -> None:
"""The row is already ``initiated``; publish the authorization link onto it. The same
``initiated`` + ``connect_url`` pair is what every card reads as its URL step."""
try:
attempt = runner.backend.start_install_oauth(target.name, env)
except Exception as exc:
_fail_install(runner, operation, target, exc)
return
runner.work[target.name] = _Work(attempt=attempt)
if operation.settled:
return
operation.refresh(target.name, connect_url=attempt.auth_url, actor=Actor.backend_watcher,
detail=getattr(attempt, "detail", ""))
def _fail_install(runner: _Runner, operation: ConnectionOperation, target: Target, error: Any) -> None:
"""A failed install asks for its fields again, so the card can reopen the form over the draft
it kept. Nothing was saved, so every declared field is still missing."""
with contextlib.suppress(Exception):
target.required_env = runner.backend.required_env(target.name)
_fail(operation, target, _detail(error, runner, target))
def _do_enable(runner: _Runner, operation: ConnectionOperation, target: Target, env: Dict[str, str]) -> None:
actor = _actor(target)
if not _move(operation, target, TargetState.initiated, actor, detail=""):
return
try:
runner.backend.enable(target.name)
except Exception as exc:
_fail(operation, target, _detail(exc, runner, target))
return
tools, discovery_error = _register_connected(runner, target, target.name)
_connect(operation, target, tools, discovery_error)
def _install_now(runner: _Runner, operation: ConnectionOperation, target: Target, env: Dict[str, str]) -> None:
"""Off the desktop nobody can fill a credential in, so a missing one is the answer."""
try:
missing = [spec["name"] for spec in runner.backend.required_env(target.name) if spec.get("required", True)]
except Exception as exc:
_fail(operation, target, _detail(exc, runner, target))
return
if missing:
from hermes_constants import display_hermes_home
_fail(operation, target, f"set {', '.join(missing)} in the environment or "
f"{display_hermes_home()}/.env, then install again")
return
actor = _actor(target)
if _installs_with_oauth(runner, target):
# No card here, so the result carries the link; the flow's own worker commits the install.
try:
attempt = runner.backend.start_install_oauth(target.name, {})
except Exception as exc:
_fail(operation, target, _detail(exc, runner, target))
return
runner.work[target.name] = _Work(attempt=attempt)
_move(operation, target, TargetState.initiated, actor, connect_url=attempt.auth_url,
detail=getattr(attempt, "detail", ""))
return
_move(operation, target, TargetState.initiated, actor)
try:
runner.backend.install(target.name, {})
except Exception as exc:
_fail(operation, target, _detail(exc, runner, target))
return
tools, discovery_error = _register_connected(runner, target, target.name)
_connect(operation, target, tools, discovery_error)
def _observe_oauth(runner: _Runner, operation: ConnectionOperation, target: Target) -> None:
work = runner.work.get(target.name)
if work is None or work.attempt is None:
return
snapshot = work.attempt.poll()
status = snapshot.get("status")
if status not in ("approved", "error"):
return
runner.work.pop(target.name, None)
if status == "approved":
discovery_error = snapshot.get("discovery_error") or ""
if discovery_error:
_connect(operation, target, [], _detail(discovery_error, runner, target))
else:
tools, registration_error = _register_connected(runner, target, target.name)
_connect(operation, target, tools, registration_error)
return
error = snapshot.get("error") or "the authorization flow failed"
if runner.action == "install":
_fail_install(runner, operation, target, error)
return
_fail(operation, target, _detail(error, runner, target))
def _observe_install(runner: _Runner, operation: ConnectionOperation, target: Target) -> None:
"""An install is an OAuth attempt for an OAuth entry and a worker for every other one."""
work = runner.work.get(target.name)
observe = _observe_oauth if work is not None and work.attempt is not None else _observe_worker
observe(runner, operation, target)
def _observe_worker(runner: _Runner, operation: ConnectionOperation, target: Target) -> None:
work = runner.work.get(target.name)
if work is None or not work.done.is_set():
return
runner.work.pop(target.name, None)
if work.error:
if runner.action == "install":
_fail_install(runner, operation, target, work.error)
return
_fail(operation, target, _detail(work.error, runner, target))
return
tools, discovery_error = _register_connected(runner, target, target.name)
_connect(operation, target, tools, discovery_error)
def _nothing(runner: _Runner, operation: ConnectionOperation, target: Target, env: Dict[str, str]) -> None:
"""Authorize needs no approval: the row's verb opens the link the flow already minted."""
_PREPARE = {"authorize": _start_oauth, "install": _declare_env, "enable": _nothing}
_APPROVE = {"authorize": _nothing, "install": _start_install, "enable": _do_enable}
_RETRY = {"authorize": _start_oauth, "install": _start_install, "enable": _do_enable}
_OBSERVE = {"authorize": _observe_oauth, "install": _observe_install, "enable": _observe_worker}
_NO_CARD = {"authorize": _start_oauth, "install": _install_now, "enable": _do_enable}
# ---------------------------------------------------------------------------
# the card's answer and its Try again
# ---------------------------------------------------------------------------
def _answer_env(entry: Dict[str, Any]) -> Dict[str, str]:
raw = entry.get("env")
return {str(k): str(v) for k, v in raw.items()} if isinstance(raw, dict) else {}
def apply_answer(operation: ConnectionOperation, raw: str) -> None:
"""Fold the card's ``connection.respond`` payload into the operation: a skip, an approval that
starts the backend's work, and Continue. The card never reports an outcome, so any other claim
moves nothing."""
try:
answer = json.loads(raw)
except (TypeError, ValueError):
answer = {}
if not isinstance(answer, dict):
answer = {}
runner = _RUNNERS.get(operation.op_id)
for entry in answer.get("targets") or ():
if not isinstance(entry, dict):
continue
target = operation.target(str(entry.get("name") or "").strip().lower())
if target is None:
continue
status = str(entry.get("status") or "").lower()
if status == "skipped":
# A row the backend resolved before this move landed has nothing to move; the rest of
# the answer still applies. A settled operation is frozen. The check and the move are
# not one step, so the refusal itself is the witness, not a read taken before it.
kept = _cancel_attempt(runner, target)
try:
operation.transition(target.name, TargetState.skipped, Actor.user,
**({"detail": kept} if kept else {}))
except IllegalTransition:
if not target.resolved and not operation.settled:
raise
elif status == "approved" and runner is not None:
if target.state == TargetState.pending:
runner.run(_APPROVE, operation, target, _answer_env(entry))
elif target.state in (TargetState.failed, TargetState.expired):
# Connect on the form a failed row reopened: the same attempt, with the new values.
runner.run(_RETRY, operation, target, _answer_env(entry))
if answer.get("settled_by") == SettleReason.continue_.value and not operation.all_resolved:
operation.settle(SettleReason.continue_)
AUTHORIZATION_KEPT = ("the authorization had already completed when this was canceled, so it was "
"kept; run the same action again to list the tools")
def _cancel_attempt(runner: Optional[_Runner], target: Target) -> str:
"""Stop the target's OAuth attempt so a late reply cannot be adopted. Returns the note for a
cancel that lost the race: the attempt had committed, and a completed authorization stays."""
work = runner.work.pop(target.name, None) if runner is not None else None
flow = getattr(getattr(work, "attempt", None), "flow", None)
if flow is None:
return ""
from tools.connectors.mcp_oauth import cancel_attempt
return AUTHORIZATION_KEPT if cancel_attempt(flow) else ""
def retry(operation: ConnectionOperation, names: List[str]) -> Optional[str]:
"""Re-run the named MCP targets on the open operation (the card's Try again): a fresh OAuth
flow, a fresh install, a fresh enable. Returns an error message when the operation is not one
this module is running."""
runner = _RUNNERS.get(operation.op_id)
if runner is None:
return "this operation has no MCP work to re-run"
if operation.settled:
return "this operation has settled; its result is frozen"
for name in names:
target = operation.target(name)
if target is not None:
runner.run(_RETRY, operation, target)
return None
# ---------------------------------------------------------------------------
# the tool entry point
# ---------------------------------------------------------------------------
def _no_card_result(runner: _Runner, names: List[str], action: str, session_key: str) -> str:
operation = DetachedOperation([Target(n, "mcp", action) for n in names], session_key=session_key)
for target in operation.targets:
runner.run(_NO_CARD, operation, target)
payload = operation.result(with_urls=True)
payload["status"] = "initiated" if any(t.state == TargetState.initiated for t in operation.targets) else "settled"
payload["note"] = NO_CARD_NOTE
return json.dumps(payload, ensure_ascii=False)
def run_mcp_operation(
names: List[str],
action: str,
*,
connection_callback: Optional[Callable[[Dict[str, Any]], Optional[str]]],
session_id: Optional[str],
tool_call_id: Optional[str] = None,
backend: Any = None,
) -> str:
error = validate_mcp_names(action, names)
if error:
return tool_error(error)
runner = open_runner(action, backend)
session_key = operation_session_key(session_id)
# Every interactive surface that renders the card attaches this callback. Registry dispatch and
# messaging sessions attach none, so they receive the link instead of opening an unanswerable op.
if connection_callback is None:
return _no_card_result(runner, names, action, session_key)
try:
return run_operation(
[Target(n, "mcp", action) for n in names],
Kind(prepare=runner.prepare, observe=runner.observe, note=NOTE),
session_key=session_key, tool_call_id=tool_call_id,
connection_callback=connection_callback, with_urls_in_result=False,
)
finally:
runner.close()