mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 19:35:18 +08:00
Compare commits
8 Commits
fix/mcp-di
...
v2026.6.19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd1977d8f | ||
|
|
40722058e5 | ||
|
|
4c5217b717 | ||
|
|
ba49fb51a5 | ||
|
|
f06508836d | ||
|
|
8dc0b18894 | ||
|
|
2d978bf44a | ||
|
|
da7253215d |
@@ -121,10 +121,11 @@ outside the supported security posture.
|
||||
### 2.3 Credential Scoping
|
||||
|
||||
Hermes Agent filters the environment it passes to its lower-trust
|
||||
in-process components: shell subprocesses, MCP subprocesses, and
|
||||
the code-execution child. Credentials like provider API keys and
|
||||
gateway tokens are stripped by default; variables explicitly
|
||||
declared by the operator or by a loaded skill are passed through.
|
||||
in-process components: shell subprocesses, MCP subprocesses,
|
||||
cron job scripts, and the code-execution child. Credentials like
|
||||
provider API keys and gateway tokens are stripped by default;
|
||||
variables explicitly declared by the operator or by a loaded
|
||||
skill are passed through.
|
||||
|
||||
This reduces casual exfiltration. It is not containment. Any
|
||||
component running inside the agent process (skills, plugins, hook
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.16.0",
|
||||
"package": "hermes-agent[acp]==0.17.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -817,6 +817,10 @@ platform_toolsets:
|
||||
# Optional per-server settings:
|
||||
# timeout: tool call timeout in seconds (default: 120)
|
||||
# connect_timeout: initial connection timeout (default: 60)
|
||||
# keepalive_interval: liveness ping cadence in seconds (default: 180).
|
||||
# Lower it below the server's session TTL for servers that expire idle
|
||||
# sessions quickly (e.g. Unreal Engine editor MCP, ~15s), otherwise idle
|
||||
# tool calls hit an expired session and pay a slow reconnect. Floored at 5s.
|
||||
#
|
||||
# mcp_servers:
|
||||
# time:
|
||||
|
||||
@@ -961,6 +961,10 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
Shell support lets ``no_agent=True`` jobs ship classic bash watchdogs
|
||||
(the `memory-watchdog.sh` pattern) without wrapping them in Python.
|
||||
|
||||
Subprocess environment is passed through ``_sanitize_subprocess_env`` so
|
||||
provider credentials and other Hermes-managed secrets are not inherited
|
||||
(SECURITY.md §2.3), matching terminal and MCP child processes.
|
||||
|
||||
Args:
|
||||
script_path: Path to the script. Relative paths are resolved
|
||||
against HERMES_HOME/scripts/. Absolute and ~-prefixed paths
|
||||
@@ -1022,6 +1026,8 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
argv = [sys.executable, str(path)]
|
||||
|
||||
try:
|
||||
from tools.environments.local import _sanitize_subprocess_env
|
||||
|
||||
popen_kwargs = {"creationflags": windows_hide_flags()} if sys.platform == "win32" else {}
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
@@ -1029,6 +1035,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
||||
text=True,
|
||||
timeout=script_timeout,
|
||||
cwd=str(path.parent),
|
||||
env=_sanitize_subprocess_env(os.environ.copy()),
|
||||
**popen_kwargs,
|
||||
)
|
||||
stdout = (result.stdout or "").strip()
|
||||
|
||||
@@ -14,8 +14,8 @@ Provides subcommands for:
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.16.0"
|
||||
__release_date__ = "2026.6.5"
|
||||
__version__ = "0.17.0"
|
||||
__release_date__ = "2026.6.19"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
@@ -26,6 +26,19 @@ from typing import Callable, Dict, List, Optional, Any, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _Snowflake:
|
||||
"""Minimal object exposing ``.id`` — satisfies discord.py's Snowflake
|
||||
protocol for ``channel.history(before=...)`` without constructing a
|
||||
``discord.Object`` (which test doubles that stub the discord module
|
||||
cannot build). Used to anchor reply-context scans inclusively.
|
||||
"""
|
||||
|
||||
__slots__ = ("id",)
|
||||
|
||||
def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
|
||||
self.id = id
|
||||
|
||||
VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080}
|
||||
_DISCORD_COMMAND_SYNC_POLICIES = {"safe", "bulk", "off"}
|
||||
_DISCORD_COMMAND_SYNC_STATE_SUBDIR = "gateway"
|
||||
@@ -4255,6 +4268,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self,
|
||||
channel: Any,
|
||||
before: "DiscordMessage",
|
||||
reply_target: Optional[Any] = None,
|
||||
) -> str:
|
||||
"""Fetch recent channel messages for conversational context.
|
||||
|
||||
@@ -4262,6 +4276,13 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
a message sent by this bot (the natural partition point between
|
||||
bot turns) or reaches ``history_backfill_limit``.
|
||||
|
||||
When ``reply_target`` is provided (the user replied to a specific
|
||||
message), a second backward scan is run ending at that target so the
|
||||
agent sees the conversation surrounding what the user pointed at —
|
||||
even when the reply target sits *before* the most recent bot turn and
|
||||
would otherwise be cut off by the self-message partition. The two
|
||||
windows are merged chronologically and de-duplicated by message ID.
|
||||
|
||||
Returns a formatted block like::
|
||||
|
||||
[Recent channel messages]
|
||||
@@ -4295,7 +4316,47 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
pass # Malformed cache entry — fall back to cold-start scan
|
||||
|
||||
try:
|
||||
collected = []
|
||||
def _keep(msg) -> Optional[str]:
|
||||
"""Return a formatted ``[name] content`` line, or None to skip.
|
||||
|
||||
Encapsulates the system-message / non-conversational / other-bot
|
||||
filtering so both the primary and reply-anchored scans apply
|
||||
identical rules. Does NOT enforce the self-message partition —
|
||||
callers decide where to stop.
|
||||
"""
|
||||
if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:
|
||||
return None
|
||||
content = getattr(msg, "clean_content", msg.content) or ""
|
||||
if (
|
||||
str(getattr(msg, "id", "")) in self._nonconversational_messages
|
||||
or _looks_like_nonconversational_history_message(content)
|
||||
):
|
||||
return None
|
||||
# Respect DISCORD_ALLOW_BOTS for other bots. For history
|
||||
# context, "mentions" is treated as "all" — we are deciding
|
||||
# what context to show, not whether to respond.
|
||||
if (
|
||||
getattr(msg.author, "bot", False)
|
||||
and msg.author != self._client.user
|
||||
and not include_other_bots
|
||||
):
|
||||
return None
|
||||
if not content and msg.attachments:
|
||||
content = "(attachment)"
|
||||
if not content:
|
||||
return None
|
||||
name = (
|
||||
getattr(msg.author, "display_name", None)
|
||||
or getattr(msg.author, "name", None)
|
||||
or "unknown"
|
||||
)
|
||||
if getattr(msg.author, "bot", False):
|
||||
name = f"{name} [bot]"
|
||||
return f"[{name}] {content}"
|
||||
|
||||
# ── Primary window: recent channel activity since the last bot turn ──
|
||||
collected: List[Tuple[str, str]] = [] # (message_id, line)
|
||||
seen_ids: set = set()
|
||||
# IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x
|
||||
# silently flips the default to True when `after=` is supplied,
|
||||
# which would select the *earliest* N messages after our last
|
||||
@@ -4309,45 +4370,89 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
after=_after_obj,
|
||||
oldest_first=False,
|
||||
):
|
||||
# Skip system messages (pins, joins, thread renames, etc.)
|
||||
if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:
|
||||
continue
|
||||
|
||||
content = getattr(msg, "clean_content", msg.content) or ""
|
||||
# Non-conversational lifecycle/status bumps (self-improvement
|
||||
# reviews, background-process notices, restart banners) must be
|
||||
# skipped BEFORE the partition check — otherwise a delayed
|
||||
# status bump authored by us would be mistaken for the real
|
||||
# last bot turn and hide messages that came after it.
|
||||
_content = getattr(msg, "clean_content", msg.content) or ""
|
||||
if (
|
||||
str(getattr(msg, "id", "")) in self._nonconversational_messages
|
||||
or _looks_like_nonconversational_history_message(content)
|
||||
or _looks_like_nonconversational_history_message(_content)
|
||||
):
|
||||
continue
|
||||
|
||||
# Stop at our own message — this is the partition point.
|
||||
# Everything before this is already in the session transcript.
|
||||
# (Redundant when _after_obj is set, but needed for cold start.)
|
||||
# Stop at our own (conversational) message — this is the
|
||||
# partition point. Everything before this is already in the
|
||||
# session transcript. (Redundant when _after_obj is set, but
|
||||
# needed for cold start.)
|
||||
if msg.author == self._client.user:
|
||||
break
|
||||
|
||||
# Respect DISCORD_ALLOW_BOTS for other bots.
|
||||
# For history context, "mentions" is treated as "all" — we are
|
||||
# deciding what context to show, not whether to respond.
|
||||
if getattr(msg.author, "bot", False) and not include_other_bots:
|
||||
line = _keep(msg)
|
||||
if line is None:
|
||||
continue
|
||||
mid = str(getattr(msg, "id", ""))
|
||||
collected.append((mid, line))
|
||||
if mid:
|
||||
seen_ids.add(mid)
|
||||
|
||||
if not content and msg.attachments:
|
||||
content = "(attachment)"
|
||||
if not content:
|
||||
continue
|
||||
# ── Reply window: context around the message the user pointed at ──
|
||||
# When the user replied to a specific message that sits BEFORE the
|
||||
# primary window's partition point, the surrounding exchange isn't
|
||||
# captured above. Fetch a small window ending just after the reply
|
||||
# target so the agent sees what it was referencing. This window is
|
||||
# NOT partitioned on the self-message boundary — the whole point is
|
||||
# to surface older context the transcript lacks.
|
||||
reply_collected: List[Tuple[str, str]] = []
|
||||
reply_target_id = str(getattr(reply_target, "id", "")) if reply_target else ""
|
||||
if reply_target is not None and reply_target_id and reply_target_id not in seen_ids:
|
||||
# Reuse the same cap as the primary scan but keep the reply
|
||||
# window modest — it's anchored context, not a full backfill.
|
||||
reply_limit = max(1, min(limit, 10))
|
||||
# `before` is exclusive in discord.py, so to *include* the
|
||||
# target we anchor at target_id + 1. Use a minimal snowflake
|
||||
# shim (any object exposing ``.id`` satisfies discord.py's
|
||||
# Snowflake protocol) rather than discord.Object, so this path
|
||||
# works under test doubles that stub the discord module too.
|
||||
try:
|
||||
_before_obj = _Snowflake(int(reply_target_id) + 1)
|
||||
except (ValueError, TypeError):
|
||||
_before_obj = before
|
||||
async for msg in channel.history(
|
||||
limit=reply_limit,
|
||||
before=_before_obj,
|
||||
oldest_first=False,
|
||||
):
|
||||
line = _keep(msg)
|
||||
if line is None:
|
||||
continue
|
||||
mid = str(getattr(msg, "id", ""))
|
||||
if mid and mid in seen_ids:
|
||||
continue
|
||||
reply_collected.append((mid, line))
|
||||
if mid:
|
||||
seen_ids.add(mid)
|
||||
|
||||
name = msg.author.display_name
|
||||
if getattr(msg.author, "bot", False):
|
||||
name = f"{name} [bot]"
|
||||
collected.append(f"[{name}] {content}")
|
||||
|
||||
if not collected:
|
||||
if not collected and not reply_collected:
|
||||
return ""
|
||||
|
||||
# channel.history returns newest-first (oldest_first=False); reverse for chronological order
|
||||
# channel.history returns newest-first; reverse each window for
|
||||
# chronological order, then present reply context first (it is
|
||||
# older) followed by the recent activity.
|
||||
collected.reverse()
|
||||
return "[Recent channel messages]\n" + "\n".join(collected)
|
||||
reply_collected.reverse()
|
||||
|
||||
blocks: List[str] = []
|
||||
if reply_collected:
|
||||
blocks.append(
|
||||
"[Context around the replied-to message]\n"
|
||||
+ "\n".join(line for _id, line in reply_collected)
|
||||
)
|
||||
if collected:
|
||||
blocks.append(
|
||||
"[Recent channel messages]\n"
|
||||
+ "\n".join(line for _id, line in collected)
|
||||
)
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
except discord.Forbidden:
|
||||
logger.debug("[%s] Missing permissions to fetch channel history", self.name)
|
||||
@@ -5381,14 +5486,40 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# - any thread (in_bot_thread bypasses the mention check, but
|
||||
# processing-window gaps and post-restart context still need
|
||||
# recovery)
|
||||
# - any reply (the user pointed at a specific message; hydrate
|
||||
# the context around it even in a free-response channel where
|
||||
# no mention gap exists — otherwise replies get only the short
|
||||
# "[Replying to: ...]" snippet with no surrounding context)
|
||||
# DMs skip entirely because every DM message triggers the bot,
|
||||
# so the session transcript already has everything.
|
||||
# Auto-threaded messages also skip — we just created the thread,
|
||||
# there's nothing prior to backfill.
|
||||
_has_mention_gap = require_mention and not is_free_channel and not in_bot_thread
|
||||
if (_has_mention_gap or is_thread) and auto_threaded_channel is None:
|
||||
_is_reply = message.reference is not None
|
||||
|
||||
# Resolve the replied-to message into an object exposing ``.id``.
|
||||
# discord.py may give us a full Message (resolved), a
|
||||
# DeletedReferencedMessage, or nothing. Duck-type on ``.id``
|
||||
# rather than isinstance(discord.Message) — under test doubles the
|
||||
# discord module (and thus discord.Message) can be a mock, which is
|
||||
# not a valid isinstance() second argument. Any object with an int
|
||||
# id works as a scan anchor; otherwise fall back to a bare snowflake
|
||||
# built from the reference's message_id.
|
||||
_reply_target = None
|
||||
if _is_reply:
|
||||
_resolved = getattr(message.reference, "resolved", None)
|
||||
_resolved_id = getattr(_resolved, "id", None) if _resolved is not None else None
|
||||
if _resolved_id is not None:
|
||||
_reply_target = _resolved
|
||||
else:
|
||||
_ref_mid = getattr(message.reference, "message_id", None)
|
||||
if _ref_mid is not None:
|
||||
with suppress(ValueError, TypeError):
|
||||
_reply_target = _Snowflake(int(_ref_mid))
|
||||
|
||||
if (_has_mention_gap or is_thread or _is_reply) and auto_threaded_channel is None:
|
||||
_backfill_text = await self._fetch_channel_context(
|
||||
message.channel, before=message,
|
||||
message.channel, before=message, reply_target=_reply_target,
|
||||
)
|
||||
if _backfill_text:
|
||||
_channel_context = _backfill_text
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-agent"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
|
||||
readme = "README.md"
|
||||
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
|
||||
|
||||
@@ -1587,6 +1587,26 @@ AUTHOR_MAP = {
|
||||
"infinitycrew39@gmail.com": "infinitycrew39", # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292)
|
||||
"eurekaxun@163.com": "huangxun375-stack", # PR #37251 / #48894 structured OpenViking sync
|
||||
"218421507+Sahil-SS9@users.noreply.github.com": "Sahil-SS9", # PR #48466/#44919/#44909/#42209 salvage (cron/checkpoint/kanban/skill)
|
||||
# v0.17.0 additions
|
||||
"2081789787@qq.com": "pengyuyanITYU", # PR #43618 (harden local file tree paths)
|
||||
"adalsteinni@gmail.com": "AIalliAI", # PR #44159 (desktop hover-reveal inset)
|
||||
"ameobius@local.host": "ameobius", # PR #44383 co-author (discord gateway task recovery)
|
||||
"andyfieb@gmail.com": "mollusk", # PR #44493 (desktop assistant-ui recovery)
|
||||
"drmani215@gmail.com": "bionicbutterfly13", # direct email match
|
||||
"enesilhaydin@gmail.com": "enesilhaydin", # direct email match
|
||||
"evisolpxe@gmail.com": "Evisolpxe", # direct email match
|
||||
"fyzan.shaik@gmail.com": "fyzanshaik", # direct email match
|
||||
"info@amik.co": "AMIK-coorporations", # PR #40578 (Urdu README) co-author
|
||||
"info@amikchat.site": "AMIK-coorporations", # PR #40578 (Urdu README)
|
||||
"kyssta69@gmail.com": "kyssta-exe", # PR #44282 (Windows dashboard re-exec)
|
||||
"loongfay@foxmail.com": "loongfay", # PR #43508 (Yuanbao wechat forward msg)
|
||||
"maplestoryjuni222@gmail.com": "BROCCOLO1D", # PR #42733 (lazy-parse docker env config)
|
||||
"marvin@photon.codes": "underthestars-zhy", # PR #46907 co-author (Photon Spectrum project ids)
|
||||
"omar@kostudios.io": "OmarB97", # PR #43977 (desktop session model metadata)
|
||||
"omarbaradei21@gmail.com": "OmarB97", # PR #43977 (desktop session model metadata)
|
||||
"philip.a.dsouza@gmail.com": "PhilipAD", # direct email match
|
||||
"qs2816661685@gmail.com": "qingshan89", # PR #46895 co-author (desktop remote artifact download)
|
||||
"yspdev@gmail.com": "AJ", # PR #44510 co-author (desktop named-profile boot loop)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -132,6 +132,31 @@ class TestRunJobScript:
|
||||
assert "exited with code 1" in output
|
||||
assert "error info" in output
|
||||
|
||||
def test_script_subprocess_env_sanitized(self, cron_env, monkeypatch):
|
||||
"""Cron scripts must not inherit Hermes provider env (SECURITY.md §2.3)."""
|
||||
from tools.environments.local import _HERMES_PROVIDER_ENV_BLOCKLIST
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
# sorted() so the probed var is deterministic across runs
|
||||
# (frozenset iteration order varies with PYTHONHASHSEED).
|
||||
blocked_var = sorted(_HERMES_PROVIDER_ENV_BLOCKLIST)[0]
|
||||
monkeypatch.setenv(blocked_var, "must_not_leak")
|
||||
|
||||
script = cron_env / "scripts" / "env_probe.py"
|
||||
script.write_text(
|
||||
textwrap.dedent(
|
||||
f"""\
|
||||
import os
|
||||
key = {blocked_var!r}
|
||||
print("PRESENT" if os.environ.get(key) else "ABSENT")
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
success, output = _run_job_script("env_probe.py")
|
||||
assert success is True
|
||||
assert output == "ABSENT"
|
||||
|
||||
def test_script_empty_output(self, cron_env):
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ def _ensure_discord_mock():
|
||||
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
|
||||
discord_mod.Interaction = object
|
||||
discord_mod.Embed = MagicMock
|
||||
discord_mod.Object = lambda *, id: SimpleNamespace(id=id)
|
||||
discord_mod.Message = type("Message", (), {})
|
||||
discord_mod.app_commands = SimpleNamespace(
|
||||
describe=lambda **kwargs: (lambda fn: fn),
|
||||
choices=lambda **kwargs: (lambda fn: fn),
|
||||
@@ -721,6 +723,84 @@ async def test_fetch_channel_context_skips_self_improvement_boundary_message(ada
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_channel_context_hydrates_around_reply_target(adapter, monkeypatch):
|
||||
"""Replying to an older message pulls the surrounding exchange into context.
|
||||
|
||||
The reply target sits *before* the self-message partition point, so the
|
||||
primary scan alone would miss it. The reply-anchored window must surface
|
||||
the target and its neighbours under a distinct header, with the recent
|
||||
activity still appearing afterwards.
|
||||
"""
|
||||
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
|
||||
adapter.config.extra["history_backfill_limit"] = 10
|
||||
|
||||
bot_user = adapter._client.user
|
||||
human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False)
|
||||
other = SimpleNamespace(id=58, display_name="Carol", name="Carol", bot=False)
|
||||
|
||||
channel = FakeHistoryChannel(
|
||||
[
|
||||
# Recent activity (after our last response, captured by primary scan)
|
||||
make_history_message(author=human, content="latest note", msg_id=6),
|
||||
make_history_message(author=bot_user, content="our prior response", msg_id=5),
|
||||
# Older exchange — behind the partition, only reachable via reply anchor
|
||||
make_history_message(author=bot_user, content="the bot answer being replied to", msg_id=3),
|
||||
make_history_message(author=other, content="older question", msg_id=2),
|
||||
make_history_message(author=human, content="even older", msg_id=1),
|
||||
],
|
||||
channel_id=123,
|
||||
)
|
||||
|
||||
# User replied to the bot's older answer (msg_id=3).
|
||||
reply_target = SimpleNamespace(id=3)
|
||||
trigger = make_message(channel=channel, content="follow-up about that")
|
||||
|
||||
result = await adapter._fetch_channel_context(
|
||||
channel, before=trigger, reply_target=reply_target,
|
||||
)
|
||||
|
||||
# Reply context comes first (older), then recent activity. The reply
|
||||
# window is NOT cut off at the self-message boundary, so msg_id=3 (a bot
|
||||
# message) and its neighbours appear.
|
||||
assert "[Context around the replied-to message]" in result
|
||||
assert "the bot answer being replied to" in result
|
||||
assert "older question" in result
|
||||
assert "[Recent channel messages]" in result
|
||||
assert "latest note" in result
|
||||
assert result.index("[Context around the replied-to message]") < result.index("[Recent channel messages]")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_channel_context_reply_target_in_primary_window_not_duplicated(adapter, monkeypatch):
|
||||
"""When the reply target is already in the recent window, don't double it."""
|
||||
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
|
||||
adapter.config.extra["history_backfill_limit"] = 10
|
||||
|
||||
bot_user = adapter._client.user
|
||||
human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False)
|
||||
|
||||
channel = FakeHistoryChannel(
|
||||
[
|
||||
make_history_message(author=human, content="recent reply target", msg_id=4),
|
||||
make_history_message(author=human, content="another recent", msg_id=3),
|
||||
make_history_message(author=bot_user, content="our prior response", msg_id=2),
|
||||
],
|
||||
channel_id=123,
|
||||
)
|
||||
|
||||
reply_target = SimpleNamespace(id=4) # already inside the primary window
|
||||
trigger = make_message(channel=channel, content="re: that")
|
||||
|
||||
result = await adapter._fetch_channel_context(
|
||||
channel, before=trigger, reply_target=reply_target,
|
||||
)
|
||||
|
||||
# No separate reply block, and the target text appears exactly once.
|
||||
assert "[Context around the replied-to message]" not in result
|
||||
assert result.count("recent reply target") == 1
|
||||
|
||||
|
||||
def test_nonconversational_fallback_requires_self_improvement_emoji():
|
||||
assert discord_platform._looks_like_nonconversational_history_message(
|
||||
"💾 Self-improvement review: Memory updated"
|
||||
@@ -1016,3 +1096,61 @@ async def test_discord_auto_thread_skips_backfill(adapter, monkeypatch):
|
||||
|
||||
adapter._auto_create_thread.assert_awaited_once()
|
||||
adapter._fetch_channel_context.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_reply_in_free_channel_triggers_backfill(adapter, monkeypatch):
|
||||
"""Replying to a message hydrates context even in a free-response channel.
|
||||
|
||||
This is the gap the reply-context feature closes: with no mention
|
||||
requirement there is no "mention gap", so the old gate skipped backfill
|
||||
and a reply received only the short "[Replying to: ...]" snippet. A reply
|
||||
must now route through _fetch_channel_context with the replied-to message
|
||||
as the anchor.
|
||||
"""
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") # free-response
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||
adapter.config.extra["history_backfill"] = True
|
||||
adapter._fetch_channel_context = AsyncMock(
|
||||
return_value="[Context around the replied-to message]\n[Hermes [bot]] earlier answer"
|
||||
)
|
||||
|
||||
message = make_message(channel=FakeTextChannel(channel_id=321), content="what about edge cases?")
|
||||
# Simulate a Discord reply: reference points at an earlier message id.
|
||||
message.reference = SimpleNamespace(message_id=42, resolved=None)
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter._fetch_channel_context.assert_awaited_once()
|
||||
# The reply target is passed as the anchor, carrying the referenced id.
|
||||
call = adapter._fetch_channel_context.await_args
|
||||
assert getattr(call.kwargs.get("reply_target"), "id", None) == 42
|
||||
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.channel_context == (
|
||||
"[Context around the replied-to message]\n[Hermes [bot]] earlier answer"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_non_reply_free_channel_skips_backfill(adapter, monkeypatch):
|
||||
"""A plain (non-reply) message in a free-response channel still skips backfill.
|
||||
|
||||
Guards against the reply gate accidentally widening to every free-channel
|
||||
message — only replies (and the existing mention-gap / thread cases) should
|
||||
hydrate context.
|
||||
"""
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||
adapter.config.extra["history_backfill"] = True
|
||||
adapter._fetch_channel_context = AsyncMock(return_value="[Recent channel messages]\n[Alice] noise")
|
||||
|
||||
message = make_message(channel=FakeTextChannel(channel_id=321), content="just chatting")
|
||||
assert message.reference is None # not a reply
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter._fetch_channel_context.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
Prompt-only / resource-only MCP servers do not implement the ``tools/*``
|
||||
request family. Per the MCP spec, ``InitializeResult.capabilities.tools``
|
||||
is non-None iff the server supports it. Before this fix, Hermes always
|
||||
called ``tools/list`` during discovery and as the keepalive probe — both
|
||||
raised ``McpError(-32601 Method not found)`` against such servers, so a
|
||||
prompt-only server could never stay connected.
|
||||
is non-None iff the server supports it. Before the capability gate, Hermes
|
||||
always called ``tools/list`` during discovery, which raised
|
||||
``McpError(-32601 Method not found)`` against such servers, so a prompt-only
|
||||
server could never stay connected. Discovery/refresh remain capability-gated.
|
||||
|
||||
Ported from anomalyco/opencode#31271.
|
||||
The keepalive probe uses ``ping`` (MCP base-protocol liveness) for every
|
||||
server regardless of capability: it works uniformly and stays a few bytes
|
||||
instead of pulling the full ``tools/list`` payload (which is ~1 MB on large
|
||||
servers like Unreal Engine's editor MCP). Its cadence is configurable via
|
||||
``keepalive_interval`` so servers with short session TTLs stay alive.
|
||||
|
||||
Discovery gating ported from anomalyco/opencode#31271.
|
||||
"""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
@@ -143,7 +149,10 @@ class TestKeepaliveProbe:
|
||||
task.session.send_ping.assert_awaited_once()
|
||||
task.session.list_tools.assert_not_called()
|
||||
|
||||
async def test_keepalive_uses_list_tools_for_tool_capable_server(self):
|
||||
async def test_keepalive_uses_ping_for_tool_capable_server(self):
|
||||
"""Keepalive uses ``ping`` even for tool-capable servers, so the probe
|
||||
stays a few bytes regardless of tool count (no ``list_tools`` payload).
|
||||
Tool-list changes still arrive via tools/list_changed notifications."""
|
||||
task = MCPServerTask("test")
|
||||
task.initialize_result = _caps(tools=SimpleNamespace())
|
||||
task.session = SimpleNamespace(
|
||||
@@ -154,5 +163,195 @@ class TestKeepaliveProbe:
|
||||
reason = await self._run_one_keepalive_cycle(task)
|
||||
|
||||
assert reason == "shutdown"
|
||||
task.session.send_ping.assert_awaited_once()
|
||||
task.session.list_tools.assert_not_called()
|
||||
|
||||
async def test_keepalive_uses_ping_legacy_fallback(self):
|
||||
"""No captured capabilities → still pings (no spurious list_tools)."""
|
||||
task = MCPServerTask("test")
|
||||
assert task.initialize_result is None
|
||||
task.session = SimpleNamespace(
|
||||
list_tools=AsyncMock(),
|
||||
send_ping=AsyncMock(),
|
||||
)
|
||||
|
||||
reason = await self._run_one_keepalive_cycle(task)
|
||||
|
||||
assert reason == "shutdown"
|
||||
task.session.send_ping.assert_awaited_once()
|
||||
task.session.list_tools.assert_not_called()
|
||||
|
||||
|
||||
class TestKeepaliveInterval:
|
||||
"""The keepalive cadence is configurable so servers with short session
|
||||
TTLs (e.g. Unreal Engine editor MCP, ~15s) can refresh fast enough to keep
|
||||
the session alive instead of hitting an expired session on every idle call.
|
||||
"""
|
||||
|
||||
async def _captured_interval(self, config):
|
||||
"""Run one keepalive cycle and capture the ``asyncio.wait`` timeout."""
|
||||
task = MCPServerTask("test")
|
||||
task._config = config
|
||||
task.session = SimpleNamespace(send_ping=AsyncMock())
|
||||
captured = {}
|
||||
real_wait = asyncio.wait
|
||||
|
||||
async def fake_wait(tasks, timeout=None, return_when=None):
|
||||
captured["timeout"] = timeout
|
||||
task._shutdown_event.set()
|
||||
return await real_wait(
|
||||
tasks, timeout=0.5, return_when=return_when or asyncio.FIRST_COMPLETED
|
||||
)
|
||||
|
||||
import tools.mcp_tool as mcp_mod
|
||||
orig = mcp_mod.asyncio.wait
|
||||
mcp_mod.asyncio.wait = fake_wait
|
||||
try:
|
||||
await task._wait_for_lifecycle_event()
|
||||
finally:
|
||||
mcp_mod.asyncio.wait = orig
|
||||
return captured["timeout"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_interval_when_unset(self):
|
||||
from tools.mcp_tool import _DEFAULT_KEEPALIVE_INTERVAL
|
||||
assert await self._captured_interval({}) == _DEFAULT_KEEPALIVE_INTERVAL
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configured_interval_honored(self):
|
||||
assert await self._captured_interval({"keepalive_interval": 10}) == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interval_clamped_to_floor(self):
|
||||
from tools.mcp_tool import _MIN_KEEPALIVE_INTERVAL
|
||||
# A sub-floor value must clamp up, never busy-loop the keepalive.
|
||||
assert (
|
||||
await self._captured_interval({"keepalive_interval": 0.1})
|
||||
== _MIN_KEEPALIVE_INTERVAL
|
||||
)
|
||||
|
||||
|
||||
def _mcp_error(code, message="boom"):
|
||||
"""Build a real McpError carrying a JSON-RPC error code."""
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
return McpError(ErrorData(code=code, message=message))
|
||||
|
||||
|
||||
class TestMethodNotFoundDetection:
|
||||
"""``_is_method_not_found_error`` underpins the ping→list_tools fallback."""
|
||||
|
||||
def test_structural_code_match(self):
|
||||
from tools.mcp_tool import _is_method_not_found_error
|
||||
assert _is_method_not_found_error(_mcp_error(-32601)) is True
|
||||
|
||||
def test_other_mcp_error_code_is_not_match(self):
|
||||
from tools.mcp_tool import _is_method_not_found_error
|
||||
# Invalid params (-32602) is a real error, NOT "ping unsupported".
|
||||
assert _is_method_not_found_error(_mcp_error(-32602)) is False
|
||||
|
||||
def test_substring_fallback(self):
|
||||
from tools.mcp_tool import _is_method_not_found_error
|
||||
assert _is_method_not_found_error(Exception("Method not found")) is True
|
||||
|
||||
def test_unrelated_exception_is_not_match(self):
|
||||
from tools.mcp_tool import _is_method_not_found_error
|
||||
assert _is_method_not_found_error(TimeoutError()) is False
|
||||
assert _is_method_not_found_error(Exception("session terminated")) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestKeepaliveProbeFallback:
|
||||
"""The probe prefers ``ping`` but falls back to ``list_tools`` for servers
|
||||
that don't implement the optional ping utility — without reconnect-looping,
|
||||
and without regressing servers that DO support ping."""
|
||||
|
||||
async def test_uses_ping_when_supported(self):
|
||||
task = MCPServerTask("test")
|
||||
task.initialize_result = _caps(tools=SimpleNamespace())
|
||||
task.session = SimpleNamespace(
|
||||
send_ping=AsyncMock(),
|
||||
list_tools=AsyncMock(),
|
||||
)
|
||||
|
||||
await task._keepalive_probe()
|
||||
|
||||
task.session.send_ping.assert_awaited_once()
|
||||
task.session.list_tools.assert_not_called()
|
||||
assert task._ping_unsupported is False
|
||||
|
||||
async def test_falls_back_to_list_tools_on_method_not_found(self):
|
||||
task = MCPServerTask("test")
|
||||
task.initialize_result = _caps(tools=SimpleNamespace())
|
||||
task.session = SimpleNamespace(
|
||||
send_ping=AsyncMock(side_effect=_mcp_error(-32601)),
|
||||
list_tools=AsyncMock(return_value=SimpleNamespace(tools=[])),
|
||||
)
|
||||
|
||||
await task._keepalive_probe()
|
||||
|
||||
# First cycle: ping tried, failed -32601, list_tools used as fallback.
|
||||
task.session.send_ping.assert_awaited_once()
|
||||
task.session.list_tools.assert_awaited_once()
|
||||
task.session.send_ping.assert_not_called()
|
||||
assert task._ping_unsupported is True
|
||||
|
||||
async def test_latch_skips_ping_on_subsequent_cycles(self):
|
||||
task = MCPServerTask("test")
|
||||
task.initialize_result = _caps(tools=SimpleNamespace())
|
||||
task.session = SimpleNamespace(
|
||||
send_ping=AsyncMock(side_effect=_mcp_error(-32601)),
|
||||
list_tools=AsyncMock(return_value=SimpleNamespace(tools=[])),
|
||||
)
|
||||
|
||||
await task._keepalive_probe() # latches _ping_unsupported
|
||||
await task._keepalive_probe() # should NOT ping again
|
||||
|
||||
task.session.send_ping.assert_awaited_once() # only the first cycle
|
||||
assert task.session.list_tools.await_count == 2
|
||||
|
||||
async def test_real_liveness_failure_propagates_not_swallowed(self):
|
||||
"""A non-(-32601) ping error is a genuine connection failure: it must
|
||||
propagate so the caller reconnects, and must NOT latch the fallback."""
|
||||
task = MCPServerTask("test")
|
||||
task.initialize_result = _caps(tools=SimpleNamespace())
|
||||
task.session = SimpleNamespace(
|
||||
send_ping=AsyncMock(side_effect=Exception("session terminated")),
|
||||
list_tools=AsyncMock(),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="session terminated"):
|
||||
await task._keepalive_probe()
|
||||
|
||||
task.session.list_tools.assert_not_called()
|
||||
assert task._ping_unsupported is False
|
||||
|
||||
async def test_no_ping_no_tools_propagates_method_not_found(self):
|
||||
"""A server advertising neither working ping nor tools has no cheaper
|
||||
probe — the -32601 must propagate rather than calling list_tools on a
|
||||
server that doesn't support it."""
|
||||
task = MCPServerTask("test")
|
||||
task.initialize_result = _caps(prompts=SimpleNamespace()) # not tool-capable
|
||||
task.session = SimpleNamespace(
|
||||
send_ping=AsyncMock(side_effect=_mcp_error(-32601)),
|
||||
list_tools=AsyncMock(),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await task._keepalive_probe()
|
||||
|
||||
task.session.list_tools.assert_not_called()
|
||||
|
||||
async def test_discover_resets_latch(self):
|
||||
"""A fresh connection (_discover_tools) re-enables the cheap ping path."""
|
||||
task = MCPServerTask("test")
|
||||
task.initialize_result = _caps(tools=SimpleNamespace())
|
||||
task._ping_unsupported = True
|
||||
task.session = SimpleNamespace(
|
||||
list_tools=AsyncMock(return_value=SimpleNamespace(tools=[])),
|
||||
)
|
||||
|
||||
await task._discover_tools()
|
||||
|
||||
assert task._ping_unsupported is False
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ Example config::
|
||||
env: {}
|
||||
timeout: 120 # per-tool-call timeout in seconds (default: 300)
|
||||
connect_timeout: 60 # initial connection timeout (default: 60)
|
||||
keepalive_interval: 10 # liveness ping cadence in seconds (default:
|
||||
# 180). Set below the server's session TTL for
|
||||
# servers that GC idle sessions quickly (e.g.
|
||||
# Unreal Engine editor MCP, ~15s). Floored at 5s.
|
||||
github:
|
||||
command: "npx"
|
||||
args: ["-y", "@modelcontextprotocol/server-github"]
|
||||
@@ -276,6 +280,17 @@ _MAX_RECONNECT_RETRIES = 5
|
||||
_MAX_INITIAL_CONNECT_RETRIES = 3 # retries for the very first connection attempt
|
||||
_MAX_BACKOFF_SECONDS = 60
|
||||
|
||||
# Keepalive cadence for HTTP/SSE sessions. The MCP spec lets a server expire
|
||||
# idle sessions on any TTL it chooses (Streamable HTTP "Session Management"),
|
||||
# so a client that wants a session to survive idle periods MUST refresh faster
|
||||
# than that TTL. The default suits long LB/NAT idle windows (commonly
|
||||
# 300-600s); servers with short session TTLs (e.g. Unreal Engine's editor MCP,
|
||||
# ~15s) need a smaller ``keepalive_interval`` in their config or every idle
|
||||
# tool call lands on a dead session and pays the full reconnect path. The floor
|
||||
# stops a misconfigured tiny interval from busy-looping the keepalive.
|
||||
_DEFAULT_KEEPALIVE_INTERVAL = 180 # seconds between liveness pings
|
||||
_MIN_KEEPALIVE_INTERVAL = 5 # clamp floor for configured intervals
|
||||
|
||||
# Environment variables that are safe to pass to stdio subprocesses
|
||||
_SAFE_ENV_KEYS = frozenset({
|
||||
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
|
||||
@@ -382,6 +397,40 @@ def _exc_str(exc: BaseException) -> str:
|
||||
return text if text else repr(exc)
|
||||
|
||||
|
||||
# JSON-RPC "method not found" — the error a server returns when it does not
|
||||
# implement a requested method (e.g. a tool-capable server that never wired up
|
||||
# the optional ``ping`` utility). Defined locally with a fallback so detection
|
||||
# works even on SDK builds that don't export the constant.
|
||||
try:
|
||||
from mcp.types import METHOD_NOT_FOUND as _JSONRPC_METHOD_NOT_FOUND
|
||||
except Exception: # pragma: no cover — older/newer SDK without the constant
|
||||
_JSONRPC_METHOD_NOT_FOUND = -32601
|
||||
|
||||
|
||||
def _is_method_not_found_error(exc: BaseException) -> bool:
|
||||
"""Return True if *exc* is a JSON-RPC ``method not found`` (-32601).
|
||||
|
||||
``ping`` is an *optional* MCP utility (spec: "optional ping mechanism").
|
||||
A server that doesn't implement it answers a ping with -32601 rather than
|
||||
an empty result. Structurally inspect ``McpError.error.code`` first, then
|
||||
fall back to a substring match so detection survives SDK version drift and
|
||||
servers that surface the condition as a plain message.
|
||||
"""
|
||||
# Structural: mcp.shared.exceptions.McpError carries ErrorData.code.
|
||||
err = getattr(exc, "error", None)
|
||||
code = getattr(err, "code", None)
|
||||
if code == _JSONRPC_METHOD_NOT_FOUND:
|
||||
return True
|
||||
msg = str(exc).lower()
|
||||
if not msg:
|
||||
return False
|
||||
return (
|
||||
str(_JSONRPC_METHOD_NOT_FOUND) in msg
|
||||
or "method not found" in msg
|
||||
or "not found: ping" in msg
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP tool description content scanning
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1362,7 +1411,7 @@ class MCPServerTask:
|
||||
"_registered_tool_names", "_auth_type", "_refresh_lock",
|
||||
"_rpc_lock", "_pending_refresh_tasks",
|
||||
"_pending_call_context",
|
||||
"initialize_result",
|
||||
"initialize_result", "_ping_unsupported",
|
||||
)
|
||||
|
||||
def __init__(self, name: str):
|
||||
@@ -1410,6 +1459,12 @@ class MCPServerTask:
|
||||
# ``.capabilities.prompts``) instead of assuming every ``ClientSession``
|
||||
# method attribute corresponds to a supported server method. See #18051.
|
||||
self.initialize_result: Optional[Any] = None
|
||||
# Set True the first time a keepalive ``ping`` returns JSON-RPC
|
||||
# -32601 (method not found): the server is tool-capable but doesn't
|
||||
# implement the optional ``ping`` utility. Subsequent keepalives fall
|
||||
# back to ``list_tools`` (the pre-ping probe) so we neither spam pings
|
||||
# nor reconnect-loop. Reset on each fresh transport connection.
|
||||
self._ping_unsupported: bool = False
|
||||
|
||||
def _is_http(self) -> bool:
|
||||
"""Check if this server uses HTTP transport."""
|
||||
@@ -1564,6 +1619,46 @@ class MCPServerTask:
|
||||
self.name, len(self._registered_tool_names),
|
||||
)
|
||||
|
||||
async def _keepalive_probe(self) -> None:
|
||||
"""Exercise the session to detect a stale/expired connection.
|
||||
|
||||
Uses ``ping`` (cheap, transport-agnostic liveness) by default. ``ping``
|
||||
is an OPTIONAL MCP utility: a server that doesn't implement it answers
|
||||
JSON-RPC -32601. The first time that happens we latch
|
||||
``_ping_unsupported`` and fall back to the pre-ping probe — capability
|
||||
permitting, ``list_tools``; otherwise ``ping`` is the only option and
|
||||
the -32601 propagates (a server advertising neither a working ping nor
|
||||
tools has no liveness primitive left). The latch resets on each fresh
|
||||
transport connection so a server that gains ping support after a
|
||||
reconnect is re-probed with the cheap path.
|
||||
|
||||
Raises on a genuine connection failure so the caller triggers a
|
||||
reconnect; returns normally when the session is alive.
|
||||
"""
|
||||
if not self._ping_unsupported:
|
||||
try:
|
||||
await asyncio.wait_for(self.session.send_ping(), timeout=30.0)
|
||||
return
|
||||
except Exception as exc:
|
||||
# Only a "method not found" means ping is unsupported. Any
|
||||
# other error (timeout, closed transport, session expired) is
|
||||
# a real liveness failure — propagate so we reconnect.
|
||||
if not _is_method_not_found_error(exc):
|
||||
raise
|
||||
if not self._advertises_tools():
|
||||
# No ping, no tools → no cheaper probe to fall back to.
|
||||
raise
|
||||
self._ping_unsupported = True
|
||||
logger.info(
|
||||
"MCP server '%s': does not implement the optional 'ping' "
|
||||
"utility (-32601); using 'list_tools' for keepalive on "
|
||||
"this connection.",
|
||||
self.name,
|
||||
)
|
||||
|
||||
# Fallback probe for servers without ping support.
|
||||
await asyncio.wait_for(self.session.list_tools(), timeout=30.0)
|
||||
|
||||
async def _wait_for_lifecycle_event(self) -> str:
|
||||
"""Block until either _shutdown_event or _reconnect_event fires.
|
||||
|
||||
@@ -1577,13 +1672,29 @@ class MCPServerTask:
|
||||
|
||||
Shutdown takes precedence if both events are set simultaneously.
|
||||
|
||||
Periodically sends a lightweight keepalive (``list_tools``) to
|
||||
prevent TCP connections from going stale during long idle
|
||||
periods (#17003). If the keepalive fails, triggers a reconnect.
|
||||
Periodically sends a lightweight keepalive (``ping``, with a
|
||||
``list_tools`` fallback for servers that don't implement the optional
|
||||
ping utility — see :meth:`_keepalive_probe`) to prevent TCP/session
|
||||
state from going stale during idle periods (#17003). If the keepalive
|
||||
fails, triggers a reconnect.
|
||||
|
||||
The cadence is ``keepalive_interval`` from server config (default
|
||||
:data:`_DEFAULT_KEEPALIVE_INTERVAL`, floored at
|
||||
:data:`_MIN_KEEPALIVE_INTERVAL`). Servers that GC idle sessions on a
|
||||
short TTL (e.g. Unreal Engine's editor MCP, ~15s) need an interval
|
||||
below that TTL, otherwise every idle tool call lands on an
|
||||
already-expired session and pays the full reconnect path.
|
||||
"""
|
||||
# Keepalive interval in seconds. Must be shorter than typical
|
||||
# LB / NAT idle-timeout (commonly 300-600s).
|
||||
_KEEPALIVE_INTERVAL = 180 # 3 minutes
|
||||
# Refresh faster than the server's session TTL. ``ping`` (MCP base
|
||||
# protocol liveness) is used rather than ``list_tools`` so the probe
|
||||
# stays a few bytes regardless of how many tools the server exposes —
|
||||
# a ``list_tools`` keepalive against an 830-tool server would pull
|
||||
# ~1 MB every cycle. Tool-list changes still arrive out-of-band via
|
||||
# ``notifications/tools/list_changed`` → ``_refresh_tools``.
|
||||
keepalive_interval = max(
|
||||
_MIN_KEEPALIVE_INTERVAL,
|
||||
float(self._config.get("keepalive_interval", _DEFAULT_KEEPALIVE_INTERVAL)),
|
||||
)
|
||||
|
||||
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
||||
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
|
||||
@@ -1591,30 +1702,23 @@ class MCPServerTask:
|
||||
while True:
|
||||
done, _pending = await asyncio.wait(
|
||||
{shutdown_task, reconnect_task},
|
||||
timeout=_KEEPALIVE_INTERVAL,
|
||||
timeout=keepalive_interval,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if done:
|
||||
break
|
||||
|
||||
# Timeout — no lifecycle event fired. Send a keepalive
|
||||
# to exercise the connection and detect stale sockets.
|
||||
# Prompt-only / resource-only servers don't implement
|
||||
# ``tools/list`` (McpError -32601), so use the universal
|
||||
# ``ping`` request for them instead — otherwise every
|
||||
# keepalive cycle would trigger a spurious reconnect.
|
||||
# Timeout — no lifecycle event fired. Probe the connection
|
||||
# to detect stale/expired sessions. Prefer ``ping`` (MCP base
|
||||
# protocol liveness): it works uniformly and stays a few bytes
|
||||
# regardless of tool count, unlike ``list_tools`` (~1 MB on an
|
||||
# 830-tool server). ``ping`` is an OPTIONAL utility, so a
|
||||
# tool-capable server that doesn't implement it answers -32601;
|
||||
# in that case fall back to the pre-ping ``list_tools`` probe
|
||||
# for the rest of this connection rather than reconnect-looping.
|
||||
if self.session:
|
||||
try:
|
||||
if self._advertises_tools():
|
||||
await asyncio.wait_for(
|
||||
self.session.list_tools(),
|
||||
timeout=30.0,
|
||||
)
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
self.session.send_ping(),
|
||||
timeout=30.0,
|
||||
)
|
||||
await self._keepalive_probe()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"MCP server '%s' keepalive failed, "
|
||||
@@ -2040,6 +2144,10 @@ class MCPServerTask:
|
||||
server doesn't advertise the ``tools`` capability.
|
||||
(Ported from anomalyco/opencode#31271.)
|
||||
"""
|
||||
# Fresh transport connection → re-probe with the cheap ``ping`` path.
|
||||
# Clears any latch from a prior connection in case the server gained
|
||||
# ping support across the reconnect.
|
||||
self._ping_unsupported = False
|
||||
if self.session is None:
|
||||
return
|
||||
if not self._advertises_tools():
|
||||
|
||||
@@ -3512,8 +3512,7 @@ def _schedule_mcp_late_refresh(sid: str, agent) -> None:
|
||||
|
||||
The agent snapshots ``agent.tools`` once at build time and never re-reads
|
||||
the registry (run_agent/agent_init). ``_make_agent`` briefly joins the
|
||||
background MCP discovery thread (``wait_for_mcp_discovery``, bounded by the
|
||||
``mcp_discovery_timeout`` config value, default 1.5s) so
|
||||
background MCP discovery thread (``wait_for_mcp_discovery``, ~0.75s) so
|
||||
already-spawning servers land in that snapshot — but a server that takes
|
||||
longer than the bound to connect (common for an HTTP MCP server on first
|
||||
connect) lands *after* the agent is built. Its tools are then absent from
|
||||
|
||||
Reference in New Issue
Block a user