Skip to content

fix(mcp): capability-gate tools/list so prompt-only MCP servers can connect (port opencode#31271)#44550

Merged
teknium1 merged 1 commit into
mainfrom
opencode-port/mcp-capability-gated-discovery
Jun 12, 2026
Merged

fix(mcp): capability-gate tools/list so prompt-only MCP servers can connect (port opencode#31271)#44550
teknium1 merged 1 commit into
mainfrom
opencode-port/mcp-capability-gated-discovery

Conversation

@teknium1

@teknium1 teknium1 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Prompt-only and resource-only MCP servers can now connect and stay connected — previously they failed permanently because Hermes unconditionally called tools/list during discovery and keepalive, which raises McpError(-32601 Method not found) on servers that don't implement the tools/* family.

Port of anomalyco/opencode#31271 ("respect MCP server capabilities"), adapted to Hermes's MCPServerTask lifecycle.

Root cause

Per the MCP spec, InitializeResult.capabilities.tools is non-None iff the server implements tools/*. Hermes already used this gate for the prompts/resources utility tool registration (_select_utility_schemas, #18051) but never for the tools/list calls themselves:

  • _discover_tools() called session.list_tools() unconditionally right after initialize() → the -32601 error aborted the connection, burning all 3 initial-connect retries → server permanently dead.
  • The 180s keepalive probed with list_tools() → even a connected prompt-only server got torn down on the first keepalive cycle.

Changes

  • tools/mcp_tool.py: new MCPServerTask._advertises_tools() (capability check, legacy fallback to old behavior when no InitializeResult was captured); _discover_tools() skips tools/list for non-tool servers; keepalive uses the universal ping request instead for them; _refresh_tools() guards against spurious tools/list_changed.
  • tests/tools/test_mcp_capability_gating.py: 10 tests covering the capability check, discovery gating, refresh gating, and both keepalive probe paths.

Validation

Before (main) After
Real stdio prompt-only server (E2E) fails all 3 connect attempts with Method-not-found, _error set connects, list_prompts returns prompts, ping keepalive OK, clean shutdown
Tool-capable servers unchanged unchanged (list_tools still used for discovery + keepalive)
tests/tools/test_mcp_tool.py + test_mcp_sse_transport.py + test_mcp_reconnect_signal.py 219 passed

E2E used a real mcp.server.Server exposing only list_prompts over stdio, driven through MCPServerTask.run() with an isolated HERMES_HOME.

Architectural notes vs the source PR

OpenCode gates via the TS SDK's client.getServerCapabilities(); Hermes captures InitializeResult on MCPServerTask.initialize_result (already present since #18051) and reads capabilities.tools from it. OpenCode returns [] tools for non-tool servers; we do the same and additionally swap the keepalive to ping (OpenCode has no equivalent keepalive loop). Their warn-not-error log change maps to the existing -32601 handling discussion in #10292 and is out of scope here.

Infographic

mcp-capability-gate

…onnect

Port from anomalyco/opencode#31271: only call tools/list when the server
advertises the 'tools' capability in InitializeResult.capabilities.

Previously, _discover_tools() unconditionally called session.list_tools()
right after initialize. Prompt-only / resource-only servers (which omit
the tools capability per the MCP spec) raise McpError(-32601 Method not
found), which aborted the connection — burning all 3 initial-connect
retries and permanently failing the server even though its prompts and
resources were perfectly usable. The 180s keepalive had the same problem:
it probed with list_tools(), so even a successfully connected prompt-only
server would be torn down on the first keepalive cycle.

Changes:
- MCPServerTask._advertises_tools(): capability check with a legacy
  fallback (no captured InitializeResult -> behave as before)
- _discover_tools(): skip tools/list for non-tool servers
- keepalive: use the universal ping request for non-tool servers
- _refresh_tools(): guard against tools/list_changed from non-tool servers

E2E verified with a real stdio prompt-only FastMCP-style server: on main
it fails all 3 connection attempts with Method-not-found; with this fix
it connects, lists prompts, answers ping keepalives, and shuts down
cleanly.
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: opencode-port/mcp-capability-gated-discovery vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10766 on HEAD, 10764 on base (🆕 +2)

🆕 New issues (2):

Rule Count
unresolved-import 1
invalid-assignment 1
First entries
tests/tools/test_mcp_capability_gating.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_mcp_capability_gating.py:126: [invalid-assignment] invalid-assignment: Object of type `def fake_wait(tasks, timeout=None, return_when=None) -> CoroutineType[Any, Any, Unknown]` is not assignable to attribute `wait` of type `def wait[_FT](fs: Iterable[_FT], *, timeout: int | float | None = None, return_when: str = "ALL_COMPLETED") -> CoroutineType[Any, Any, tuple[set[_FT], set[_FT]]]`

✅ Fixed issues: none

Unchanged: 5637 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@liuhao1024

Copy link
Copy Markdown
Contributor

Verified: MCP capability-gating is clean and correct.

Checked:

  • _advertises_tools() correctly inspects InitializeResult.capabilities.tools with proper None/missing fallback (legacy servers preserve old behavior)
  • All three gate points are consistently gated: _discover_tools(), _refresh_tools(), and _wait_for_lifecycle_event() keepalive
  • Keepalive fallback to send_ping() for non-tool servers prevents spurious reconnect loops
  • _tools = [] on gate hit is safe — only affects discovery phase, not runtime tool execution
  • Legacy fallback (True when no capabilities captured) avoids regressions for servers that were working before
  • Test coverage is thorough: 5 tests for _advertises_tools edge cases, 3 for discovery gating, 1 for refresh, 2 for keepalive probe routing

No issues found. LGTM.

@teknium1 teknium1 merged commit 5affecb into main Jun 12, 2026
28 checks passed
@teknium1 teknium1 deleted the opencode-port/mcp-capability-gated-discovery branch June 12, 2026 00:34
teddyjfpender added a commit to teddyjfpender/superforecasting-agent that referenced this pull request Jun 13, 2026
…onnect (#5affecb44)

Only call tools/list when the server advertises the 'tools' capability
in InitializeResult.capabilities. Previously _discover_tools()
unconditionally called session.list_tools() right after initialize;
prompt-only / resource-only servers (which omit the tools capability per
the MCP spec) raise McpError(-32601 Method not found), aborting the
connection and burning all initial-connect retries. The 180s keepalive
had the same problem — it probed with list_tools(), so even a
successfully connected prompt-only server was torn down on the first
keepalive cycle.

- MCPServerTask._advertises_tools(): capability check with a legacy
  fallback (no captured InitializeResult -> behave as before)
- _discover_tools(): skip tools/list for non-tool servers
- keepalive: use the universal ping request for non-tool servers
- _refresh_tools(): guard against tools/list_changed from non-tool servers

Upstream test file brought verbatim (tests/tools/test_mcp_capability_gating.py).

Ported from upstream NousResearch/hermes-agent (NousResearch#44550, originally
anomalyco/opencode#31271).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants