diff --git a/changelog/unreleased/2026-08-20-mcp-resource-integration.md b/changelog/unreleased/2026-08-20-mcp-resource-integration.md
new file mode 100644
index 000000000..709b758f6
--- /dev/null
+++ b/changelog/unreleased/2026-08-20-mcp-resource-integration.md
@@ -0,0 +1,12 @@
+# Unified MCP Resource integration
+
+AgentPool now exposes exactly three model-facing MCP Resource tools:
+`list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource`.
+Listings preserve MCP metadata and use opaque host cursors; reads require an
+explicit server and URI, with structured errors, text truncation, and bounded
+binary attachment handling.
+
+Resource-capable MCP providers are negotiated and registered independently of
+the `resources.enabled` model-tool gate. Host catalog enumeration and
+`ResourceSource` injection use the configured server display name, preserving
+same-URI isolation across providers.
diff --git a/changelog/unreleased/README.md b/changelog/unreleased/README.md
new file mode 100644
index 000000000..c130d38be
--- /dev/null
+++ b/changelog/unreleased/README.md
@@ -0,0 +1,3 @@
+# Unreleased
+
+- [2026-08-20] Unify MCP Resource discovery, pagination, server-qualified reads, and Host ResourceSource routing. ([details](2026-08-20-mcp-resource-integration.md))
diff --git a/src/wolfharness/agents/native_agent/agent.py b/src/wolfharness/agents/native_agent/agent.py
index 2e73c6eda..bd9a4b5df 100644
--- a/src/wolfharness/agents/native_agent/agent.py
+++ b/src/wolfharness/agents/native_agent/agent.py
@@ -1158,7 +1158,7 @@ async def get_agentlet[AgentOutputType]( # noqa: PLR0915
# Per-agent opt-out via ``resources.enabled: false`` in YAML.
if self.config is not None and self.config.resources.enabled:
resource_cap = pool.resource_capability
- if resource_cap is not None:
+ if resource_cap is not None and resource_cap not in self._external_capabilities:
tool_capabilities.append(resource_cap)
# Register per-session capabilities (MCP, SkillManagerCap)
@@ -1178,9 +1178,20 @@ async def get_agentlet[AgentOutputType]( # noqa: PLR0915
ScopeLevel,
)
- session_scope = Scope(level=ScopeLevel.SESSION, session_id=session_id)
+ session_scope = Scope(
+ level=ScopeLevel.SESSION,
+ agent_name=self.name,
+ session_id=session_id,
+ )
for cap in mcp_capabilities:
registry.register(cap, session_scope)
+ # Agent/session MCP managers may own additional resource
+ # providers. Pool-shared providers are already registered
+ # at POOL scope by AgentFactory and must not be duplicated.
+ if self.host_context is None or self.mcp is not self.host_context.mcp:
+ for provider in self.mcp.get_mcp_providers():
+ if provider.resources_supported is not False:
+ registry.register(provider, session_scope)
if pool is not None:
pool_caps = pool.skill_capabilities
if pool_caps:
diff --git a/src/wolfharness/capabilities/extension_registry.py b/src/wolfharness/capabilities/extension_registry.py
index 20152a0ab..cb1429b40 100644
--- a/src/wolfharness/capabilities/extension_registry.py
+++ b/src/wolfharness/capabilities/extension_registry.py
@@ -24,6 +24,7 @@
from typing import TYPE_CHECKING, Any
import warnings
+from wolfharness.capabilities.uri_scheme_registry import UriSchemeRegistry
from wolfharness.log import get_logger
@@ -36,6 +37,7 @@
from wolfharness.capabilities.resource_protocols import (
ChangeObservable,
CommandResource,
+ McpResourceProvider,
ResourceAccess,
ResourceTemplateAccess,
SkillResource,
@@ -126,6 +128,7 @@ class ExtensionRegistry:
def __init__(
self,
max_composition_depth: int = DEFAULT_MAX_COMPOSITION_DEPTH,
+ scheme_registry: UriSchemeRegistry | None = None,
) -> None:
"""Initialize the registry with empty scope storage.
@@ -133,8 +136,12 @@ def __init__(
max_composition_depth: Maximum composition depth (root-inclusive).
When depth exceeds this limit, a warning is logged but
registration is NOT blocked. Default: 3.
+ scheme_registry: Optional ``UriSchemeRegistry`` for scheme-based
+ resource routing. When ``None``, routing falls back to the
+ legacy iteration over all providers.
"""
self._max_composition_depth = max_composition_depth
+ self._scheme_registry = scheme_registry or UriSchemeRegistry()
# 4-level scope storage: POOL > AGENT > SESSION > TURN
self._pool: list[AbstractCapability[Any]] = []
@@ -315,6 +322,20 @@ def clear_session(self, session_id: str) -> None:
self._session.pop(session_id, None)
self._turn.pop(session_id, None)
+ # ------------------------------------------------------------------
+ # Properties
+ # ------------------------------------------------------------------
+
+ @property
+ def scheme_registry(self) -> UriSchemeRegistry:
+ """Return the URI scheme registry for resource routing.
+
+ Returns:
+ The ``UriSchemeRegistry`` instance, which is created
+ automatically during ``__init__`` if one was not provided.
+ """
+ return self._scheme_registry
+
# ------------------------------------------------------------------
# Query
# ------------------------------------------------------------------
@@ -387,7 +408,24 @@ def get_resource_access(
from wolfharness.capabilities.resource_protocols import ResourceAccess
return [
- cap for cap in self.get_visible_capabilities(scope) if isinstance(cap, ResourceAccess)
+ cap
+ for cap in self.get_visible_capabilities(scope)
+ if isinstance(cap, ResourceAccess)
+ and getattr(cap, "resources_supported", None) is not False
+ ]
+
+ def get_mcp_resource_providers(
+ self,
+ scope: Scope,
+ ) -> list[McpResourceProvider]:
+ """Get visible providers implementing the paged MCP Resource contract."""
+ from wolfharness.capabilities.resource_protocols import McpResourceProvider
+
+ return [
+ cap
+ for cap in self.get_visible_capabilities(scope)
+ if isinstance(cap, McpResourceProvider)
+ and getattr(cap, "resources_supported", None) is not False
]
def get_tool_access(
diff --git a/src/wolfharness/capabilities/mcp_server_cap.py b/src/wolfharness/capabilities/mcp_server_cap.py
index 9c173ccf3..3380eaa09 100644
--- a/src/wolfharness/capabilities/mcp_server_cap.py
+++ b/src/wolfharness/capabilities/mcp_server_cap.py
@@ -30,6 +30,9 @@
CommandResource,
CompletionArgument,
CompletionResult,
+ McpResourceListPage,
+ McpResourceProvider,
+ McpResourceTemplateListPage,
ResourceAccess,
ResourceEntry,
ResourceTemplateAccess,
@@ -40,6 +43,7 @@
ToolAccess,
ToolEntry,
ToolResult,
+ normalize_mcp_json_object,
)
@@ -69,6 +73,7 @@ class McpServerCap(
AbstractCapability[AgentDepsT],
ToolAccess,
ResourceAccess,
+ McpResourceProvider,
ResourceTemplateAccess,
SkillResource,
CommandResource,
@@ -135,6 +140,10 @@ def __init__(
self._name = name or self._config.client_id
self._tool_prefix = tool_prefix
self._client: MCPClient | None = client
+ # ``None`` means that the MCP initialize handshake has not yet been
+ # queried. The registry treats an explicit ``False`` as a tools-only
+ # server and keeps it out of the Resource provider view.
+ self._resources_supported: bool | None = None
self._change_queues: set[asyncio.Queue[ChangeEvent]] = set()
self._resources_cache: list[ResourceEntry] | None = None
self._resource_templates_cache: list[ResourceTemplateEntry] | None = None
@@ -162,6 +171,11 @@ def client(self) -> MCPClient | None:
"""Return the wrapped MCP client, or None if not yet initialized."""
return self._client
+ @property
+ def resources_supported(self) -> bool | None:
+ """Return the cached MCP Resource capability state, when known."""
+ return self._resources_supported
+
# ---- Lazy client initialization ----
async def _ensure_client(self) -> MCPClient:
@@ -321,6 +335,12 @@ async def _build_toolset(
tools = await client.list_tools()
if not tools:
return None
+ # Apply enabled_tools/disabled_tools filtering from config.
+ # _config is a BaseMCPServerConfig (or subclass) which provides
+ # is_tool_allowed() and needs_tool_filtering().
+ config = self._config
+ if config.needs_tool_filtering():
+ tools = [t for t in tools if config.is_tool_allowed(t.name)]
from pydantic_ai.toolsets import CombinedToolset, FunctionToolset, PrefixedToolset
from wolfharness.capabilities.tool_schema_overlap_config import (
@@ -433,6 +453,89 @@ async def call_tool(self, name: str, args: dict[str, Any]) -> ToolResult:
# ---- ResourceAccess ----
+ @property
+ def client_name(self) -> str:
+ """Return the stable configured MCP client/server identifier."""
+ display_name = getattr(self._config, "display_name", None)
+ if isinstance(display_name, str) and display_name.strip():
+ return display_name.strip()
+ return self._config.client_id
+
+ @property
+ def server_name(self) -> str:
+ """Return the stable configured server identifier."""
+ return self.client_name
+
+ async def supports_resources(self) -> bool:
+ """Return whether this upstream MCP server declared resources."""
+ client = await self._ensure_client()
+ try:
+ supported = await client.supports_resources()
+ except (OSError, RuntimeError, TimeoutError, ValueError):
+ # A failed handshake must not leave an ambiguous provider in the
+ # Resource registry; the MCP tool connection remains available.
+ self._resources_supported = False
+ raise
+ self._resources_supported = supported
+ return supported
+
+ async def list_resources_page(self, cursor: str | None = None) -> McpResourceListPage:
+ """Return one upstream resource page with complete MCP metadata."""
+ client = await self._ensure_client()
+ return await client.list_resources_mcp(cursor)
+
+ async def list_resource_templates_page(
+ self, cursor: str | None = None
+ ) -> McpResourceTemplateListPage:
+ """Return one upstream resource-template page."""
+ client = await self._ensure_client()
+ return await client.list_resource_templates_mcp(cursor)
+
+ async def read_mcp_resource(
+ self, uri: str
+ ) -> list[TextResourceContent | BlobResourceContent] | None:
+ """Read a resource through the MCP Resource contract."""
+ client = await self._ensure_client()
+ contents = await client.read_resource(uri)
+ return self._convert_resource_contents(uri, contents)
+
+ @staticmethod
+ def _convert_resource_contents(
+ uri: str,
+ contents: Sequence[Any],
+ ) -> list[TextResourceContent | BlobResourceContent] | None:
+ """Convert MCP content blocks to shared protocol content types."""
+ if not contents:
+ return None
+ result: list[TextResourceContent | BlobResourceContent] = []
+ for content in contents:
+ text_value: str | None = getattr(content, "text", None)
+ if text_value is not None:
+ result.append(
+ TextResourceContent(
+ uri=uri,
+ mime_type=getattr(content, "mimeType", None),
+ meta=normalize_mcp_json_object(
+ getattr(content, "meta", getattr(content, "_meta", None))
+ ),
+ text=text_value,
+ )
+ )
+ continue
+ blob_value: str | None = getattr(content, "blob", None)
+ if blob_value is not None:
+ result.append(
+ BlobResourceContent(
+ uri=uri,
+ mime_type=getattr(content, "mimeType", None),
+ meta=normalize_mcp_json_object(
+ getattr(content, "meta", getattr(content, "_meta", None))
+ ),
+ blob=blob_value,
+ )
+ )
+ return result or None
+
async def list_resources(self) -> Sequence[ResourceEntry]:
"""List available MCP resources.
@@ -448,9 +551,14 @@ async def list_resources(self) -> Sequence[ResourceEntry]:
self._resources_cache = [
ResourceEntry(
uri=str(r.uri),
+ server=self.server_name,
name=r.title or r.name,
+ title=getattr(r, "title", "") or "",
description=r.description or "",
mime_type=r.mimeType if r.mimeType else "",
+ size=getattr(r, "size", None),
+ annotations=normalize_mcp_json_object(getattr(r, "annotations", None)),
+ meta=normalize_mcp_json_object(getattr(r, "meta", getattr(r, "_meta", None))),
)
for r in resources
]
@@ -474,33 +582,7 @@ async def read_resource(
except Exception:
logger.warning("Failed to read resource %r", uri, exc_info=True)
return None
- if not contents:
- return None
- result: list[TextResourceContent | BlobResourceContent] = []
- for c in contents:
- # MCP TextResourceContents has .text, BlobResourceContents has .blob
- text_val: str | None = getattr(c, "text", None)
- if text_val is not None:
- result.append(
- TextResourceContent(
- uri=uri,
- mime_type=getattr(c, "mimeType", None),
- meta=getattr(c, "meta", None),
- text=text_val,
- )
- )
- else:
- blob_val: str | None = getattr(c, "blob", None)
- if blob_val is not None:
- result.append(
- BlobResourceContent(
- uri=uri,
- mime_type=getattr(c, "mimeType", None),
- meta=getattr(c, "meta", None),
- blob=blob_val,
- )
- )
- return result if result else None
+ return self._convert_resource_contents(uri, contents)
async def resource_exists(self, uri: str) -> bool:
"""Check if an MCP resource exists.
@@ -532,11 +614,13 @@ async def list_resource_templates(self) -> Sequence[ResourceTemplateEntry]:
self._resource_templates_cache = [
ResourceTemplateEntry(
uri_template=str(t.uriTemplate),
+ server=self.server_name,
name=t.name or "",
title=getattr(t, "title", "") or "",
description=t.description or "",
mime_type=t.mimeType if t.mimeType else "",
- annotations=getattr(t, "annotations", None),
+ annotations=normalize_mcp_json_object(getattr(t, "annotations", None)),
+ meta=normalize_mcp_json_object(getattr(t, "meta", getattr(t, "_meta", None))),
)
for t in templates
]
diff --git a/src/wolfharness/capabilities/resource_capability.py b/src/wolfharness/capabilities/resource_capability.py
index 959989670..7d18831d9 100644
--- a/src/wolfharness/capabilities/resource_capability.py
+++ b/src/wolfharness/capabilities/resource_capability.py
@@ -1,4 +1,4 @@
-"""ResourceCapability — unified resource access via 5 agent-facing tools.
+"""ResourceCapability — unified MCP Resource access via three agent tools.
Provides a single ``AbstractCapability`` that aggregates resource access
across all visible ``ResourceAccess``, ``SkillResource``, and
@@ -6,37 +6,44 @@
``ExtensionRegistry``. The capability is stateless — it reads
``ctx.deps`` (an ``AgentContextDeps``) at runtime to resolve providers.
-Tools exposed:
- - ``list_resources``: Aggregate resources from MCP + skills
- - ``read_resource``: Read content by URI (skill:// or other)
- - ``resource_exists``: Check if a resource exists
- - ``list_resource_templates``: List URI templates for dynamic discovery
- - ``complete_resource_template``: Get completion suggestions for template params
+Only the three ``list_mcp_*``/``read_mcp_resource`` methods are model-facing.
+The older five methods remain below as internal compatibility helpers.
"""
from __future__ import annotations
import asyncio
+import base64
+import binascii
+import json
from typing import TYPE_CHECKING, Annotated
import logfire
from pydantic import Field
-from pydantic_ai import ToolReturn
+from pydantic_ai import BinaryContent, ToolReturn
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.tools import AgentDepsT, RunContext
from pydantic_ai.toolsets import AgentToolset, FunctionToolset
from wolfharness.capabilities.extension_registry import Scope, ScopeLevel
from wolfharness.capabilities.resource_protocols import (
+ BlobResourceContent,
CompletionArgument,
CompletionResult,
+ McpResourceListResult,
+ McpResourceReadResult,
+ McpResourceTemplateListResult,
ResourceEntry,
+ ResourceError,
ResourceTemplateEntry,
+ TextResourceContent,
)
if TYPE_CHECKING:
from wolfharness.capabilities.agent_context import AgentContextDeps
+ from wolfharness.capabilities.resource_protocols import McpResourceProvider
+ from wolfharness.common_types import JsonObject
# Number of header lines (header + separator) before data rows.
@@ -45,7 +52,17 @@
# Default pagination limits.
_DEFAULT_LIST_LIMIT = 50
_DEFAULT_READ_TEXT_LIMIT = 10_000
+_MAX_LIST_LIMIT = 100
+_CURSOR_VERSION = 2
_MAX_COMPLETION_SUGGESTIONS = 100
+_MAX_BLOB_BYTES = 10 * 1024 * 1024
+_SUPPORTED_BLOB_MIME_TYPES = frozenset({
+ "application/pdf",
+ "image/gif",
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+})
# Max wall-clock time a single provider may take to answer a listing request.
# Providers that time out are skipped with a warning instead of blocking the
@@ -54,7 +71,7 @@
class ResourceCapability(AbstractCapability[AgentDepsT]):
- """Unified resource access capability providing 5 agent-facing tools.
+ """Unified MCP Resource capability providing three agent-facing tools.
Aggregates resources from all visible providers (MCP servers, local
skills) via the ``ExtensionRegistry`` on ``AgentContextDeps``. The
@@ -93,35 +110,24 @@ def get_instructions(self) -> str | None:
management tools and supported URI schemes.
"""
return (
- "You have access to resource management tools:\n"
- "- list_resources: List available resources from connected MCP "
- "servers and local files (paginated, use offset to page through)\n"
- "- read_resource: Read content from a resource URI (e.g. mcp://, "
- "file://, viking://, optionally http(s) for servers that use web "
- "URIs; web pages are usually better read with a web fetch tool)\n"
- "- resource_exists: Check if a resource exists\n"
- "- list_resource_templates: List URI templates for dynamic "
- "resource discovery (paginated, use offset to page through)\n"
- "- complete_resource_template: Get completion suggestions for "
- "template parameters\n\n"
- "URI schemes: mcp:// for MCP server resources, file:// for "
- "file-based resources"
+ "Use MCP Resource tools progressively: list_mcp_resources or "
+ "list_mcp_resource_templates first, then read_mcp_resource with "
+ "the exact server and opaque URI returned by the provider. "
+ "Do not infer URI values from templates."
)
@logfire.instrument("capability.resource_capability.get_toolset")
def get_toolset(self) -> AgentToolset[AgentDepsT] | None:
- """Return a ``FunctionToolset`` with all 5 resource tools.
+ """Return a ``FunctionToolset`` with the three MCP Resource tools.
The tools access ``ctx.deps`` at runtime, which must be an
``AgentContextDeps`` with an ``extension_registry`` field.
"""
return FunctionToolset(
[
- self.list_resources,
- self.read_resource,
- self.resource_exists,
- self.list_resource_templates,
- self.complete_resource_template,
+ self.list_mcp_resources,
+ self.list_mcp_resource_templates,
+ self.read_mcp_resource,
],
id=self._toolset_id,
)
@@ -185,6 +191,685 @@ def _extract_skill_name(uri: str) -> str:
path = uri[len("skill://") :]
return path.split("/")[0] if path else ""
+ @staticmethod
+ def _encode_cursor(
+ *,
+ server: str | None,
+ current_server: str | None,
+ provider_index: int,
+ upstream_cursor: str | None,
+ offset: int,
+ ) -> str:
+ payload = {
+ "version": _CURSOR_VERSION,
+ "server": server,
+ "current_server": current_server,
+ "provider_index": provider_index,
+ "upstream_cursor": upstream_cursor,
+ "offset": offset,
+ }
+ raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
+ return base64.urlsafe_b64encode(raw).decode().rstrip("=")
+
+ @staticmethod
+ def _decode_cursor(cursor: str) -> dict[str, object]:
+ try:
+ padding = "=" * (-len(cursor) % 4)
+ value = json.loads(base64.urlsafe_b64decode((cursor + padding).encode()))
+ except (
+ ValueError,
+ TypeError,
+ binascii.Error,
+ UnicodeDecodeError,
+ json.JSONDecodeError,
+ ) as exc:
+ raise ValueError("cursor is not a valid host cursor") from exc
+ if not isinstance(value, dict) or value.get("version") != _CURSOR_VERSION:
+ raise ValueError("cursor version is unsupported")
+ if not isinstance(value.get("provider_index"), int) or not isinstance(
+ value.get("offset"), int
+ ):
+ raise TypeError("cursor position is invalid")
+ if value["provider_index"] < 0 or value["offset"] < 0:
+ raise ValueError("cursor position is invalid")
+ if value.get("server") is not None and not isinstance(value.get("server"), str):
+ raise ValueError("cursor server is invalid")
+ if value.get("current_server") is not None and not isinstance(
+ value.get("current_server"), str
+ ):
+ raise ValueError("cursor current server is invalid")
+ if value.get("upstream_cursor") is not None and not isinstance(
+ value.get("upstream_cursor"), str
+ ):
+ raise ValueError("cursor upstream value is invalid")
+ return value
+
+ @staticmethod
+ def _cursor_state(value: dict[str, object]) -> tuple[int, str | None, int]:
+ provider_index = value.get("provider_index")
+ offset = value.get("offset")
+ upstream_cursor = value.get("upstream_cursor")
+ if not isinstance(provider_index, int) or not isinstance(offset, int):
+ raise TypeError("cursor position is invalid")
+ if upstream_cursor is not None and not isinstance(upstream_cursor, str):
+ raise ValueError("cursor upstream value is invalid")
+ return provider_index, upstream_cursor, offset
+
+ @staticmethod
+ def _resource_entry_dict(entry: ResourceEntry) -> dict[str, object]:
+ return {
+ "server": entry.server,
+ "uri": entry.uri,
+ "name": entry.name,
+ "title": entry.title,
+ "description": entry.description,
+ "mime_type": entry.mime_type,
+ "size": entry.size,
+ "annotations": entry.annotations,
+ "meta": entry.meta,
+ }
+
+ @staticmethod
+ def _template_entry_dict(entry: ResourceTemplateEntry) -> dict[str, object]:
+ return {
+ "server": entry.server,
+ "uri_template": entry.uri_template,
+ "name": entry.name,
+ "title": entry.title,
+ "description": entry.description,
+ "mime_type": entry.mime_type,
+ "annotations": entry.annotations,
+ "meta": entry.meta,
+ }
+
+ @staticmethod
+ def _error_dict(error: ResourceError) -> dict[str, object]:
+ return {
+ "code": error.code,
+ "message": error.message,
+ "retryable": error.retryable,
+ "suggestion": error.suggestion,
+ }
+
+ @staticmethod
+ def _validate_limit(limit: int) -> None:
+ if not 1 <= limit <= _MAX_LIST_LIMIT:
+ raise ValueError("limit must be between 1 and 100")
+
+ async def _mcp_providers(self, ctx: RunContext[AgentDepsT]) -> list[McpResourceProvider]:
+ agent_ctx = self._resolve_agent_context(ctx)
+ registry = agent_ctx.extension_registry
+ if registry is None:
+ return []
+ from wolfharness.capabilities.resource_protocols import McpResourceProvider
+
+ return sorted(
+ (
+ cap
+ for cap in registry.get_visible_capabilities(self._make_scope(agent_ctx))
+ if isinstance(cap, McpResourceProvider)
+ ),
+ key=lambda provider: provider.server_name,
+ )
+
+ @logfire.instrument("capability.resource_capability.list_mcp_resources")
+ async def list_mcp_resources( # noqa: PLR0915
+ self,
+ ctx: RunContext[AgentDepsT],
+ server: Annotated[
+ str | None, Field(description="Optional configured MCP server name")
+ ] = None,
+ cursor: Annotated[
+ str | None, Field(description="Opaque cursor from the previous page")
+ ] = None,
+ limit: Annotated[
+ int,
+ Field(
+ ge=1,
+ le=_MAX_LIST_LIMIT,
+ description="Number of resources to return, from 1 to 100",
+ ),
+ ] = _DEFAULT_LIST_LIMIT,
+ ) -> McpResourceListResult:
+ """List one host-aggregated page of MCP resources."""
+ self._validate_limit(limit)
+ providers = await self._mcp_providers(ctx)
+ if server is not None:
+ providers = [provider for provider in providers if provider.server_name == server]
+ if not providers:
+ return self._resource_list_result(
+ "No MCP server is registered with that name.",
+ errors=[
+ ResourceError(
+ "unknown_server",
+ f"Unknown MCP server: {server}",
+ False,
+ "Use list_mcp_resources without server to discover names.",
+ )
+ ],
+ )
+ provider_index = 0
+ upstream_cursor: str | None = None
+ offset = 0
+ if cursor:
+ try:
+ decoded = self._decode_cursor(cursor)
+ except (ValueError, TypeError) as exc:
+ return self._resource_list_result(
+ "The supplied resource cursor is invalid.",
+ errors=[
+ ResourceError(
+ "invalid_cursor",
+ str(exc),
+ False,
+ "Restart pagination without a cursor.",
+ )
+ ],
+ )
+ if decoded.get("server") != server:
+ return self._resource_list_result(
+ "The cursor does not belong to the requested server.",
+ errors=[
+ ResourceError(
+ "invalid_cursor",
+ "cursor/server mismatch",
+ False,
+ "Restart pagination with the original server argument.",
+ )
+ ],
+ )
+ provider_index, upstream_cursor, offset = self._cursor_state(decoded)
+ if provider_index >= len(providers):
+ return self._resource_list_result(
+ "The supplied resource cursor is no longer valid.",
+ errors=[
+ ResourceError(
+ "invalid_cursor",
+ "cursor provider position is outside the current provider set",
+ False,
+ "Restart pagination without a cursor.",
+ )
+ ],
+ )
+ current_server = decoded.get("current_server")
+ if current_server != providers[provider_index].server_name:
+ return self._resource_list_result(
+ "The supplied resource cursor is no longer valid.",
+ errors=[
+ ResourceError(
+ "invalid_cursor",
+ "cursor current server does not match provider position",
+ False,
+ "Restart pagination without a cursor.",
+ )
+ ],
+ )
+
+ entries: list[ResourceEntry] = []
+ errors: list[ResourceError] = []
+ next_cursor: str | None = None
+ while provider_index < len(providers) and len(entries) < limit:
+ provider = providers[provider_index]
+ try:
+ if not await provider.supports_resources():
+ errors.append(
+ ResourceError(
+ "resources_not_supported",
+ f"Server {provider.server_name} did not declare resources capability",
+ False,
+ "Use its tools or configure a server with resources capability.",
+ )
+ )
+ provider_index += 1
+ upstream_cursor = None
+ offset = 0
+ continue
+ page = await provider.list_resources_page(
+ upstream_cursor if isinstance(upstream_cursor, str) else None
+ )
+ except (OSError, RuntimeError, TimeoutError, ValueError) as exc:
+ errors.append(
+ ResourceError(
+ "provider_unavailable",
+ f"Failed to list resources from {provider.server_name}: {exc}",
+ True,
+ "Retry the same request later.",
+ )
+ )
+ provider_index += 1
+ upstream_cursor = None
+ offset = 0
+ continue
+ available = page.entries[offset:]
+ take = min(limit - len(entries), len(available))
+ entries.extend(available[:take])
+ offset += take
+ if offset < len(page.entries):
+ next_cursor = self._encode_cursor(
+ server=server,
+ current_server=provider.server_name,
+ provider_index=provider_index,
+ upstream_cursor=upstream_cursor if isinstance(upstream_cursor, str) else None,
+ offset=offset,
+ )
+ break
+ if page.next_cursor:
+ next_cursor = self._encode_cursor(
+ server=server,
+ current_server=provider.server_name,
+ provider_index=provider_index,
+ upstream_cursor=page.next_cursor,
+ offset=0,
+ )
+ if len(entries) >= limit:
+ break
+ upstream_cursor = page.next_cursor
+ offset = 0
+ continue
+ provider_index += 1
+ upstream_cursor = None
+ offset = 0
+ if len(entries) >= limit:
+ break
+ if next_cursor is None and provider_index < len(providers):
+ next_cursor = self._encode_cursor(
+ server=server,
+ current_server=providers[provider_index].server_name,
+ provider_index=provider_index,
+ upstream_cursor=upstream_cursor if isinstance(upstream_cursor, str) else None,
+ offset=offset,
+ )
+ summary = f"Returned {len(entries)} MCP resource(s)"
+ return self._resource_list_result(
+ summary, resources=entries, next_cursor=next_cursor, errors=errors
+ )
+
+ @logfire.instrument("capability.resource_capability.list_mcp_resource_templates")
+ async def list_mcp_resource_templates( # noqa: PLR0915
+ self,
+ ctx: RunContext[AgentDepsT],
+ server: Annotated[
+ str | None, Field(description="Optional configured MCP server name")
+ ] = None,
+ cursor: Annotated[
+ str | None, Field(description="Opaque cursor from the previous page")
+ ] = None,
+ limit: Annotated[
+ int,
+ Field(
+ ge=1,
+ le=_MAX_LIST_LIMIT,
+ description="Number of templates to return, from 1 to 100",
+ ),
+ ] = _DEFAULT_LIST_LIMIT,
+ ) -> McpResourceTemplateListResult:
+ """List one host-aggregated page of MCP resource templates."""
+ self._validate_limit(limit)
+ providers = await self._mcp_providers(ctx)
+ if server is not None:
+ providers = [provider for provider in providers if provider.server_name == server]
+ if not providers:
+ return self._template_list_result(
+ "No MCP server is registered with that name.",
+ templates=[],
+ errors=[
+ ResourceError(
+ "unknown_server",
+ f"Unknown MCP server: {server}",
+ False,
+ "Use list_mcp_resource_templates without server to discover names.",
+ )
+ ],
+ )
+ provider_index = 0
+ upstream_cursor = None
+ offset = 0
+ if cursor:
+ try:
+ decoded = self._decode_cursor(cursor)
+ except (ValueError, TypeError) as exc:
+ return self._template_list_result(
+ "The supplied resource cursor is invalid.",
+ templates=[],
+ errors=[
+ ResourceError(
+ "invalid_cursor",
+ str(exc),
+ False,
+ "Restart pagination without a cursor.",
+ )
+ ],
+ )
+ if decoded.get("server") != server:
+ return self._template_list_result(
+ "The cursor does not belong to the requested server.",
+ templates=[],
+ errors=[
+ ResourceError(
+ "invalid_cursor",
+ "cursor/server mismatch",
+ False,
+ "Restart pagination with the original server argument.",
+ )
+ ],
+ )
+ provider_index, upstream_cursor, offset = self._cursor_state(decoded)
+ if provider_index >= len(providers):
+ return self._template_list_result(
+ "The supplied resource cursor is no longer valid.",
+ templates=[],
+ errors=[
+ ResourceError(
+ "invalid_cursor",
+ "cursor provider position is outside the current provider set",
+ False,
+ "Restart pagination without a cursor.",
+ )
+ ],
+ )
+ current_server = decoded.get("current_server")
+ if current_server != providers[provider_index].server_name:
+ return self._template_list_result(
+ "The supplied resource cursor is no longer valid.",
+ templates=[],
+ errors=[
+ ResourceError(
+ "invalid_cursor",
+ "cursor current server does not match provider position",
+ False,
+ "Restart pagination without a cursor.",
+ )
+ ],
+ )
+ entries: list[ResourceTemplateEntry] = []
+ errors: list[ResourceError] = []
+ next_cursor: str | None = None
+ while provider_index < len(providers) and len(entries) < limit:
+ provider = providers[provider_index]
+ try:
+ if not await provider.supports_resources():
+ errors.append(
+ ResourceError(
+ "resources_not_supported",
+ f"Server {provider.server_name} did not declare resources capability",
+ False,
+ "Use its tools or configure a server with resources capability.",
+ )
+ )
+ provider_index += 1
+ upstream_cursor = None
+ offset = 0
+ continue
+ page = await provider.list_resource_templates_page(
+ upstream_cursor if isinstance(upstream_cursor, str) else None
+ )
+ except (OSError, RuntimeError, TimeoutError, ValueError) as exc:
+ errors.append(
+ ResourceError(
+ "provider_unavailable",
+ f"Failed to list resource templates from {provider.server_name}: {exc}",
+ True,
+ "Retry the same request later.",
+ )
+ )
+ provider_index += 1
+ upstream_cursor = None
+ offset = 0
+ continue
+ available = page.entries[offset:]
+ take = min(limit - len(entries), len(available))
+ entries.extend(available[:take])
+ offset += take
+ if offset < len(page.entries):
+ next_cursor = self._encode_cursor(
+ server=server,
+ current_server=provider.server_name,
+ provider_index=provider_index,
+ upstream_cursor=upstream_cursor if isinstance(upstream_cursor, str) else None,
+ offset=offset,
+ )
+ break
+ if page.next_cursor:
+ next_cursor = self._encode_cursor(
+ server=server,
+ current_server=provider.server_name,
+ provider_index=provider_index,
+ upstream_cursor=page.next_cursor,
+ offset=0,
+ )
+ if len(entries) >= limit:
+ break
+ upstream_cursor = page.next_cursor
+ offset = 0
+ continue
+ provider_index += 1
+ upstream_cursor = None
+ offset = 0
+ if next_cursor is None and provider_index < len(providers):
+ next_cursor = self._encode_cursor(
+ server=server,
+ current_server=providers[provider_index].server_name,
+ provider_index=provider_index,
+ upstream_cursor=upstream_cursor if isinstance(upstream_cursor, str) else None,
+ offset=offset,
+ )
+ return self._template_list_result(
+ f"Returned {len(entries)} MCP resource template(s)",
+ templates=entries,
+ next_cursor=next_cursor,
+ errors=errors,
+ )
+
+ @staticmethod
+ def _resource_list_result(
+ summary: str,
+ *,
+ resources: list[ResourceEntry] | None = None,
+ next_cursor: str | None = None,
+ errors: list[ResourceError] | None = None,
+ ) -> McpResourceListResult:
+ return McpResourceListResult(
+ summary=summary,
+ resources=resources or [],
+ next_cursor=next_cursor,
+ errors=errors or [],
+ )
+
+ @staticmethod
+ def _template_list_result(
+ summary: str,
+ *,
+ templates: list[ResourceTemplateEntry] | None = None,
+ next_cursor: str | None = None,
+ errors: list[ResourceError] | None = None,
+ ) -> McpResourceTemplateListResult:
+ return McpResourceTemplateListResult(
+ summary=summary,
+ templates=templates or [],
+ next_cursor=next_cursor,
+ errors=errors or [],
+ )
+
+ @logfire.instrument("capability.resource_capability.read_mcp_resource")
+ async def read_mcp_resource( # noqa: PLR0915
+ self,
+ ctx: RunContext[AgentDepsT],
+ server: Annotated[str, Field(description="Configured MCP server name")],
+ uri: Annotated[
+ str, Field(description="Opaque resource URI copied from MCP list/search output")
+ ],
+ ) -> ToolReturn[McpResourceReadResult]:
+ """Read one resource from the named MCP server without URI rewriting."""
+ providers = await self._mcp_providers(ctx)
+ provider = next((item for item in providers if item.server_name == server), None)
+ if provider is None:
+ error = ResourceError(
+ "unknown_server",
+ f"Unknown MCP server: {server}",
+ False,
+ "Use list_mcp_resources to discover server names.",
+ )
+ return self._read_error(uri, error)
+ try:
+ if not await provider.supports_resources():
+ error = ResourceError(
+ "resources_not_supported",
+ f"Server {server} did not declare resources capability",
+ False,
+ "Use the server's tools instead.",
+ )
+ return self._read_error(uri, error)
+ contents = await provider.read_mcp_resource(uri)
+ except PermissionError as exc:
+ error = ResourceError(
+ "permission_denied",
+ str(exc),
+ False,
+ "Request a URI permitted by the upstream server.",
+ )
+ return self._read_error(uri, error)
+ except TimeoutError as exc:
+ error = ResourceError("timeout", str(exc), True, "Retry the read later.")
+ return self._read_error(uri, error)
+ except (OSError, RuntimeError, ValueError) as exc:
+ error = ResourceError(
+ "provider_unavailable",
+ str(exc),
+ True,
+ "Retry the read or inspect the provider status.",
+ )
+ return self._read_error(uri, error)
+ if not contents:
+ error = ResourceError(
+ "resource_not_found",
+ f"Resource not found: {uri}",
+ False,
+ "Copy the URI exactly from the server's list or search result.",
+ )
+ return self._read_error(uri, error)
+
+ model_contents: list[JsonObject] = []
+ visible_content: list[str | BinaryContent] = []
+ errors: list[ResourceError] = []
+ truncated = False
+ original_char_count = 0
+ for content in contents:
+ if isinstance(content, TextResourceContent):
+ original_char_count += len(content.text)
+ text = content.text
+ if len(text) > _DEFAULT_READ_TEXT_LIMIT:
+ text = text[:_DEFAULT_READ_TEXT_LIMIT]
+ truncated = True
+ content_entry = self._content_entry_with_meta(
+ {
+ "type": "text",
+ "uri": content.uri,
+ "mime_type": content.mime_type,
+ "text": text,
+ },
+ content.meta,
+ )
+ model_contents.append(content_entry)
+ visible_content.append(text)
+ elif isinstance(content, BlobResourceContent):
+ try:
+ blob = base64.b64decode(content.blob, validate=True)
+ except (binascii.Error, ValueError):
+ error = ResourceError(
+ "unsupported_mime_type",
+ "Provider returned invalid base64 content",
+ False,
+ "Ask the provider for a supported binary resource.",
+ )
+ return self._read_error(uri, error)
+ mime_type = content.mime_type or ""
+ if mime_type not in _SUPPORTED_BLOB_MIME_TYPES:
+ content_entry = self._content_entry_with_meta(
+ {
+ "type": "blob",
+ "uri": content.uri,
+ "mime_type": mime_type,
+ "size": len(blob),
+ "attached": False,
+ "omission_reason": "unsupported_mime_type",
+ },
+ content.meta,
+ )
+ model_contents.append(content_entry)
+ errors.append(
+ ResourceError(
+ "unsupported_mime_type",
+ f"Binary MIME type is not supported: {mime_type or 'unknown'}",
+ False,
+ "Request a PDF, GIF, JPEG, PNG, or WebP resource.",
+ )
+ )
+ continue
+ if len(blob) > _MAX_BLOB_BYTES:
+ content_entry = self._content_entry_with_meta(
+ {
+ "type": "blob",
+ "uri": content.uri,
+ "mime_type": mime_type,
+ "size": len(blob),
+ "attached": False,
+ "omission_reason": "content_too_large",
+ },
+ content.meta,
+ )
+ model_contents.append(content_entry)
+ errors.append(
+ ResourceError(
+ "content_too_large",
+ f"Binary resource exceeds {_MAX_BLOB_BYTES} bytes",
+ False,
+ "Read a smaller resource or request metadata only.",
+ )
+ )
+ continue
+ content_entry = self._content_entry_with_meta(
+ {
+ "type": "blob",
+ "uri": content.uri,
+ "mime_type": mime_type,
+ "size": len(blob),
+ "attached": True,
+ },
+ content.meta,
+ )
+ model_contents.append(content_entry)
+ visible_content.append(BinaryContent(data=blob, media_type=mime_type))
+ return ToolReturn(
+ return_value=McpResourceReadResult(
+ summary=f"Read MCP resource {uri} from {server}",
+ uri=uri,
+ contents=model_contents,
+ truncated=truncated,
+ original_char_count=original_char_count or None,
+ errors=errors,
+ ),
+ content=visible_content,
+ )
+
+ @staticmethod
+ def _read_error(uri: str, error: ResourceError) -> ToolReturn[McpResourceReadResult]:
+ result = McpResourceReadResult(
+ summary=error.message,
+ uri=uri,
+ errors=[error],
+ )
+ return ToolReturn(return_value=result, content=error.message)
+
+ @staticmethod
+ def _content_entry_with_meta(
+ entry: JsonObject,
+ meta: JsonObject | None,
+ ) -> JsonObject:
+ """Attach upstream content metadata without changing other fields."""
+ if meta is not None:
+ entry["meta"] = meta
+ return entry
+
# ------------------------------------------------------------------
# Tool implementations
# ------------------------------------------------------------------
@@ -231,6 +916,8 @@ async def list_resources(
return_exceptions=True,
)
+ # Aggregate, deduplicate by URI, and identify sources.
+ seen_uris: set[str] = set()
source_entries: list[tuple[str, ResourceEntry]] = []
for cap, result in zip(provider_caps, gathered, strict=True):
if isinstance(result, BaseException):
@@ -239,8 +926,23 @@ async def list_resources(
source=type(cap).__name__,
)
continue
- source = type(cap).__name__
- source_entries.extend((source, entry) for entry in result)
+ # Use server_name if available, otherwise fall back to the
+ # first owned scheme or the class name.
+ source = (
+ getattr(cap, "server_name", None)
+ or (next(iter(cap.owned_schemes)) if cap.owned_schemes else "")
+ or type(cap).__name__
+ )
+ for entry in result:
+ if entry.uri in seen_uris:
+ logfire.warning(
+ "Duplicate resource URI '{uri}' from {source} (skipped)",
+ uri=entry.uri,
+ source=source,
+ )
+ continue
+ seen_uris.add(entry.uri)
+ source_entries.append((source, entry))
total = len(source_entries)
page = source_entries[offset : offset + limit]
@@ -301,7 +1003,9 @@ async def read_resource(
resource_caps = registry.get_resource_access(scope)
skill_caps = registry.get_skill_resources(scope)
- content = await resolve_resource_content(uri, resource_caps, skill_caps)
+ content = await resolve_resource_content(
+ uri, resource_caps, skill_caps, scheme_registry=registry.scheme_registry
+ )
if content is None:
return ToolReturn(return_value=f"Resource not found: {uri}")
diff --git a/src/wolfharness/capabilities/resource_protocols.py b/src/wolfharness/capabilities/resource_protocols.py
index 869ad8c82..0b9545154 100644
--- a/src/wolfharness/capabilities/resource_protocols.py
+++ b/src/wolfharness/capabilities/resource_protocols.py
@@ -19,6 +19,8 @@
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
import warnings
+from wolfharness.common_types import JsonObject, JsonValue # noqa: TC001
+
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
@@ -29,6 +31,41 @@
from wolfharness.capabilities.change_event import ChangeEvent
+def resource_catalog_key(server: str, uri: str) -> str:
+ """Build the OpenCode-compatible escaped ``server:uri`` catalog key."""
+ escaped_server = server.replace("%", "%25").replace(":", "%3A")
+ return f"{escaped_server}:{uri}"
+
+
+def normalize_mcp_json_object(value: object) -> JsonObject | None:
+ """Convert MCP metadata models into the repository JSON value types."""
+ if value is None:
+ return None
+ if hasattr(value, "model_dump"):
+ value = value.model_dump(mode="json", by_alias=True, exclude_none=True)
+ if not isinstance(value, dict):
+ return None
+ return {
+ key: _normalize_mcp_json_value(item) for key, item in value.items() if isinstance(key, str)
+ }
+
+
+def _normalize_mcp_json_value(value: object) -> JsonValue:
+ if value is None or isinstance(value, (bool, int, float, str)):
+ return value
+ if hasattr(value, "model_dump"):
+ value = value.model_dump(mode="json", by_alias=True, exclude_none=True)
+ if isinstance(value, dict):
+ return {
+ key: _normalize_mcp_json_value(item)
+ for key, item in value.items()
+ if isinstance(key, str)
+ }
+ if isinstance(value, list):
+ return [_normalize_mcp_json_value(item) for item in value]
+ return str(value)
+
+
# ---- Dataclasses ----
@@ -87,14 +124,24 @@ class ResourceEntry:
Attributes:
uri: Resource URI (e.g., ``"file:///path/to/resource"``).
name: Human-readable resource name.
+ title: Optional display title.
description: Optional description.
mime_type: MIME type of the resource content.
+ size: Optional byte size reported by the provider.
+ annotations: Optional MCP annotations.
+ meta: Optional provider metadata.
+ server: Configured MCP server/client name.
"""
uri: str
name: str = ""
description: str = ""
mime_type: str = ""
+ title: str = ""
+ size: int | None = None
+ annotations: JsonObject | None = None
+ meta: JsonObject | None = None
+ server: str = ""
@dataclass(frozen=True, slots=True)
@@ -131,7 +178,7 @@ class ResourceContent:
uri: str
mime_type: str | None = None
- meta: dict[str, Any] | None = None
+ meta: JsonObject | None = None
@dataclass(frozen=True, slots=True)
@@ -177,6 +224,8 @@ class ResourceTemplateEntry:
description: Optional description.
mime_type: MIME type of expanded resources.
annotations: Optional MCP annotations dict.
+ meta: Optional provider metadata.
+ server: Configured MCP server/client name.
"""
uri_template: str
@@ -184,7 +233,67 @@ class ResourceTemplateEntry:
title: str = ""
description: str = ""
mime_type: str = ""
- annotations: dict[str, Any] | None = None
+ annotations: JsonObject | None = None
+ meta: JsonObject | None = None
+ server: str = ""
+
+
+@dataclass(frozen=True, slots=True)
+class ResourceError:
+ """Structured error returned by a resource provider."""
+
+ code: str
+ message: str
+ retryable: bool = False
+ suggestion: str = ""
+
+
+@dataclass(frozen=True, slots=True)
+class McpResourceListResult:
+ """One host-aggregated page of MCP resources."""
+
+ summary: str
+ resources: list[ResourceEntry] = field(default_factory=list)
+ next_cursor: str | None = None
+ errors: list[ResourceError] = field(default_factory=list)
+
+
+@dataclass(frozen=True, slots=True)
+class McpResourceTemplateListResult:
+ """One host-aggregated page of MCP resource templates."""
+
+ summary: str
+ templates: list[ResourceTemplateEntry] = field(default_factory=list)
+ next_cursor: str | None = None
+ errors: list[ResourceError] = field(default_factory=list)
+
+
+@dataclass(frozen=True, slots=True)
+class McpResourceReadResult:
+ """Structured result for a single MCP resource read."""
+
+ summary: str
+ uri: str
+ contents: list[JsonObject] = field(default_factory=list)
+ truncated: bool = False
+ original_char_count: int | None = None
+ errors: list[ResourceError] = field(default_factory=list)
+
+
+@dataclass(frozen=True, slots=True)
+class McpResourceListPage:
+ """Single upstream MCP resources/list page."""
+
+ entries: list[ResourceEntry] = field(default_factory=list)
+ next_cursor: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class McpResourceTemplateListPage:
+ """Single upstream MCP resources/templates/list page."""
+
+ entries: list[ResourceTemplateEntry] = field(default_factory=list)
+ next_cursor: str | None = None
@dataclass(frozen=True, slots=True)
@@ -286,6 +395,43 @@ async def call_tool(self, name: str, args: dict[str, Any]) -> ToolResult:
...
+class UriSchemeMismatchError(ValueError):
+ """Raised when a provider receives a URI whose scheme it does not own.
+
+ Attributes:
+ scheme: The URI scheme that was not recognized.
+ provider_name: The name of the provider that rejected the URI.
+ uri: The full URI that was rejected.
+ """
+
+ def __init__(self, scheme: str, provider_name: str, uri: str) -> None:
+ self.scheme = scheme
+ self.provider_name = provider_name
+ self.uri = uri
+ super().__init__(
+ f"Provider '{provider_name}' does not own URI scheme '{scheme}' for URI: {uri}"
+ )
+
+
+class UriSchemeConflictError(ValueError):
+ """Raised when two providers claim the same URI scheme.
+
+ Attributes:
+ scheme: The URI scheme with conflicting claims.
+ existing_provider: The provider already registered for this scheme.
+ conflicting_provider: The provider attempting to register.
+ """
+
+ def __init__(self, scheme: str, existing_provider: str, conflicting_provider: str) -> None:
+ self.scheme = scheme
+ self.existing_provider = existing_provider
+ self.conflicting_provider = conflicting_provider
+ super().__init__(
+ f"URI scheme '{scheme}' is already claimed by "
+ f"'{existing_provider}'; cannot register '{conflicting_provider}'"
+ )
+
+
@runtime_checkable
class ResourceAccess(Protocol):
"""Protocol for accessing MCP resources.
@@ -295,6 +441,17 @@ class ResourceAccess(Protocol):
and existence checking.
"""
+ @property
+ def owned_schemes(self) -> frozenset[str]:
+ """URI schemes this provider authoritatively handles.
+
+ Returns:
+ ``frozenset`` of URI scheme strings (e.g., ``{"viking"}``).
+ An empty set (default) means the provider handles opaque
+ URIs and is consulted for unregistered schemes.
+ """
+ return frozenset()
+
async def list_resources(self) -> Sequence[ResourceEntry]:
"""List available MCP resources.
@@ -352,19 +509,41 @@ async def complete_resource_template(
argument: CompletionArgument,
context: dict[str, str] | None = None,
) -> CompletionResult:
- """Complete a resource template parameter.
+ """Complete a resource template parameter."""
+ ...
- Args:
- uri_template: The URI template to complete.
- argument: The argument being completed.
- context: Optional context arguments.
- Returns:
- ``CompletionResult`` with suggestion values.
+@runtime_checkable
+class McpResourceProvider(Protocol):
+ """Protocol for host-side MCP Resource catalog and reads."""
- Raises:
- NotImplementedError: If completion is not supported.
- """
+ @property
+ def server_name(self) -> str:
+ """Return the stable configured MCP server identifier."""
+ ...
+
+ async def supports_resources(self) -> bool:
+ """Return whether the upstream server declared Resource capability."""
+ ...
+
+ async def list_resources(self) -> Sequence[ResourceEntry]:
+ """Read all resources for compatibility Host catalog consumers."""
+ ...
+
+ async def list_resources_page(self, cursor: str | None = None) -> McpResourceListPage:
+ """Read one upstream resources/list page."""
+ ...
+
+ async def list_resource_templates_page(
+ self, cursor: str | None = None
+ ) -> McpResourceTemplateListPage:
+ """Read one upstream resources/templates/list page."""
+ ...
+
+ async def read_mcp_resource(
+ self, uri: str
+ ) -> list[TextResourceContent | BlobResourceContent] | None:
+ """Read an MCP resource by opaque URI."""
...
diff --git a/src/wolfharness/capabilities/resource_resolver.py b/src/wolfharness/capabilities/resource_resolver.py
index 138917fdd..e4eafb56f 100644
--- a/src/wolfharness/capabilities/resource_resolver.py
+++ b/src/wolfharness/capabilities/resource_resolver.py
@@ -15,6 +15,7 @@
from wolfharness.capabilities.resource_protocols import (
BlobResourceContent,
TextResourceContent,
+ UriSchemeMismatchError,
)
from wolfharness.skills.uri_resolver import ResolvedSkillURI, _name_alternatives
@@ -23,6 +24,7 @@
from pydantic_ai.messages import UserContent
from wolfharness.capabilities.resource_protocols import ResourceAccess, SkillResource
+ from wolfharness.capabilities.uri_scheme_registry import UriSchemeRegistry
def _truncate_text(text: str, max_chars: int) -> str:
@@ -123,6 +125,90 @@ async def _resolve_skill_reference(
return None
+def _filter_by_client_name(
+ resource_caps: list[ResourceAccess],
+ client_name: str,
+) -> list[ResourceAccess] | None:
+ """Filter resource caps to only those matching ``client_name``.
+
+ Returns a filtered list, or ``None`` if no caps matched.
+ """
+ identified_caps = [
+ resource_cap
+ for resource_cap in resource_caps
+ if getattr(resource_cap, "server_name", None) is not None
+ ]
+ if not identified_caps:
+ return resource_caps
+ selected_caps = [
+ resource_cap
+ for resource_cap in identified_caps
+ if getattr(resource_cap, "server_name", None) == client_name
+ ]
+ return selected_caps or None
+
+
+async def _resolve_skill_uri(
+ uri: str,
+ skill_caps: list[SkillResource],
+ max_text_chars: int,
+) -> list[UserContent] | None:
+ """Resolve a ``skill://`` URI and return its content.
+
+ Handles both reference files (``skill://name/path/to/file.md``) and
+ main skill content (``skill://name``, which reads SKILL.md).
+
+ Args:
+ uri: The ``skill://`` URI to resolve.
+ skill_caps: List of ``SkillResource`` providers.
+ max_text_chars: Maximum text characters before truncation.
+
+ Returns:
+ Content items if the skill was found, or ``None`` if not.
+ """
+ if not uri.startswith("skill://"):
+ return None
+ resolved = ResolvedSkillURI.parse(uri)
+ skill_name = resolved.skill_name
+
+ # If the URI contains a reference path, read the reference file.
+ # Exception: "SKILL.md" (case-insensitive) is the skill's main file —
+ # use read_skill() for backward compatibility and virtual skill support.
+ if resolved.reference_path is not None and resolved.reference_path.upper() != "SKILL.MD":
+ try:
+ ref_content = await _resolve_skill_reference(
+ skill_caps, skill_name, resolved.reference_path
+ )
+ except Exception: # noqa: BLE001
+ logfire.exception(
+ "Failed to read skill reference '{skill_name}/{ref}'",
+ skill_name=skill_name,
+ ref=resolved.reference_path,
+ )
+ return None
+ if ref_content is not None:
+ truncated = _truncate_text(ref_content, max_text_chars)
+ return [f'