mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
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:
parent
7e6d60aadc
commit
16aa09aca5
12 changed files with 1959 additions and 36 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue