fix(mcp): report runtime_scope on /api/notes/sources and surface it in the Notes drawer

Review follow-up on #7720 (Greptile P2): the external Notes drawer reads the
same profile-bound MCP inventory as the MCP panel but dropped the
``runtime_scope`` label, so a profile whose runtime scope could not be
confirmed rendered its note sources as inactive/configured with no
explanation. ``/api/notes/sources`` now returns ``runtime_scope`` and the
drawer shows the existing ``mcp_runtime_scope_unavailable`` notice (same key,
no new locale strings) when it is ``unavailable``.

Tests: the skewed-routing unit test now also asserts the notes response
carries ``runtime_scope: "unavailable"`` with its sources inactive.
docs/troubleshooting.md mentions the drawer next to the panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
tancouandClaude Fable 5.1 committed 2026-09-24 09:53:02 +02:00
1 parent 90b814f673
commit dbf0179c53
4 files changed
+25 -5

No files matched your search

+2 -1
View File
@@ -29835,7 +29835,7 @@ def _handle_notes_sources_list(handler):
servers = cfg.get("mcp_servers", {}) servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict): if not isinstance(servers, dict):
servers = {} servers = {}
server_summaries, tools, source, _runtime_scope = _mcp_profile_runtime_inventory( server_summaries, tools, source, runtime_scope = _mcp_profile_runtime_inventory(
servers, "/api/notes/sources" servers, "/api/notes/sources"
) )
return j(handler, { return j(handler, {
@@ -29843,6 +29843,7 @@ def _handle_notes_sources_list(handler):
"sources": _notes_sources_from_mcp_inventory(server_summaries, tools), "sources": _notes_sources_from_mcp_inventory(server_summaries, tools),
"source": source, "source": source,
"inventory_scope": "already_known_runtime_only", "inventory_scope": "already_known_runtime_only",
"runtime_scope": runtime_scope,
"attach_supported": False, "attach_supported": False,
"automatic_recall_unchanged": True, "automatic_recall_unchanged": True,
"recent_ai_notes": _joplin_recent_ai_notes(limit=6), "recent_ai_notes": _joplin_recent_ai_notes(limit=6),
+1 -1
View File
@@ -311,7 +311,7 @@ Interpret the two together:
## MCP panel shows another profile's servers, or "Live status for this profile is unavailable" ## MCP panel shows another profile's servers, or "Live status for this profile is unavailable"
**Symptom.** With several profiles, the MCP settings panel of profile A shows a server as *Active* with a tool count while the tool inventory is empty (or lists profile B's tools); `/reload-mcp` on one profile stops the other profile's servers; or the panel shows the notice *"Live status for this profile is unavailable right now"* and `/reload-mcp` answers *"MCP runtime scope could not be confirmed"*. **Symptom.** With several profiles, the MCP settings panel of profile A shows a server as *Active* with a tool count while the tool inventory is empty (or lists profile B's tools); `/reload-mcp` on one profile stops the other profile's servers; or the MCP panel and the external Notes drawer show the notice *"Live status for this profile is unavailable right now"* and `/reload-mcp` answers *"MCP runtime scope could not be confirmed"*.
**Why.** Hermes Agent keeps one in-process MCP ledger per WebUI process and keys a connection by profile only when it can tell the request serves a profile other than the process's own. The WebUI binds every MCP status read and `/reload-mcp` to the request profile (`ARCHITECTURE.md` §4.10). While a chat turn is streaming, the WebUI mirrors that turn's profile into `HERMES_HOME`; Agents that predate `hermes_constants.pin_process_hermes_home` cannot distinguish that mirror from the process profile, so the WebUI withholds runtime data and refuses the reload instead of showing or resetting another profile's connection. **Why.** Hermes Agent keeps one in-process MCP ledger per WebUI process and keys a connection by profile only when it can tell the request serves a profile other than the process's own. The WebUI binds every MCP status read and `/reload-mcp` to the request profile (`ARCHITECTURE.md` §4.10). While a chat turn is streaming, the WebUI mirrors that turn's profile into `HERMES_HOME`; Agents that predate `hermes_constants.pin_process_hermes_home` cannot distinguish that mirror from the process profile, so the WebUI withholds runtime data and refuses the reload instead of showing or resetting another profile's connection.
+7 -2
View File
@@ -5415,8 +5415,13 @@ function _renderExternalNotesSources() {
const recall = data.automatic_recall_unchanged !== false const recall = data.automatic_recall_unchanged !== false
? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>` ? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>`
: ''; : '';
// Same withheld-runtime state as the MCP panel: sources still list, but their
// live status/tools are hidden until the profile's runtime scope is confirmed.
const scopeNotice = data.runtime_scope === 'unavailable'
? `<div class="memory-detail-mtime">${esc(t('mcp_runtime_scope_unavailable'))}</div>`
: '';
if (!sources.length) { if (!sources.length) {
body.innerHTML = `<div class="main-view-content">${recall}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`; body.innerHTML = `<div class="main-view-content">${recall}${scopeNotice}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
} else { } else {
const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0]; const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0];
_notesSelectedSource = (selected && selected.name) || 'joplin'; _notesSelectedSource = (selected && selected.name) || 'joplin';
@@ -5463,7 +5468,7 @@ function _renderExternalNotesSources() {
${searchError} ${searchError}
${resultHtml} ${resultHtml}
</section>`; </section>`;
body.innerHTML = `<div class="main-view-content">${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`; body.innerHTML = `<div class="main-view-content">${recall}${scopeNotice}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
} }
body.style.display = ''; body.style.display = '';
if (empty) empty.style.display = 'none'; if (empty) empty.style.display = 'none';
+15 -1
View File
@@ -423,9 +423,17 @@ def test_adopted_connection_serves_status_inventory_and_reload(monkeypatch, tmp_
def test_skewed_routing_hides_runtime_and_refuses_reload(monkeypatch, tmp_path): def test_skewed_routing_hides_runtime_and_refuses_reload(monkeypatch, tmp_path):
from api.routes import _handle_mcp_servers_list, _handle_mcp_tools_list from api.routes import (
_handle_mcp_servers_list,
_handle_mcp_tools_list,
_handle_notes_sources_list,
)
agent = _install(monkeypatch, tmp_path, pin_supported=False) agent = _install(monkeypatch, tmp_path, pin_supported=False)
_configure(agent, "profile-write", {
"atlassian": {"command": "fake", "fake_tools": WRITE},
"joplin": {"command": "fake", "fake_tools": []}, # a notes-drawer source
})
agent.connect("atlassian", READ, scope=None) agent.connect("atlassian", READ, scope=None)
monkeypatch.setenv("HERMES_HOME", str(agent.base / "profiles" / "profile-write")) # turn mirror monkeypatch.setenv("HERMES_HOME", str(agent.base / "profiles" / "profile-write")) # turn mirror
@@ -434,6 +442,12 @@ def test_skewed_routing_hides_runtime_and_refuses_reload(monkeypatch, tmp_path):
assert servers["servers"][0]["status"] == "configured" assert servers["servers"][0]["status"] == "configured"
assert servers["servers"][0]["tool_count"] is None assert servers["servers"][0]["tool_count"] is None
assert _call("profile-write", _handle_mcp_tools_list)["total"] == 0 assert _call("profile-write", _handle_mcp_tools_list)["total"] == 0
# The notes drawer reads the same inventory and must be told the runtime is withheld.
monkeypatch.setenv("HERMES_WEBUI_EXTERNAL_NOTES_SOURCES", "1")
notes = _call("profile-write", _handle_notes_sources_list)
assert notes["runtime_scope"] == "unavailable"
assert [s["name"] for s in notes["sources"]] == ["joplin"]
assert notes["sources"][0]["active"] is False
with pytest.raises(RuntimeError, match="could not be confirmed"): with pytest.raises(RuntimeError, match="could not be confirmed"):
_reload("profile-write") _reload("profile-write")
assert not [c for c in agent.calls if c[0] in ("discover", "shutdown")] assert not [c for c in agent.calls if c[0] in ("discover", "shutdown")]