feat(mcp): first-class MCP tab — catalog, GUI auth/probe/logs, per-tool gating

A Cursor-style MCP manager inside Capabilities, plus the backend it needs.

- Server list with brand/favicon avatars + live status dot and a capability
  summary (N tools, M prompts, K resources); Servers | Catalog views.
- Catalog: one-click install of Nous-approved servers with required-env prompts.
- GUI OAuth: Authenticate opens the system browser from the TTY-less backend and
  verifies a token actually lands; header/API-key servers are never pushed down
  OAuth; a dirty mcp.json can't drop a freshly-persisted auth field.
- Full-width mcp.json editor (ecosystem document format) + pinned stdio/agent
  LogTail; probes cached 5m and keyed by (profile, config) so revisiting never
  respawns the fleet or shows a stale probe.
- Whole-map persistence (PUT /api/mcp/servers) so deletes/toggles actually stick
  (the generic /api/config deep-merge could not remove keys).
- perf: MCP probe/auth no longer hold the global skills lock, so a slow stdio
  spawn can't stall every other request into a 15s timeout.
- per-tool include/exclude gating (lib/mcp-tool-filter) mirroring the CLI loader.
This commit is contained in:
Brooklyn Nicholson 2026-07-03 05:08:28 -05:00
parent 7e6d60aadc
commit 16aa09aca5
12 changed files with 1959 additions and 36 deletions

View file

@ -104,6 +104,15 @@ _oauth_interactive_enabled: "contextvars.ContextVar[bool]" = contextvars.Context
"_oauth_interactive_enabled", default=True
)
# Forces _is_interactive() past the stdin-TTY check for flows driven from a
# GUI (dashboard/desktop REST): the browser + localhost callback server do all
# the work there, and the stdin paste fallback degrades harmlessly (EOF is
# swallowed by _paste_callback_reader). Suppression still wins — background
# discovery must never start a browser flow.
_oauth_interactive_forced: "contextvars.ContextVar[bool]" = contextvars.ContextVar(
"_oauth_interactive_forced", default=False
)
# Skip tokens accepted at the paste prompt — exit OAuth without auth.
_SKIP_TOKENS = frozenset({"skip", "cancel", "s", "n", "no", "q", "quit"})
@ -150,12 +159,30 @@ def _is_interactive() -> bool:
"""Return True if we can reasonably expect to interact with a user."""
if not _oauth_interactive_enabled.get():
return False
if _oauth_interactive_forced.get():
return True
try:
return sys.stdin.isatty()
except (AttributeError, ValueError):
return False
@contextmanager
def force_interactive_oauth():
"""Treat the current execution context as interactive despite no TTY.
For GUI-driven auth (dashboard/desktop REST endpoint): the user IS present
just not on stdin. Opens the browser + localhost callback flow that the
TTY heuristic would otherwise refuse. Same ContextVar propagation story as
suppress_interactive_oauth() (#35927).
"""
token = _oauth_interactive_forced.set(True)
try:
yield
finally:
_oauth_interactive_forced.reset(token)
@contextmanager
def suppress_interactive_oauth():
"""Disable stdin-based OAuth prompts for the current execution context.

View file

@ -359,6 +359,19 @@ _CREDENTIAL_PATTERN = re.compile(
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}")
def _env_ref_name(ref: str) -> str:
"""Normalize a ``${...}`` reference body into an env-var name.
Accepts Cursor-style ``${env:VAR}`` in addition to plain ``${VAR}`` by
stripping a leading ``env:`` prefix. The result is the bare variable name
to look up in the secret scope / ``os.environ``.
"""
ref = ref.strip()
if ref.startswith("env:"):
ref = ref[len("env:"):].strip()
return ref
# ---------------------------------------------------------------------------
# Security helpers
# ---------------------------------------------------------------------------
@ -3207,17 +3220,20 @@ def _interrupted_call_result() -> str:
def _interpolate_env_vars(value):
"""Recursively resolve ``${VAR}`` placeholders.
Resolves from the active profile's secret scope when multiplexing is on
(so an MCP server config's ``${API_KEY}`` picks up the routed profile's
value, not the process-global ``os.environ`` which may hold another
profile's), falling back to ``os.environ`` otherwise. Unset vars keep the
literal ``${VAR}`` placeholder, as before.
Both ``${VAR}`` and Cursor-style ``${env:VAR}`` are accepted the
``env:`` prefix is stripped so a doc copied from a Cursor / Claude MCP
config resolves the same secret. Resolves from the active profile's secret
scope when multiplexing is on (so an MCP server config's ``${API_KEY}``
picks up the routed profile's value, not the process-global ``os.environ``
which may hold another profile's), falling back to ``os.environ``
otherwise. Unset vars keep the literal placeholder, as before.
"""
from agent.secret_scope import get_secret as _get_secret
if isinstance(value, str):
def _replace(m):
return _get_secret(m.group(1), m.group(0)) or m.group(0)
name = _env_ref_name(m.group(1))
return _get_secret(name, m.group(0)) or m.group(0)
return _ENV_VAR_PATTERN.sub(_replace, value)
if isinstance(value, dict):
return {k: _interpolate_env_vars(v) for k, v in value.items()}