Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Information-preserving degradation for modality filter

The `ModalityFilterCapability` `describe` strategy previously replaced
unsupported multimodal content with a bare MIME placeholder such as
`[image/png]`. That token carried no filename, path, or identifier, so the
model could never retrieve the underlying content — a vision-capable
subagent or file tool had nothing to open, and the placeholder misled the
model into inventing content it never saw.

The placeholder is now **information-preserving** (RFC-0061): binary content
states its media type, that direct model processing is unsupported, and
whether a file identifier is available; control characters in caller-supplied
identifiers are escaped to prevent prompt injection via malformed filenames.
URL-type content keeps its `[image: url]` / `[audio: url]` form since the URL
is already retrievable.

A new opt-in `reference` strategy is added for each modality: instead of a
placeholder, the content bytes are persisted to a per-session scratch
directory under `tempfile.gettempdir()/wolfharness-modality/{session_id}/` and
replaced with a `[file: <path>]` reference a vision-capable subagent or the
agent's `read` tool can open. The directory is removed by
`after_node_run()`. URL and `UploadedFile` content has no local bytes and
falls back to `describe`.

Resolves wolf1069b/wolfharness#377.
34 changes: 34 additions & 0 deletions changelog/unreleased/2026-08-19-resource-listing-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Faster resource listing — parallel providers, caching, timeouts

`list_resources`/`list_resource_templates` (the agent-facing
`ResourceCapability` tools) were slow because each provider (MCP server,
Viking capability) was queried **sequentially** with a fresh network round
trip per call, and a dead server re-paid its connection retry backoff on
every invocation.

- **Parallel providers**: `ResourceCapability` now queries all visible
providers concurrently via `asyncio.gather`, so total latency is bounded
by the slowest provider instead of the sum of all. Each provider runs
under a 10s timeout — a hung or unreachable server is skipped with a
warning rather than blocking the whole listing. Pagination now happens
before row formatting.
- **Result caching**: `McpServerCap.list_resources()` and
`list_resource_templates()` cache their results and invalidate them on
`resources/list_changed` notifications (the notification hook was already
wired but previously unused for caching). `resource_exists()` reuses the
cached listing.
- **Connect cooldown**: a failed MCP connection enters a 30s cooldown, so
repeated calls do not re-pay the 3-attempt exponential-backoff retry for
a server that is down.
- **Caching invalidation on reconnect**: `McpServerCap` caches are cleared
on connection drop → reconnect and on exit, so listings never go stale
after a server restarts (previously only `resources/list_changed`
notifications invalidated them — many servers never send those).
- **Viking**: per-directory recursive `ls()` calls inside
`VikingCapability.list_resources()` now run in parallel instead of
serially.
- **New `web_fetch` tool**: fetches a web page over HTTP(S) and returns its
text content (50k-char cap). Refuses non-http(s) schemes and hosts that
resolve to private/loopback/link-local addresses (SSRF guard); TLS
verification always on; binary responses point at `download_file`. Wired
into the default filesystem toolset.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# viking read_resource serves image resources as blobs for vision models

Image resources (by extension, excluding SVG) read through the resources
capability's `read_resource` tool now come back as `BlobResourceContent`
with real bytes and MIME type, instead of a base64 text dump. Multimodal
models consume them as image parts directly; text-only path unchanged.
Mirrors the existing `viking_read` tool behavior.

Large diffs are not rendered by default.

62 changes: 51 additions & 11 deletions src/wolfharness/capabilities/mcp_server_cap.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import asyncio
import logging
import time
from typing import TYPE_CHECKING, Any

from pydantic_ai.capabilities import AbstractCapability
Expand Down Expand Up @@ -59,6 +60,10 @@
_DEFAULT_MAX_RETRIES = 3
_RETRY_BASE_DELAY = 1 # seconds

# Cooldown after a failed connection attempt: a dead server is not retried
# for this long, so every listing call does not re-pay the retry backoff.
_CONNECT_COOLDOWN = 30.0 # seconds


