Skip to content

Commit 1005106

Browse files
feat(mcp): expose list_resources, list_resource_templates, and read_resource on MCPServer (#2721)
1 parent 34ff848 commit 1005106

File tree

5 files changed

+356
-2
lines changed

5 files changed

+356
-2
lines changed

src/agents/mcp/server.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@
2222
from mcp.client.streamable_http import GetSessionIdCallback, streamablehttp_client
2323
from mcp.shared.exceptions import McpError
2424
from mcp.shared.message import SessionMessage
25-
from mcp.types import CallToolResult, GetPromptResult, InitializeResult, ListPromptsResult
25+
from mcp.types import (
26+
CallToolResult,
27+
GetPromptResult,
28+
InitializeResult,
29+
ListPromptsResult,
30+
ListResourcesResult,
31+
ListResourceTemplatesResult,
32+
ReadResourceResult,
33+
)
2634
from typing_extensions import NotRequired, TypedDict
2735

2836
from ..exceptions import UserError
@@ -192,6 +200,63 @@ async def get_prompt(
192200
"""Get a specific prompt from the server."""
193201
pass
194202

203+
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
204+
"""List the resources available on the server.
205+
206+
Args:
207+
cursor: An opaque pagination cursor returned in a previous
208+
:class:`~mcp.types.ListResourcesResult` as ``nextCursor``. Pass it
209+
here to fetch the next page of results. ``None`` fetches the first
210+
page.
211+
212+
Returns a :class:`~mcp.types.ListResourcesResult`. When the result contains
213+
a ``nextCursor`` field, call this method again with that cursor to retrieve
214+
the next page. Subclasses that do not support resources may leave this
215+
unimplemented; it will raise :exc:`NotImplementedError` at call time.
216+
"""
217+
raise NotImplementedError(
218+
f"MCP server '{self.name}' does not support list_resources. "
219+
"Override this method in your server implementation."
220+
)
221+
222+
async def list_resource_templates(
223+
self, cursor: str | None = None
224+
) -> ListResourceTemplatesResult:
225+
"""List the resource templates available on the server.
226+
227+
Args:
228+
cursor: An opaque pagination cursor returned in a previous
229+
:class:`~mcp.types.ListResourceTemplatesResult` as ``nextCursor``.
230+
Pass it here to fetch the next page of results. ``None`` fetches
231+
the first page.
232+
233+
Returns a :class:`~mcp.types.ListResourceTemplatesResult`. When the result
234+
contains a ``nextCursor`` field, call this method again with that cursor to
235+
retrieve the next page. Subclasses that do not support resource templates
236+
may leave this unimplemented; it will raise :exc:`NotImplementedError` at
237+
call time.
238+
"""
239+
raise NotImplementedError(
240+
f"MCP server '{self.name}' does not support list_resource_templates. "
241+
"Override this method in your server implementation."
242+
)
243+
244+
async def read_resource(self, uri: str) -> ReadResourceResult:
245+
"""Read the contents of a specific resource by URI.
246+
247+
Args:
248+
uri: The URI of the resource to read. See :class:`~pydantic.networks.AnyUrl`
249+
for the supported URI formats.
250+
251+
Returns a :class:`~mcp.types.ReadResourceResult`. Subclasses that do not
252+
support resources may leave this unimplemented; it will raise
253+
:exc:`NotImplementedError` at call time.
254+
"""
255+
raise NotImplementedError(
256+
f"MCP server '{self.name}' does not support read_resource. "
257+
"Override this method in your server implementation."
258+
)
259+
195260
@staticmethod
196261
def _normalize_needs_approval(
197262
*,
@@ -708,6 +773,39 @@ async def get_prompt(
708773
assert session is not None
709774
return await self._maybe_serialize_request(lambda: session.get_prompt(name, arguments))
710775

776+
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
777+
"""List the resources available on the server."""
778+
if not self.session:
779+
raise UserError("Server not initialized. Make sure you call `connect()` first.")
780+
session = self.session
781+
assert session is not None
782+
return await self._maybe_serialize_request(lambda: session.list_resources(cursor))
783+
784+
async def list_resource_templates(
785+
self, cursor: str | None = None
786+
) -> ListResourceTemplatesResult:
787+
"""List the resource templates available on the server."""
788+
if not self.session:
789+
raise UserError("Server not initialized. Make sure you call `connect()` first.")
790+
session = self.session
791+
assert session is not None
792+
return await self._maybe_serialize_request(lambda: session.list_resource_templates(cursor))
793+
794+
async def read_resource(self, uri: str) -> ReadResourceResult:
795+
"""Read the contents of a specific resource by URI.
796+
797+
Args:
798+
uri: The URI of the resource to read. See :class:`~pydantic.networks.AnyUrl`
799+
for the supported URI formats.
800+
"""
801+
if not self.session:
802+
raise UserError("Server not initialized. Make sure you call `connect()` first.")
803+
session = self.session
804+
assert session is not None
805+
from pydantic import AnyUrl
806+
807+
return await self._maybe_serialize_request(lambda: session.read_resource(AnyUrl(uri)))
808+
711809
async def cleanup(self):
712810
"""Cleanup the server."""
713811
async with self._cleanup_lock:

tests/mcp/helpers.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
Content,
1212
GetPromptResult,
1313
ListPromptsResult,
14+
ListResourcesResult,
15+
ListResourceTemplatesResult,
1416
PromptMessage,
17+
ReadResourceResult,
1518
TextContent,
1619
)
1720

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

144+
async def list_resources(self, cursor: str | None = None) -> ListResourcesResult:
145+
"""Return empty list of resources for fake server."""
146+
return ListResourcesResult(resources=[])
147+
148+
async def list_resource_templates(
149+
self, cursor: str | None = None
150+
) -> ListResourceTemplatesResult:
151+
"""Return empty list of resource templates for fake server."""
152+
return ListResourceTemplatesResult(resourceTemplates=[])
153+
154+
async def read_resource(self, uri: str) -> ReadResourceResult:
155+
"""Return empty resource contents for fake server."""
156+
return ReadResourceResult(contents=[])
157+
141158
@property
142159
def name(self) -> str:
143160
return self._server_name

tests/mcp/test_mcp_resources.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Tests for MCP server list_resources, list_resource_templates, and read_resource."""
2+
3+
from unittest.mock import AsyncMock, MagicMock
4+
5+
import pytest
6+
from mcp.types import (
7+
ListResourcesResult,
8+
ListResourceTemplatesResult,
9+
ReadResourceResult,
10+
Resource,
11+
ResourceTemplate,
12+
TextResourceContents,
13+
)
14+
from pydantic import AnyUrl
15+
16+
from agents.mcp import MCPServerStreamableHttp
17+
18+
19+
@pytest.fixture
20+
def server():
21+
return MCPServerStreamableHttp(params={"url": "http://localhost:8000/mcp"})
22+
23+
24+
@pytest.mark.asyncio
25+
async def test_list_resources_raises_when_not_connected(server: MCPServerStreamableHttp):
26+
"""list_resources raises UserError when server has not been connected."""
27+
from agents.exceptions import UserError
28+
29+
with pytest.raises(UserError, match="Server not initialized"):
30+
await server.list_resources()
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_list_resource_templates_raises_when_not_connected(server: MCPServerStreamableHttp):
35+
"""list_resource_templates raises UserError when server has not been connected."""
36+
from agents.exceptions import UserError
37+
38+
with pytest.raises(UserError, match="Server not initialized"):
39+
await server.list_resource_templates()
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_read_resource_raises_when_not_connected(server: MCPServerStreamableHttp):
44+
"""read_resource raises UserError when server has not been connected."""
45+
from agents.exceptions import UserError
46+
47+
with pytest.raises(UserError, match="Server not initialized"):
48+
await server.read_resource("file:///etc/hosts")
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_list_resources_returns_result(server: MCPServerStreamableHttp):
53+
"""list_resources delegates to the underlying MCP session."""
54+
mock_session = MagicMock()
55+
expected = ListResourcesResult(
56+
resources=[
57+
Resource(uri=AnyUrl("file:///readme.md"), name="readme.md", mimeType="text/markdown"),
58+
]
59+
)
60+
mock_session.list_resources = AsyncMock(return_value=expected)
61+
server.session = mock_session
62+
63+
result = await server.list_resources()
64+
65+
assert result is expected
66+
mock_session.list_resources.assert_awaited_once_with(None)
67+
68+
69+
@pytest.mark.asyncio
70+
async def test_list_resources_forwards_cursor(server: MCPServerStreamableHttp):
71+
"""list_resources forwards the cursor argument for pagination."""
72+
mock_session = MagicMock()
73+
page2 = ListResourcesResult(resources=[])
74+
mock_session.list_resources = AsyncMock(return_value=page2)
75+
server.session = mock_session
76+
77+
result = await server.list_resources(cursor="tok_abc")
78+
79+
assert result is page2
80+
mock_session.list_resources.assert_awaited_once_with("tok_abc")
81+
82+
83+
@pytest.mark.asyncio
84+
async def test_list_resource_templates_returns_result(server: MCPServerStreamableHttp):
85+
"""list_resource_templates delegates to the underlying MCP session."""
86+
mock_session = MagicMock()
87+
expected = ListResourceTemplatesResult(
88+
resourceTemplates=[
89+
ResourceTemplate(uriTemplate="file:///{path}", name="file"),
90+
]
91+
)
92+
mock_session.list_resource_templates = AsyncMock(return_value=expected)
93+
server.session = mock_session
94+
95+
result = await server.list_resource_templates()
96+
97+
assert result is expected
98+
mock_session.list_resource_templates.assert_awaited_once_with(None)
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_list_resource_templates_forwards_cursor(server: MCPServerStreamableHttp):
103+
"""list_resource_templates forwards the cursor argument for pagination."""
104+
mock_session = MagicMock()
105+
page2 = ListResourceTemplatesResult(resourceTemplates=[])
106+
mock_session.list_resource_templates = AsyncMock(return_value=page2)
107+
server.session = mock_session
108+
109+
result = await server.list_resource_templates(cursor="tok_xyz")
110+
111+
assert result is page2
112+
mock_session.list_resource_templates.assert_awaited_once_with("tok_xyz")
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_read_resource_returns_result(server: MCPServerStreamableHttp):
117+
"""read_resource delegates to the underlying MCP session with the given URI."""
118+
mock_session = MagicMock()
119+
uri = "file:///readme.md"
120+
expected = ReadResourceResult(
121+
contents=[
122+
TextResourceContents(uri=AnyUrl(uri), text="# Hello", mimeType="text/markdown"),
123+
]
124+
)
125+
mock_session.read_resource = AsyncMock(return_value=expected)
126+
server.session = mock_session
127+
128+
result = await server.read_resource(uri)
129+
130+
assert result is expected
131+
mock_session.read_resource.assert_awaited_once_with(AnyUrl(uri))
132+
133+
134+
@pytest.mark.asyncio
135+
async def test_base_methods_raise_not_implemented():
136+
"""Bare MCPServer subclasses that don't override resource methods get NotImplementedError."""
137+
from mcp.types import CallToolResult, GetPromptResult, ListPromptsResult
138+
139+
from agents.mcp import MCPServer
140+
141+
class MinimalServer(MCPServer):
142+
"""Minimal subclass implementing only the truly abstract methods."""
143+
144+
@property
145+
def name(self) -> str:
146+
return "minimal"
147+
148+
async def connect(self) -> None:
149+
pass
150+
151+
async def cleanup(self) -> None:
152+
pass
153+
154+
async def list_tools(self, run_context=None, agent=None):
155+
return []
156+
157+
async def call_tool(self, tool_name, tool_arguments, run_context=None, agent=None):
158+
return CallToolResult(content=[])
159+
160+
async def list_prompts(self):
161+
return ListPromptsResult(prompts=[])
162+
163+
async def get_prompt(self, name, arguments=None):
164+
return GetPromptResult(messages=[])
165+
166+
s = MinimalServer()
167+
168+
with pytest.raises(NotImplementedError, match="list_resources"):
169+
await s.list_resources()
170+
171+
with pytest.raises(NotImplementedError, match="list_resource_templates"):
172+
await s.list_resource_templates()
173+
174+
with pytest.raises(NotImplementedError, match="read_resource"):
175+
await s.read_resource("file:///test.txt")

0 commit comments

Comments
 (0)