Skip to content

feat(tools): MCP elicitation handler with gateway-aware approval routing - #43112

Closed
lgalabru wants to merge 1 commit into
NousResearch:mainfrom
lgalabru:feat/mcp-elicitation-callback
Closed

feat(tools): MCP elicitation handler with gateway-aware approval routing#43112
lgalabru wants to merge 1 commit into
NousResearch:mainfrom
lgalabru:feat/mcp-elicitation-callback

Conversation

@lgalabru

@lgalabru lgalabru commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds support for the MCP elicitation/create request (Python SDK ≥ 1.11) so MCP servers can ask the user to confirm sensitive operations mid-tool-call — payment authorization, OAuth confirmation, anything that previously failed closed or required local biometrics that don't exist on a server / VM / Telegram chat.

The handler routes the prompt through Hermes's existing approval system, so the confirmation surfaces on whichever surface owns the active session (CLI, TUI, Telegram, Slack, etc.) — same UX as prompt_dangerous_approval for shell commands.

The non-obvious part is the contextvar bridge. The MCP recv-loop task that dispatches incoming elicitation/create requests is a different asyncio task from the one running the agent's tool call; it doesn't inherit HERMES_SESSION_PLATFORM or any of the other session-scoped contextvars. Without bridging, every gateway session was incorrectly classified as a CLI session, hit prompt_dangerous_approval in a worker thread with no TTY, and fail-closed declined every elicitation. The fix snapshots the agent's contextvars.copy_context() on the server task right before session.call_tool, then replays it inside the elicitation handler via Context.copy().run(...) so attribution survives the task hop.

Related Issue

No upstream issue — surfaced while integrating an MCP server (pay) whose payment authorization needs a user prompt on a remote messaging surface.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • tools/mcp_tool.py: ElicitationHandler class, _format_elicitation_schema_summary helper, _pending_call_context slot on MCPServerTask, contextvars snapshot around session.call_tool, owner back-reference on the handler.
  • tools/approval.py: request_elicitation_consent() — dispatches to _await_gateway_decision for gateway sessions, prompt_dangerous_approval for CLI/TUI; fails closed on timeout / missing notify_cb / exception.
  • tests/tools/test_mcp_elicitation.py: 15 unit tests covering form-mode accept/decline/cancel, URL-mode decline, timeout, exception fail-closed, and four regression tests for the context bridge.

How to Test

  1. uv sync --extra mcp --extra dev
  2. uv run pytest tests/tools/test_mcp_elicitation.py -v — 15 passed.
  3. Manual end-to-end against an MCP server that uses elicitation (any MCP server speaking SDK ≥ 1.11 with elicitation/create):
    • Configure the MCP server in ~/.hermes/config.yaml under mcp_servers:
    • hermes gateway setup to wire Telegram (or any gateway platform)
    • hermes gateway run
    • DM the bot, ask the agent to invoke an MCP tool whose handler emits an elicitation
    • Expected: the approval prompt arrives back on Telegram; replying accept/decline closes the elicitation and the tool call resumes accordingly

Verified locally end-to-end against pay (an MCP server that calls elicitation/create to authorize a payment): agent message arrives via Telegram → agent calls mcp_pay_curl against a paid HTTP endpoint → pay returns 402, fires elicitation/create → handler routes the approval back to the originating Telegram chat → reply approves → curl signs and completes.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this feature
  • I've run uv run pytest tests/tools/test_mcp_elicitation.py -v and all tests pass
  • I've added tests for my changes (4 new regression tests for the context bridge, plus 11 covering the broader handler)
  • I've tested on my platform: macOS 14 (darwin/arm64)

Documentation & Housekeeping

  • Docstrings updated; no user-facing config keys added — N/A for README / config example
  • No cli-config.yaml.example changes (the elicitation handler is configured per-server under the existing mcp_servers.<name> block; it activates automatically when the MCP SDK reports elicitation capability)
  • No architecture changes outside this scope — N/A for CONTRIBUTING.md / AGENTS.md
  • Cross-platform impact considered: no Unix-only syscalls introduced; scripts/check-windows-footguns.py tools/mcp_tool.py tests/tools/test_mcp_elicitation.py is clean
  • No tool schema changes (this adds an MCP-protocol-level callback, not a new Hermes tool)

Wires support for the MCP `elicitation/create` request (Python SDK 1.11+)
so MCP servers can ask the user to confirm sensitive operations
mid-tool-call (payment authorization, OAuth confirmation, etc.) instead
of failing closed or requiring out-of-band biometrics.

Behavior:

- `tools/mcp_tool.py` adds `ElicitationHandler`, attached per server task
  and passed to `ClientSession` as `elicitation_callback`. Form-mode
  requests route through the existing approval system; URL-mode requests
  decline cleanly (out of scope for this pass).
- `tools/approval.py` adds `request_elicitation_consent()`, which dispatches
  to whichever surface owns the active session — `_await_gateway_decision`
  for Telegram / Slack / etc. (so the approval prompt lands on the right
  platform), `prompt_dangerous_approval` for CLI / TUI. Fails closed on
  timeout, missing notify_cb, or exception.
- The MCP tool wrapper snapshots `contextvars.copy_context()` into
  `MCPServerTask._pending_call_context` before each `session.call_tool`
  and clears it after. The recv-loop task that dispatches incoming
  `elicitation/create` requests does not inherit the agent task's
  contextvars (HERMES_SESSION_PLATFORM and friends), so without the
  bridge `_is_gateway_approval_context()` returns False on every
  gateway session and the elicitation falls through to a CLI prompt
  that has no TTY → fail-closed decline. The handler now reads the
  snapshot via its `owner` back-reference and replays it through
  `Context.copy().run(...)` so attribution survives the task hop.

Tests (`tests/tools/test_mcp_elicitation.py`):

- form-mode accept / decline / cancel
- URL-mode declined without prompting
- exception in approval system → decline
- timeout in approval → cancel
- context-bridge regression tests (replay observed in consent call,
  missing-context fallback, multiple-replay safety, owner with
  cleared `_pending_call_context`)

Verified end-to-end against pay's MCP server on macOS: agent message
arrives via Telegram, agent calls `mcp_pay_curl` against a paid endpoint,
pay returns 402, ElicitationHandler routes the approval prompt back to
the originating Telegram chat, user replies in TG, the curl tool signs
and completes.

Platforms tested: macOS 14 (darwin/arm64). No Unix-only syscalls
introduced; Windows footgun checker passes on the touched files.
@liuhao1024

Copy link
Copy Markdown
Contributor

Verification review — no issues found.

Well-structured implementation of MCP elicitation support with strong safety properties.

Checked:

  • ✅ Fail-closed design: timeout → cancel, exception → decline, import failure → decline, URL-mode → decline (no silent accepts)
  • ✅ Context bridge: _pending_call_context is set/cleared within _rpc_lock (no race condition); Context.run() correctly uses .copy() to allow multiple elicitations per tool call
  • ✅ Approval routing: request_elicitation_consent is offloaded via asyncio.to_thread to avoid blocking the MCP event loop; gateway session detection survives the task hop via contextvar replay
  • ✅ Configuration gate: elicitation.enabled defaults to True but is explicitly checkable; SDK type availability gated separately via _MCP_ELICITATION_TYPES
  • ✅ Test coverage: 14 tests covering accept/decline/cancel flows, URL-mode rejection, exception fail-closed, timeout behavior, context bridge replay, missing context fallback, multi-replay safety, None context safety

The _OUTER_TIMEOUT_GRACE_SECONDS = 5 safety net on asyncio.wait_for is a good defensive addition — prevents indefinite blocking if the inner approval timeout machinery is bypassed.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have tool/mcp MCP client and OAuth labels Jun 9, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #49203. Your commit was cherry-picked onto current main with your authorship preserved in git log (commit 239740a). Thanks for the contribution — clean implementation that mirrors the existing SamplingHandler pattern, all 15 tests plus the broader MCP suite pass, and E2E-verified against the real approval routing. Closes this.

gnalvesteffer pushed a commit to gnalvesteffer/hermes-agent that referenced this pull request Jun 19, 2026
xyshanren pushed a commit to xyshanren/hermes-agent-cn that referenced this pull request Jun 25, 2026
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3 Low — cosmetic, nice to have tool/mcp MCP client and OAuth type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants