diff --git a/agent/agent_init.py b/agent/agent_init.py index c2021bd16e..79bf7e9fd8 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -494,6 +494,7 @@ def init_agent( read_terminal_callback: callable = None, read_preview_callback: callable = None, read_window_below_callback: callable = None, + setup_mcp_callback: callable = None, step_callback: callable = None, stream_delta_callback: callable = None, interim_assistant_callback: callable = None, @@ -769,6 +770,7 @@ def init_agent( agent.read_terminal_callback = read_terminal_callback agent.read_preview_callback = read_preview_callback agent.read_window_below_callback = read_window_below_callback + agent.setup_mcp_callback = setup_mcp_callback agent.step_callback = step_callback agent.stream_delta_callback = stream_delta_callback agent.interim_assistant_callback = interim_assistant_callback diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 50db73c3b3..d7d5c39ebd 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -99,7 +99,7 @@ def _ra(): AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset( - {"todo", "session_search", "memory", "clarify", "read_terminal", "read_preview", "read_window_below", "delegate_task"} + {"todo", "session_search", "memory", "clarify", "read_terminal", "read_preview", "read_window_below", "setup_mcp", "delegate_task"} ) @@ -3025,6 +3025,18 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i ), next_args, ) + elif function_name == "setup_mcp": + def _execute(next_args: dict) -> Any: + from tools.setup_mcp_tool import setup_mcp_tool as _setup_mcp_tool + return _finish_agent_tool( + _setup_mcp_tool( + server=next_args.get("server", ""), + action=next_args.get("action", "install"), + reason=next_args.get("reason", ""), + callback=getattr(agent, "setup_mcp_callback", None), + ), + next_args, + ) elif function_name == "delegate_task": def _execute(next_args: dict) -> Any: return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 212ff624ed..96f3eb4786 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -903,7 +903,11 @@ PLATFORM_HINTS = { "in your response. Images (.png, .jpg, .webp) appear inline, audio and " "video play inline, and other files arrive as download links. You can " "also include image URLs in markdown format ![alt](url) and they " - "render inline as photos." + "render inline as photos. " + "When the user asks to add, enable, or authorize an MCP server (or a " + "task clearly needs one that is missing), use the setup_mcp tool if " + "it is available — it shows an inline consent card right in the chat; " + "never hand-edit mcp_servers config for them." ), "sms": ( "You are communicating via SMS. Keep responses concise and use plain text " diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 00029aabdd..1438cd08fc 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1900,6 +1900,28 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('read_window_below', function_args, tool_duration, result=function_result)}") + elif function_name == "setup_mcp": + def _execute(next_args: dict) -> Any: + from tools.setup_mcp_tool import setup_mcp_tool as _setup_mcp_tool + return _setup_mcp_tool( + server=next_args.get("server", ""), + action=next_args.get("action", "install"), + reason=next_args.get("reason", ""), + callback=getattr(agent, "setup_mcp_callback", None), + ) + function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + scope_block=_ts_scope_block, + display_index=i, + )) + tool_duration = time.time() - tool_start_time + if agent._should_emit_quiet_tool_messages(): + agent._vprint(f" {_get_cute_tool_message_impl('setup_mcp', function_args, tool_duration, result=function_result)}") elif function_name == "delegate_task": tasks_arg = function_args.get("tasks") if tasks_arg and isinstance(tasks_arg, list): diff --git a/run_agent.py b/run_agent.py index b04002e46a..2cf9c455dc 100644 --- a/run_agent.py +++ b/run_agent.py @@ -471,6 +471,7 @@ class AIAgent: read_terminal_callback: callable = None, read_preview_callback: callable = None, read_window_below_callback: callable = None, + setup_mcp_callback: callable = None, step_callback: callable = None, stream_delta_callback: callable = None, interim_assistant_callback: callable = None, @@ -558,6 +559,7 @@ class AIAgent: read_terminal_callback=read_terminal_callback, read_preview_callback=read_preview_callback, read_window_below_callback=read_window_below_callback, + setup_mcp_callback=setup_mcp_callback, step_callback=step_callback, stream_delta_callback=stream_delta_callback, interim_assistant_callback=interim_assistant_callback, diff --git a/tests/tools/test_setup_mcp_tool.py b/tests/tools/test_setup_mcp_tool.py new file mode 100644 index 0000000000..ae7be1d418 --- /dev/null +++ b/tests/tools/test_setup_mcp_tool.py @@ -0,0 +1,75 @@ +"""setup_mcp tool — the desktop inline MCP consent card's tool half. + +Behavior contracts: +- no callback (not the desktop app) → tool_error pointing at the CLI path +- empty/invalid args → tool_error +- callback answer passes through as JSON +- empty callback answer (timeout) → status "unanswered", never an error +""" + +import json + +import pytest + +from tools.setup_mcp_tool import setup_mcp_tool + + +def test_requires_desktop_callback(): + result = json.loads(setup_mcp_tool(server="linear", callback=None)) + assert "error" in result + assert "hermes mcp install" in result["error"] + + +def test_requires_server_name(): + result = json.loads(setup_mcp_tool(server=" ", callback=lambda *a: "")) + assert "error" in result + + +def test_rejects_unknown_action(): + result = json.loads( + setup_mcp_tool(server="linear", action="uninstall", callback=lambda *a: "") + ) + assert "error" in result + assert "action" in result["error"] + + +def test_passes_through_renderer_outcome(): + outcome = {"status": "installed", "server": "linear"} + + def cb(server, action, reason): + assert server == "linear" + assert action == "install" + assert reason == "to read tickets" + return json.dumps(outcome) + + result = json.loads( + setup_mcp_tool(server="linear", action="install", reason="to read tickets", callback=cb) + ) + assert result == outcome + + +def test_timeout_returns_unanswered_not_error(): + result = json.loads(setup_mcp_tool(server="figma", callback=lambda *a: "")) + assert result["status"] == "unanswered" + assert result["server"] == "figma" + + +def test_callback_exception_is_tool_error(): + def cb(*a): + raise RuntimeError("gateway went away") + + result = json.loads(setup_mcp_tool(server="figma", callback=cb)) + assert "error" in result + + +def test_non_json_answer_wrapped_as_error_status(): + result = json.loads(setup_mcp_tool(server="figma", callback=lambda *a: "garbage")) + assert result["status"] == "error" + + +@pytest.mark.parametrize("action", ["install", "enable", "authorize"]) +def test_all_actions_accepted(action): + result = json.loads( + setup_mcp_tool(server="x", action=action, callback=lambda s, a, r: json.dumps({"status": "declined", "server": s})) + ) + assert result["status"] == "declined" diff --git a/tools/setup_mcp_tool.py b/tools/setup_mcp_tool.py new file mode 100644 index 0000000000..765c5028cd --- /dev/null +++ b/tools/setup_mcp_tool.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Propose an MCP server to the user as an inline card in the desktop chat. + +The card (install / enable / authorize + decline) lives in the desktop +renderer, so this tool round-trips through the gateway's blocking-prompt +bridge — the same one ``clarify`` uses: tui_gateway emits +``mcp.setup.request``, the renderer walks the user through the flow via the +existing REST endpoints (catalog install, enable, OAuth), and answers with +``mcp.setup.respond`` once the flow settles. This module is just schema + a +thin dispatcher over the platform-injected callback. + +Lives in the ``desktop_ui`` toolset, which the GUI gateway enables only for +desktop-sourced sessions — on every other surface the agent falls back to +``hermes mcp install `` in the terminal. +""" + +import json +from typing import Callable, Optional + +from tools.registry import registry, tool_error + +_ACTIONS = ("install", "enable", "authorize") + + +def setup_mcp_tool( + server: str = "", + action: str = "install", + reason: str = "", + callback: Optional[Callable] = None, +) -> str: + """Ask the desktop GUI to run an MCP setup flow; return its JSON outcome.""" + if callback is None: + return tool_error( + "setup_mcp is only available in the Hermes desktop app. Use the " + "terminal instead: `hermes mcp install ` for catalog entries, " + "`hermes mcp login ` for OAuth." + ) + + name = (server or "").strip() + if not name: + return tool_error("server is required — the catalog or config name of the MCP server.") + + action = (action or "install").strip().lower() + if action not in _ACTIONS: + return tool_error(f"action must be one of {', '.join(_ACTIONS)}.") + + try: + raw = callback(name, action, (reason or "").strip()) + except Exception as exc: + return tool_error(f"MCP setup flow failed: {exc}") + + if not raw: + # The renderer never answered (timeout / closed window). Distinct from + # an explicit decline, which arrives as {"status": "declined"}. + return json.dumps( + { + "status": "unanswered", + "server": name, + "note": ( + "The user did not respond to the setup card. Do not retry " + "immediately; continue without the server or ask in chat." + ), + }, + ensure_ascii=False, + ) + + # Desktop answers with a JSON object; pass it through, else wrap the raw text. + try: + return json.dumps(json.loads(raw), ensure_ascii=False) + except (TypeError, ValueError): + return json.dumps({"status": "error", "detail": str(raw)}, ensure_ascii=False) + + +SETUP_MCP_SCHEMA = { + "name": "setup_mcp", + "description": ( + "Propose an MCP server to the user as an inline consent card in the " + "Hermes desktop chat. The card lets them install a catalog entry, " + "re-enable a disabled server, or run an OAuth login — right there, " + "without opening the Capabilities tab — and blocks until they act or " + "decline. Use when the user asks to add/set up an MCP (e.g. \"add the " + "linear mcp\"), or when a task clearly needs one that is missing or " + "unauthorized. Never call it twice for the same server after a " + "decline. Returns JSON {status: installed|enabled|authorized|declined|" + "unanswered|error, server, detail?, tools?}. On declined/unanswered, " + "continue without the server. Catalog names: run `hermes mcp catalog` " + "in the terminal to list them." + ), + "parameters": { + "type": "object", + "properties": { + "server": { + "type": "string", + "description": ( + "The server's catalog name (for install) or its name in " + "mcp_servers config (for enable/authorize)." + ), + }, + "action": { + "type": "string", + "enum": ["install", "enable", "authorize"], + "description": ( + "install: add a catalog entry (prompts for any required " + "keys). enable: re-enable a disabled configured server. " + "authorize: run the OAuth browser flow for a configured " + "server. Defaults to install." + ), + }, + "reason": { + "type": "string", + "description": ( + "One short sentence shown on the card: why this server " + "helps right now (e.g. \"To read the JIRA ticket you " + "linked\")." + ), + }, + }, + "required": ["server"], + }, +} + + +registry.register( + name="setup_mcp", + toolset="desktop_ui", + schema=SETUP_MCP_SCHEMA, + handler=lambda args, **kw: setup_mcp_tool( + server=args.get("server", ""), + action=args.get("action", "install"), + reason=args.get("reason", ""), + callback=kw.get("callback"), + ), + emoji="🔌", +) diff --git a/toolsets.py b/toolsets.py index f33966ebae..26d635043f 100644 --- a/toolsets.py +++ b/toolsets.py @@ -279,6 +279,7 @@ TOOLSETS = { "open_preview", "read_preview", "read_window_below", "focus_pane", "react_to_message", + "setup_mcp", ], "includes": [] }, diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index d0efe89f9f..8e78724cd8 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -974,6 +974,15 @@ def _(rid, params: dict) -> dict: return _respond(rid, params, "text", allow_expired=True) +@method("mcp.setup.respond") +def _(rid, params: dict) -> dict: + # `result` is a JSON string of the setup card's outcome ({status, server, + # detail?, tools?}). allow_expired=True: the setup_mcp tool waits 10 + # minutes, but an OAuth round-trip or a slow install can outlive that — + # a late answer must resolve gracefully, not surface a raw 4009. + return _respond(rid, params, "result", allow_expired=True) + + @method("sudo.respond") def _(rid, params: dict) -> dict: return _respond(rid, params, "password", allow_expired=True) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 14d6dbc2f2..4e15cce335 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3286,6 +3286,7 @@ def _block(event: str, sid: str, payload: dict, timeout: float | None = 300) -> "terminal.read.request", "preview.read.request", "window.read.request", + "mcp.setup.request", }: _emit( f"{event.removesuffix('.request')}.expire", @@ -4394,7 +4395,8 @@ def _tool_lifecycle_required_for_ui(name: str) -> bool: # Desktop renders the clarify choices/question from the tool-call part, then # wires request_id from clarify.request. If tool progress is off, suppressing # clarify's lifecycle events leaves only the sidebar attention dot visible. - return name == "clarify" + # setup_mcp is the same shape: its consent card mounts on the tool part. + return name in ("clarify", "setup_mcp") def _restart_slash_worker(sid: str, session: dict): @@ -5877,6 +5879,18 @@ def _agent_cbs(sid: str) -> dict: {}, timeout=30, ), + # setup_mcp tool (desktop GUI): the renderer shows an inline consent + # card and walks the user through install/enable/OAuth via the REST + # endpoints, then answers mcp.setup.respond with the JSON outcome. + # Long timeout on purpose — the flow can include typing an API key or + # a browser OAuth round-trip. Same lifecycle as clarify: on timeout + # the tool returns "unanswered" and a late answer is tolerated. + "setup_mcp_callback": lambda server, action, reason: _block( + "mcp.setup.request", + sid, + {"server": server, "action": action, "reason": reason}, + timeout=600, + ), } # Interim assistant commentary (text alongside tool calls, or the attempted