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

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.
This commit is contained in:
Teknium
2026-06-11 17:34:49 -07:00
committed by GitHub
parent 880107ab24
commit 5affecb443
2 changed files with 215 additions and 5 deletions
+57 -5
View File
@@ -1202,6 +1202,26 @@ class MCPServerTask:
"""Check if this server uses HTTP transport."""
return "url" in self._config
def _advertises_tools(self) -> bool:
"""Whether the server advertises the ``tools`` capability.
Per the MCP spec, ``InitializeResult.capabilities.tools`` is non-None
iff the server implements the ``tools/*`` request family. Prompt-only
or resource-only servers omit it, and calling ``tools/list`` against
them raises ``McpError(-32601 Method not found)`` — which previously
killed the connection during discovery and made every keepalive fail.
(Ported from anomalyco/opencode#31271.)
Returns True when no capability info was captured (legacy fallback:
preserve the old always-call-list_tools behavior rather than regress
any server that was working before this gate).
"""
init_result = self.initialize_result
caps = getattr(init_result, "capabilities", None) if init_result is not None else None
if caps is None:
return True
return getattr(caps, "tools", None) is not None
# ----- Dynamic tool discovery (notifications/tools/list_changed) -----
async def _refresh_tools_task(self):
@@ -1273,6 +1293,12 @@ class MCPServerTask:
"""
from tools.registry import registry
if not self._advertises_tools():
# A server that doesn't implement tools/* should never send
# tools/list_changed, but guard anyway — calling tools/list
# would raise McpError(-32601).
return
async with self._refresh_lock:
# Capture old tool names for change diff
old_tool_names = set(self._registered_tool_names)
@@ -1360,12 +1386,22 @@ class MCPServerTask:
# 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.
if self.session:
try:
await asyncio.wait_for(
self.session.list_tools(),
timeout=30.0,
)
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,
)
except Exception as exc:
logger.warning(
"MCP server '%s' keepalive failed, "
@@ -1778,9 +1814,25 @@ class MCPServerTask:
)
async def _discover_tools(self):
"""Discover tools from the connected session."""
"""Discover tools from the connected session.
Capability-gated: prompt-only / resource-only MCP servers don't
implement ``tools/list``, and calling it raises ``McpError(-32601)``,
which previously aborted the connection — those servers could never
stay connected for their prompts/resources. Skip the call when the
server doesn't advertise the ``tools`` capability.
(Ported from anomalyco/opencode#31271.)
"""
if self.session is None:
return
if not self._advertises_tools():
logger.info(
"MCP server '%s': does not advertise 'tools' capability — "
"skipping tools/list (prompts/resources remain available)",
self.name,
)
self._tools = []
return
async with self._rpc_lock:
tools_result = await self.session.list_tools()
self._tools = (