hermes-agent/hermes_cli/subcommands/mcp.py
Max Hsu 075f93ad78 fix(mcp): auto-recover from invalid_client on stale OAuth client registration
Fixes #36767.

Two complementary recoveries for the recurring "delete three cache files and
re-auth by hand" ritual when an MCP server's dynamically-registered OAuth
client goes dead server-side (IdP redeploy / DB wipe / rebrand):

- Auto-heal (token-endpoint subset): HermesMCPOAuthProvider now sniffs
  auth-flow responses and, on a 400/401 `invalid_client` from the discovered
  token endpoint, backs up + deletes `<server>.client.json` and `.meta.json`
  and clears the in-memory client so the SDK re-runs RFC 7591 dynamic client
  registration on the next flow. Conservative by construction: only
  dynamically-registered (non config-supplied) clients, only the token
  endpoint, only on a word-boundary `invalid_client` match (so RFC 7591's
  `invalid_client_metadata` does not trip it); best-effort so a miss never
  breaks the live flow. Covers both code-exchange and refresh when the token
  endpoint was discovered. Tokens are preserved.

- `hermes mcp reauth [<name>|--all]`: the reporter's primary symptom — the
  IdP's in-browser "Redirect URI Mismatch" — produces no HTTP signal (the SDK
  only sees a callback timeout), so it cannot be auto-detected. The new
  command re-auths one or ALL `auth: oauth` servers, serially: one browser
  flow at a time, which also fixes the startup popup storm when several
  servers are stale at once. Single-server reauth is factored out of
  `mcp login` and shared.

Tests: +14 (poison helper x2; token-endpoint detection x5 incl. wrong-endpoint,
success-response, pre-registered, and invalid_client_metadata negative guards;
a bridge integration test driving the real async_auth_flow generator to prove
the detection hook preserves the bidirectional asend() forwarding contract;
reauth CLI x6). Verified against the pinned mcp==1.26.0: scripts/run_tests.sh
122/122 green for the touched suites; check-windows-footguns.py and ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:35:27 -07:00

121 lines
4.3 KiB
Python

"""``hermes mcp`` subcommand parser.
Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2 follow-up).
Handler injected to avoid importing ``main``.
"""
from __future__ import annotations
import argparse
from typing import Callable
from hermes_cli.subcommands._shared import add_accept_hooks_flag
def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None:
"""Attach the ``mcp`` subcommand to ``subparsers``."""
mcp_parser = subparsers.add_parser(
"mcp",
help="Manage MCP servers and run Hermes as an MCP server",
description=(
"Manage MCP server connections and run Hermes as an MCP server.\n\n"
"MCP servers provide additional tools via the Model Context Protocol.\n"
"Use 'hermes mcp add' to connect to a new server, or\n"
"'hermes mcp serve' to expose Hermes conversations over MCP."
),
)
mcp_sub = mcp_parser.add_subparsers(dest="mcp_action")
mcp_serve_p = mcp_sub.add_parser(
"serve",
help="Run Hermes as an MCP server (expose conversations to other agents)",
)
mcp_serve_p.add_argument(
"-v",
"--verbose",
action="store_true",
help="Enable verbose logging on stderr",
)
add_accept_hooks_flag(mcp_serve_p)
mcp_add_p = mcp_sub.add_parser(
"add", help="Add an MCP server (discovery-first install)"
)
mcp_add_p.add_argument("name", help="Server name (used as config key)")
mcp_add_p.add_argument("--url", help="HTTP/SSE endpoint URL")
# dest="mcp_command" so this flag does not clobber the top-level
# subparser's args.command attribute, which the dispatcher reads to
# route to cmd_mcp. Without an explicit dest, argparse derives
# dest="command" from the flag name and sets it to None when the
# flag is omitted, causing `hermes mcp add ...` to fall through to
# interactive chat.
mcp_add_p.add_argument(
"--command", dest="mcp_command", help="Stdio command (e.g. npx)"
)
mcp_add_p.add_argument(
"--args",
nargs=argparse.REMAINDER,
default=[],
help="Arguments for stdio command; must be the last option",
)
mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method")
mcp_add_p.add_argument("--preset", help="Known MCP preset name")
mcp_add_p.add_argument(
"--env",
nargs="*",
default=[],
help="Environment variables for stdio servers (KEY=VALUE)",
)
mcp_rm_p = mcp_sub.add_parser("remove", aliases=["rm"], help="Remove an MCP server")
mcp_rm_p.add_argument("name", help="Server name to remove")
mcp_sub.add_parser("list", aliases=["ls"], help="List configured MCP servers")
mcp_test_p = mcp_sub.add_parser("test", help="Test MCP server connection")
mcp_test_p.add_argument("name", help="Server name to test")
mcp_cfg_p = mcp_sub.add_parser(
"configure", aliases=["config"], help="Toggle tool selection"
)
mcp_cfg_p.add_argument("name", help="Server name to configure")
mcp_login_p = mcp_sub.add_parser(
"login",
help="Force re-authentication for an OAuth-based MCP server",
)
mcp_login_p.add_argument("name", help="Server name to re-authenticate")
mcp_reauth_p = mcp_sub.add_parser(
"reauth",
help="Re-authenticate one OAuth MCP server, or all of them (--all)",
)
mcp_reauth_p.add_argument(
"name", nargs="?", help="Server name to re-authenticate (omit with --all)"
)
mcp_reauth_p.add_argument(
"--all",
action="store_true",
help="Re-authenticate every OAuth server in config, one at a time",
)
# ── Catalog (Nous-approved MCPs shipped with the repo) ─────────────────
mcp_sub.add_parser(
"picker",
help="Interactive catalog picker (also the default for `hermes mcp`)",
)
mcp_sub.add_parser(
"catalog",
help="List Nous-approved MCPs available for one-click install",
)
mcp_install_p = mcp_sub.add_parser(
"install",
help="Install a catalog MCP by name (e.g. `hermes mcp install n8n`)",
)
mcp_install_p.add_argument(
"identifier",
help="Catalog entry name (or `official/<name>`)",
)
add_accept_hooks_flag(mcp_parser)
mcp_parser.set_defaults(func=cmd_mcp)