class McpServerCap(
AbstractCapability[AgentDepsT],
Expand Down Expand Up @@ -131,6 +136,9 @@ def __init__(
self._tool_prefix = tool_prefix
self._client: MCPClient | None = client
self._change_queues: set[asyncio.Queue[ChangeEvent]] = set()
self._resources_cache: list[ResourceEntry] | None = None
self._resource_templates_cache: list[ResourceTemplateEntry] | None = None
self._connect_cooldown_until: float = 0.0

# ---- Properties ----

Expand Down Expand Up @@ -162,7 +170,8 @@ async def _ensure_client(self) -> MCPClient:
Connection strategy (in priority order):
1. If a cached client exists, return it.
2. If a session pool is available, connect via the pool with
exponential backoff retry (3 attempts, base delay 1s).
exponential backoff retry (3 attempts, base delay 1s). A
30-second cooldown is enforced after exhausted retries.
3. Otherwise, create a direct ``MCPClient`` from config — this
mirrors how ``MCPManager.setup_server()`` creates clients for
pool-level providers (manager.py:428-439). This fallback
Expand All @@ -173,7 +182,8 @@ async def _ensure_client(self) -> MCPClient:
The cached ``MCPClient`` instance.

Raises:
RuntimeError: If connection fails after all retries.
RuntimeError: If connection is in cooldown or fails after all
retries.
"""
if self._client is not None:
return self._client
Expand All @@ -193,8 +203,20 @@ async def _ensure_client(self) -> MCPClient:
return client

async def _connect_via_pool(self) -> MCPClient:
"""Connect using the session pool with retry logic."""
"""Connect using the session pool with retry logic.

Enforces a cooldown period after exhausted retries to avoid
hammering a failing server on every tool access.
"""
assert self._session_pool is not None

now = time.monotonic()
if now < self._connect_cooldown_until:
raise RuntimeError(
f"MCP server {self._name!r} connection is in cooldown "
f"({self._connect_cooldown_until - now:.0f}s remaining)"
)

last_error: Exception | None = None
for attempt in range(1, _DEFAULT_MAX_RETRIES + 1):
try:
Expand Down Expand Up @@ -230,6 +252,8 @@ async def _on_tools_changed() -> None:
await q.put(event)

async def _on_resource_list_changed() -> None:
self._resources_cache = None
self._resource_templates_cache = None
event = ChangeEvent(
capability_name=self._name,
kind="resource_list_changed",
Expand Down Expand Up @@ -262,9 +286,16 @@ async def _on_prompts_changed() -> None:
resource_updated_callback=_on_resource_updated,
prompt_change_callback=_on_prompts_changed,
)
# A reconnected server is a different process state — any cached
# listings belong to the old connection and are stale. Many
# servers never send resources/list_changed, so failures must be
# the invalidation trigger, not the notification.
self._resources_cache = None
self._resource_templates_cache = None
self._client = client
return client

self._connect_cooldown_until = time.monotonic() + _CONNECT_COOLDOWN
raise RuntimeError(
f"Failed to connect MCP server {self._name!r} after {_DEFAULT_MAX_RETRIES} attempts"
) from last_error
Expand Down Expand Up @@ -405,12 +436,16 @@ async def call_tool(self, name: str, args: dict[str, Any]) -> ToolResult:
async def list_resources(self) -> Sequence[ResourceEntry]:
"""List available MCP resources.

Returns:
Sequence of ``ResourceEntry`` descriptors.
Results are cached and invalidated by ``resources/list_changed``
notifications. ponytail: no TTL — relies on the server sending the
change notification per protocol. Add a TTL if a server never
notifies but its resource list changes.
"""
if self._resources_cache is not None:
return self._resources_cache
client = await self._ensure_client()
resources = await client.list_resources()
return [
self._resources_cache = [
ResourceEntry(
uri=str(r.uri),
name=r.title or r.name,
Expand All @@ -419,6 +454,7 @@ async def list_resources(self) -> Sequence[ResourceEntry]:
)
for r in resources
]
return self._resources_cache

async def read_resource(
self, uri: str
Expand Down Expand Up @@ -475,9 +511,8 @@ async def resource_exists(self, uri: str) -> bool:
Returns:
``True`` if the resource exists, ``False`` otherwise.
"""
client = await self._ensure_client()
try:
resources = await client.list_resources()
resources = await self.list_resources()
except Exception: # noqa: BLE001
return False
return any(str(r.uri) == uri for r in resources)
Expand All @@ -487,12 +522,14 @@ async def resource_exists(self, uri: str) -> bool:
async def list_resource_templates(self) -> Sequence[ResourceTemplateEntry]:
"""List available MCP resource templates.

Returns:
Sequence of ``ResourceTemplateEntry`` descriptors.
Cached like ``list_resources``, invalidated by the same
``resources/list_changed`` notification.
"""
if self._resource_templates_cache is not None:
return self._resource_templates_cache
client = await self._ensure_client()
templates = await client.list_resource_templates()
return [
self._resource_templates_cache = [
ResourceTemplateEntry(
uri_template=str(t.uriTemplate),
name=t.name or "",
Expand All @@ -503,6 +540,7 @@ async def list_resource_templates(self) -> Sequence[ResourceTemplateEntry]:
)
for t in templates
]
return self._resource_templates_cache

async def complete_resource_template(
self,
Expand Down Expand Up @@ -733,4 +771,6 @@ async def __aexit__(
if self._session_pool is None and self._client is not None:
await self._client.__aexit__(exc_type, exc_val, exc_tb)
self._client = None
self._resources_cache = None
self._resource_templates_cache = None
self._change_queues.clear()
Loading
Loading