Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
54 changes: 53 additions & 1 deletion src/agents/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@
from mcp.client.streamable_http import GetSessionIdCallback, streamablehttp_client
from mcp.shared.exceptions import McpError
from mcp.shared.message import SessionMessage
from mcp.types import CallToolResult, GetPromptResult, InitializeResult, ListPromptsResult
from mcp.types import (
CallToolResult,
GetPromptResult,
InitializeResult,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ReadResourceResult,
)
from typing_extensions import NotRequired, TypedDict

from ..exceptions import UserError
Expand Down Expand Up @@ -192,6 +200,21 @@ async def get_prompt(
"""Get a specific prompt from the server."""
pass

@abc.abstractmethod
async def list_resources(self) -> ListResourcesResult:
Comment thread
seratch marked this conversation as resolved.
Outdated
"""List the resources available on the server."""
pass

@abc.abstractmethod
async def list_resource_templates(self) -> ListResourceTemplatesResult:
"""List the resource templates available on the server."""
pass

@abc.abstractmethod
async def read_resource(self, uri: str) -> ReadResourceResult:
"""Read the contents of a specific resource by URI."""
pass

@staticmethod
def _normalize_needs_approval(
*,
Expand Down Expand Up @@ -708,6 +731,35 @@ async def get_prompt(
assert session is not None
return await self._maybe_serialize_request(lambda: session.get_prompt(name, arguments))

async def list_resources(self) -> ListResourcesResult:
"""List the resources available on the server."""
if not self.session:
raise UserError("Server not initialized. Make sure you call `connect()` first.")
session = self.session
assert session is not None
return await self._maybe_serialize_request(lambda: session.list_resources())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose cursor arguments for paginated resource lists

list_resources/list_resource_templates always call the session methods with no pagination arguments, and the new public methods also expose no cursor/params input. On MCP servers that paginate these endpoints, callers can only ever retrieve the first page (despite getting a nextCursor in the result), so resource discovery becomes incomplete for larger datasets. Please forward pagination inputs through these wrappers so clients can fetch subsequent pages.

Useful? React with 👍 / 👎.


async def list_resource_templates(self) -> ListResourceTemplatesResult:
"""List the resource templates available on the server."""
if not self.session:
raise UserError("Server not initialized. Make sure you call `connect()` first.")
session = self.session
assert session is not None
return await self._maybe_serialize_request(lambda: session.list_resource_templates())

async def read_resource(self, uri: str) -> ReadResourceResult:
"""Read the contents of a specific resource by URI.

Args:
uri: The URI of the resource to read (e.g. ``file:///path/to/file.txt`` or
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you simply mention AnyUrl docstring as reference? We'd like to avoid mentioning specific examples and having our own comment, which can be outdated in the future.

``postgres://db/table/row``).
"""
if not self.session:
raise UserError("Server not initialized. Make sure you call `connect()` first.")
session = self.session
assert session is not None
return await self._maybe_serialize_request(lambda: session.read_resource(uri))

async def cleanup(self):
"""Cleanup the server."""
async with self._cleanup_lock:
Expand Down
15 changes: 15 additions & 0 deletions tests/mcp/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
Content,
GetPromptResult,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
PromptMessage,
ReadResourceResult,
TextContent,
)

Expand Down Expand Up @@ -138,6 +141,18 @@ async def get_prompt(
message = PromptMessage(role="user", content=TextContent(type="text", text=content))
return GetPromptResult(description=f"Fake prompt: {name}", messages=[message])

async def list_resources(self) -> ListResourcesResult:
"""Return empty list of resources for fake server."""
return ListResourcesResult(resources=[])

async def list_resource_templates(self) -> ListResourceTemplatesResult:
"""Return empty list of resource templates for fake server."""
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match FakeMCPServer resource method signatures to MCPServer

FakeMCPServer narrows the new interface by omitting the optional cursor parameter on list_resources and list_resource_templates. Any test or helper code that calls these through an MCPServer reference with cursor=... will raise TypeError, so pagination behavior cannot be exercised with this shared fake server.

Useful? React with 👍 / 👎.

return ListResourceTemplatesResult(resourceTemplates=[])

async def read_resource(self, uri: str) -> ReadResourceResult:
"""Return empty resource contents for fake server."""
return ReadResourceResult(contents=[])

@property
def name(self) -> str:
return self._server_name
102 changes: 102 additions & 0 deletions tests/mcp/test_mcp_resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Tests for MCP server list_resources, list_resource_templates, and read_resource."""

from unittest.mock import AsyncMock, MagicMock

import pytest
from mcp.types import (
ListResourcesResult,
ListResourceTemplatesResult,
ReadResourceResult,
Resource,
ResourceTemplate,
TextResourceContents,
)

from agents.mcp import MCPServerStreamableHttp


@pytest.fixture
def server():
return MCPServerStreamableHttp(params={"url": "http://localhost:8000/mcp"})


@pytest.mark.asyncio
async def test_list_resources_raises_when_not_connected(server: MCPServerStreamableHttp):
"""list_resources raises UserError when server has not been connected."""
from agents.exceptions import UserError

with pytest.raises(UserError, match="Server not initialized"):
await server.list_resources()


@pytest.mark.asyncio
async def test_list_resource_templates_raises_when_not_connected(server: MCPServerStreamableHttp):
"""list_resource_templates raises UserError when server has not been connected."""
from agents.exceptions import UserError

with pytest.raises(UserError, match="Server not initialized"):
await server.list_resource_templates()


@pytest.mark.asyncio
async def test_read_resource_raises_when_not_connected(server: MCPServerStreamableHttp):
"""read_resource raises UserError when server has not been connected."""
from agents.exceptions import UserError

with pytest.raises(UserError, match="Server not initialized"):
await server.read_resource("file:///etc/hosts")


@pytest.mark.asyncio
async def test_list_resources_returns_result(server: MCPServerStreamableHttp):
"""list_resources delegates to the underlying MCP session."""
mock_session = MagicMock()
expected = ListResourcesResult(
resources=[
Resource(uri="file:///readme.md", name="readme.md", mimeType="text/markdown"),
]
)
mock_session.list_resources = AsyncMock(return_value=expected)
server.session = mock_session

result = await server.list_resources()

assert result is expected
mock_session.list_resources.assert_awaited_once()


@pytest.mark.asyncio
async def test_list_resource_templates_returns_result(server: MCPServerStreamableHttp):
"""list_resource_templates delegates to the underlying MCP session."""
mock_session = MagicMock()
expected = ListResourceTemplatesResult(
resourceTemplates=[
ResourceTemplate(uriTemplate="file:///{path}", name="file"),
]
)
mock_session.list_resource_templates = AsyncMock(return_value=expected)
server.session = mock_session

result = await server.list_resource_templates()

assert result is expected
mock_session.list_resource_templates.assert_awaited_once()


@pytest.mark.asyncio
async def test_read_resource_returns_result(server: MCPServerStreamableHttp):
"""read_resource delegates to the underlying MCP session with the given URI."""
mock_session = MagicMock()
uri = "file:///readme.md"
expected = ReadResourceResult(
contents=[
TextResourceContents(uri=uri, text="# Hello", mimeType="text/markdown"),
]
)
mock_session.read_resource = AsyncMock(return_value=expected)
server.session = mock_session

result = await server.read_resource(uri)

assert result is expected
mock_session.read_resource.assert_awaited_once_with(uri)
46 changes: 45 additions & 1 deletion tests/mcp/test_mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@
from typing import Any, cast

import pytest
from mcp.types import CallToolResult, GetPromptResult, ListPromptsResult, Tool as MCPTool
from mcp.types import (
CallToolResult,
GetPromptResult,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
ReadResourceResult,
Tool as MCPTool,
)

from agents.mcp import MCPServer, MCPServerManager
from agents.run_context import RunContextWrapper
Expand Down Expand Up @@ -49,6 +57,15 @@ async def get_prompt(
) -> GetPromptResult:
raise NotImplementedError

async def list_resources(self) -> ListResourcesResult:
return ListResourcesResult(resources=[])

async def list_resource_templates(self) -> ListResourceTemplatesResult:
return ListResourceTemplatesResult(resourceTemplates=[])

async def read_resource(self, uri: str) -> ReadResourceResult:
return ReadResourceResult(contents=[])


class FlakyServer(MCPServer):
def __init__(self, failures: int) -> None:
Expand Down Expand Up @@ -90,6 +107,15 @@ async def get_prompt(
) -> GetPromptResult:
raise NotImplementedError

async def list_resources(self) -> ListResourcesResult:
return ListResourcesResult(resources=[])

async def list_resource_templates(self) -> ListResourceTemplatesResult:
return ListResourceTemplatesResult(resourceTemplates=[])

async def read_resource(self, uri: str) -> ReadResourceResult:
return ReadResourceResult(contents=[])


class CleanupAwareServer(MCPServer):
def __init__(self) -> None:
Expand Down Expand Up @@ -130,6 +156,15 @@ async def get_prompt(
) -> GetPromptResult:
raise NotImplementedError

async def list_resources(self) -> ListResourcesResult:
return ListResourcesResult(resources=[])

async def list_resource_templates(self) -> ListResourceTemplatesResult:
return ListResourceTemplatesResult(resourceTemplates=[])

async def read_resource(self, uri: str) -> ReadResourceResult:
return ReadResourceResult(contents=[])


class CancelledServer(MCPServer):
@property
Expand Down Expand Up @@ -163,6 +198,15 @@ async def get_prompt(
) -> GetPromptResult:
raise NotImplementedError

async def list_resources(self) -> ListResourcesResult:
return ListResourcesResult(resources=[])

async def list_resource_templates(self) -> ListResourceTemplatesResult:
return ListResourceTemplatesResult(resourceTemplates=[])

async def read_resource(self, uri: str) -> ReadResourceResult:
return ReadResourceResult(contents=[])


class FailingTaskBoundServer(TaskBoundServer):
@property
Expand Down
10 changes: 10 additions & 0 deletions tests/mcp/test_prompt_server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any

import pytest
from mcp.types import ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult

from agents import Agent, Runner
from agents.mcp import MCPServer, MCPToolMetaResolver
Expand Down Expand Up @@ -76,6 +77,15 @@ async def call_tool(
):
raise NotImplementedError("This fake server doesn't support tools")

async def list_resources(self) -> ListResourcesResult:
return ListResourcesResult(resources=[])

async def list_resource_templates(self) -> ListResourceTemplatesResult:
return ListResourceTemplatesResult(resourceTemplates=[])

async def read_resource(self, uri: str) -> ReadResourceResult:
return ReadResourceResult(contents=[])

@property
def name(self) -> str:
return self._server_name
Expand Down