Skip to content

Commit 3bc34ac

Browse files
[FEATURE] add MCP resource operations in MCP Tools (#1117)
* feat(tools): Add MCP resource operations * feat(tools): Add MCP resource operations * tests: add integ tests for mcp resources * fix: broken merge --------- Co-authored-by: Dean Schmigelski <dbschmigelski+github@gmail.com>
1 parent 50e5e74 commit 3bc34ac

File tree

4 files changed

+388
-2
lines changed

4 files changed

+388
-2
lines changed

src/strands/tools/mcp/mcp_client.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,20 @@
2121
import anyio
2222
from mcp import ClientSession, ListToolsResult
2323
from mcp.client.session import ElicitationFnT
24-
from mcp.types import BlobResourceContents, GetPromptResult, ListPromptsResult, TextResourceContents
24+
from mcp.types import (
25+
BlobResourceContents,
26+
GetPromptResult,
27+
ListPromptsResult,
28+
ListResourcesResult,
29+
ListResourceTemplatesResult,
30+
ReadResourceResult,
31+
TextResourceContents,
32+
)
2533
from mcp.types import CallToolResult as MCPCallToolResult
2634
from mcp.types import EmbeddedResource as MCPEmbeddedResource
2735
from mcp.types import ImageContent as MCPImageContent
2836
from mcp.types import TextContent as MCPTextContent
37+
from pydantic import AnyUrl
2938
from typing_extensions import Protocol, TypedDict
3039

3140
from ...experimental.tools import ToolProvider
@@ -449,6 +458,82 @@ async def _get_prompt_async() -> GetPromptResult:
449458

450459
return get_prompt_result
451460

461+
def list_resources_sync(self, pagination_token: Optional[str] = None) -> ListResourcesResult:
462+
"""Synchronously retrieves the list of available resources from the MCP server.
463+
464+
This method calls the asynchronous list_resources method on the MCP session
465+
and returns the raw ListResourcesResult with pagination support.
466+
467+
Args:
468+
pagination_token: Optional token for pagination
469+
470+
Returns:
471+
ListResourcesResult: The raw MCP response containing resources and pagination info
472+
"""
473+
self._log_debug_with_thread("listing MCP resources synchronously")
474+
if not self._is_session_active():
475+
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
476+
477+
async def _list_resources_async() -> ListResourcesResult:
478+
return await cast(ClientSession, self._background_thread_session).list_resources(cursor=pagination_token)
479+
480+
list_resources_result: ListResourcesResult = self._invoke_on_background_thread(_list_resources_async()).result()
481+
self._log_debug_with_thread("received %d resources from MCP server", len(list_resources_result.resources))
482+
483+
return list_resources_result
484+
485+
def read_resource_sync(self, uri: AnyUrl | str) -> ReadResourceResult:
486+
"""Synchronously reads a resource from the MCP server.
487+
488+
Args:
489+
uri: The URI of the resource to read
490+
491+
Returns:
492+
ReadResourceResult: The resource content from the MCP server
493+
"""
494+
self._log_debug_with_thread("reading MCP resource synchronously: %s", uri)
495+
if not self._is_session_active():
496+
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
497+
498+
async def _read_resource_async() -> ReadResourceResult:
499+
# Convert string to AnyUrl if needed
500+
resource_uri = AnyUrl(uri) if isinstance(uri, str) else uri
501+
return await cast(ClientSession, self._background_thread_session).read_resource(resource_uri)
502+
503+
read_resource_result: ReadResourceResult = self._invoke_on_background_thread(_read_resource_async()).result()
504+
self._log_debug_with_thread("received resource content from MCP server")
505+
506+
return read_resource_result
507+
508+
def list_resource_templates_sync(self, pagination_token: Optional[str] = None) -> ListResourceTemplatesResult:
509+
"""Synchronously retrieves the list of available resource templates from the MCP server.
510+
511+
Resource templates define URI patterns that can be used to access resources dynamically.
512+
513+
Args:
514+
pagination_token: Optional token for pagination
515+
516+
Returns:
517+
ListResourceTemplatesResult: The raw MCP response containing resource templates and pagination info
518+
"""
519+
self._log_debug_with_thread("listing MCP resource templates synchronously")
520+
if not self._is_session_active():
521+
raise MCPClientInitializationError(CLIENT_SESSION_NOT_RUNNING_ERROR_MESSAGE)
522+
523+
async def _list_resource_templates_async() -> ListResourceTemplatesResult:
524+
return await cast(ClientSession, self._background_thread_session).list_resource_templates(
525+
cursor=pagination_token
526+
)
527+
528+
list_resource_templates_result: ListResourceTemplatesResult = self._invoke_on_background_thread(
529+
_list_resource_templates_async()
530+
).result()
531+
self._log_debug_with_thread(
532+
"received %d resource templates from MCP server", len(list_resource_templates_result.resourceTemplates)
533+
)
534+
535+
return list_resource_templates_result
536+
452537
def call_tool_sync(
453538
self,
454539
tool_use_id: str,

tests/strands/tools/mcp/test_mcp_client.py

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@
55
import pytest
66
from mcp import ListToolsResult
77
from mcp.types import CallToolResult as MCPCallToolResult
8-
from mcp.types import GetPromptResult, ListPromptsResult, Prompt, PromptMessage
8+
from mcp.types import (
9+
GetPromptResult,
10+
ListPromptsResult,
11+
ListResourcesResult,
12+
ListResourceTemplatesResult,
13+
Prompt,
14+
PromptMessage,
15+
ReadResourceResult,
16+
Resource,
17+
ResourceTemplate,
18+
TextResourceContents,
19+
)
920
from mcp.types import TextContent as MCPTextContent
1021
from mcp.types import Tool as MCPTool
22+
from pydantic import AnyUrl
1123

1224
from strands.tools.mcp import MCPClient
1325
from strands.tools.mcp.mcp_types import MCPToolResult
@@ -772,3 +784,143 @@ def test_call_tool_sync_with_meta_and_structured_content(mock_transport, mock_se
772784
assert result["metadata"] == metadata
773785
assert "structuredContent" in result
774786
assert result["structuredContent"] == structured_content
787+
788+
789+
# Resource Tests - Sync Methods
790+
791+
792+
def test_list_resources_sync(mock_transport, mock_session):
793+
"""Test that list_resources_sync correctly retrieves resources."""
794+
mock_resource = Resource(
795+
uri=AnyUrl("file://documents/test.txt"), name="test.txt", description="A test document", mimeType="text/plain"
796+
)
797+
mock_session.list_resources.return_value = ListResourcesResult(resources=[mock_resource])
798+
799+
with MCPClient(mock_transport["transport_callable"]) as client:
800+
result = client.list_resources_sync()
801+
802+
mock_session.list_resources.assert_called_once_with(cursor=None)
803+
assert len(result.resources) == 1
804+
assert result.resources[0].name == "test.txt"
805+
assert str(result.resources[0].uri) == "file://documents/test.txt"
806+
assert result.nextCursor is None
807+
808+
809+
def test_list_resources_sync_with_pagination_token(mock_transport, mock_session):
810+
"""Test that list_resources_sync correctly passes pagination token and returns next cursor."""
811+
mock_resource = Resource(
812+
uri=AnyUrl("file://documents/test.txt"), name="test.txt", description="A test document", mimeType="text/plain"
813+
)
814+
mock_session.list_resources.return_value = ListResourcesResult(resources=[mock_resource], nextCursor="next_page")
815+
816+
with MCPClient(mock_transport["transport_callable"]) as client:
817+
result = client.list_resources_sync(pagination_token="current_page")
818+
819+
mock_session.list_resources.assert_called_once_with(cursor="current_page")
820+
assert len(result.resources) == 1
821+
assert result.resources[0].name == "test.txt"
822+
assert result.nextCursor == "next_page"
823+
824+
825+
def test_list_resources_sync_session_not_active():
826+
"""Test that list_resources_sync raises an error when session is not active."""
827+
client = MCPClient(MagicMock())
828+
829+
with pytest.raises(MCPClientInitializationError, match="client session is not running"):
830+
client.list_resources_sync()
831+
832+
833+
def test_read_resource_sync(mock_transport, mock_session):
834+
"""Test that read_resource_sync correctly reads a resource."""
835+
mock_content = TextResourceContents(
836+
uri=AnyUrl("file://documents/test.txt"), text="Resource content", mimeType="text/plain"
837+
)
838+
mock_session.read_resource.return_value = ReadResourceResult(contents=[mock_content])
839+
840+
with MCPClient(mock_transport["transport_callable"]) as client:
841+
result = client.read_resource_sync("file://documents/test.txt")
842+
843+
# Verify the session method was called
844+
mock_session.read_resource.assert_called_once()
845+
# Check the URI argument (it will be wrapped as AnyUrl)
846+
call_args = mock_session.read_resource.call_args[0]
847+
assert str(call_args[0]) == "file://documents/test.txt"
848+
849+
assert len(result.contents) == 1
850+
assert result.contents[0].text == "Resource content"
851+
852+
853+
def test_read_resource_sync_with_anyurl(mock_transport, mock_session):
854+
"""Test that read_resource_sync correctly handles AnyUrl input."""
855+
mock_content = TextResourceContents(
856+
uri=AnyUrl("file://documents/test.txt"), text="Resource content", mimeType="text/plain"
857+
)
858+
mock_session.read_resource.return_value = ReadResourceResult(contents=[mock_content])
859+
860+
with MCPClient(mock_transport["transport_callable"]) as client:
861+
uri = AnyUrl("file://documents/test.txt")
862+
result = client.read_resource_sync(uri)
863+
864+
mock_session.read_resource.assert_called_once()
865+
call_args = mock_session.read_resource.call_args[0]
866+
assert str(call_args[0]) == "file://documents/test.txt"
867+
868+
assert len(result.contents) == 1
869+
assert result.contents[0].text == "Resource content"
870+
871+
872+
def test_read_resource_sync_session_not_active():
873+
"""Test that read_resource_sync raises an error when session is not active."""
874+
client = MCPClient(MagicMock())
875+
876+
with pytest.raises(MCPClientInitializationError, match="client session is not running"):
877+
client.read_resource_sync("file://documents/test.txt")
878+
879+
880+
def test_list_resource_templates_sync(mock_transport, mock_session):
881+
"""Test that list_resource_templates_sync correctly retrieves resource templates."""
882+
mock_template = ResourceTemplate(
883+
uriTemplate="file://documents/{name}",
884+
name="document_template",
885+
description="Template for documents",
886+
mimeType="text/plain",
887+
)
888+
mock_session.list_resource_templates.return_value = ListResourceTemplatesResult(resourceTemplates=[mock_template])
889+
890+
with MCPClient(mock_transport["transport_callable"]) as client:
891+
result = client.list_resource_templates_sync()
892+
893+
mock_session.list_resource_templates.assert_called_once_with(cursor=None)
894+
assert len(result.resourceTemplates) == 1
895+
assert result.resourceTemplates[0].name == "document_template"
896+
assert result.resourceTemplates[0].uriTemplate == "file://documents/{name}"
897+
assert result.nextCursor is None
898+
899+
900+
def test_list_resource_templates_sync_with_pagination_token(mock_transport, mock_session):
901+
"""Test that list_resource_templates_sync correctly passes pagination token and returns next cursor."""
902+
mock_template = ResourceTemplate(
903+
uriTemplate="file://documents/{name}",
904+
name="document_template",
905+
description="Template for documents",
906+
mimeType="text/plain",
907+
)
908+
mock_session.list_resource_templates.return_value = ListResourceTemplatesResult(
909+
resourceTemplates=[mock_template], nextCursor="next_page"
910+
)
911+
912+
with MCPClient(mock_transport["transport_callable"]) as client:
913+
result = client.list_resource_templates_sync(pagination_token="current_page")
914+
915+
mock_session.list_resource_templates.assert_called_once_with(cursor="current_page")
916+
assert len(result.resourceTemplates) == 1
917+
assert result.resourceTemplates[0].name == "document_template"
918+
assert result.nextCursor == "next_page"
919+
920+
921+
def test_list_resource_templates_sync_session_not_active():
922+
"""Test that list_resource_templates_sync raises an error when session is not active."""
923+
client = MCPClient(MagicMock())
924+
925+
with pytest.raises(MCPClientInitializationError, match="client session is not running"):
926+
client.list_resource_templates_sync()

tests_integ/mcp/echo_server.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
"""
1717

1818
import base64
19+
import json
1920
from typing import Literal
2021

2122
from mcp.server import FastMCP
2223
from mcp.types import BlobResourceContents, CallToolResult, EmbeddedResource, TextContent, TextResourceContents
2324
from pydantic import BaseModel
2425

26+
TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
27+
2528

2629
class EchoResponse(BaseModel):
2730
"""Response model for echo with structured content."""
@@ -102,6 +105,22 @@ def get_weather(location: Literal["New York", "London", "Tokyo"] = "New York"):
102105
)
103106
]
104107

108+
# Resources
109+
@mcp.resource("test://static-text")
110+
def static_text_resource() -> str:
111+
"""A static text resource for testing"""
112+
return "This is the content of the static text resource."
113+
114+
@mcp.resource("test://static-binary")
115+
def static_binary_resource() -> bytes:
116+
"""A static binary resource (image) for testing"""
117+
return base64.b64decode(TEST_IMAGE_BASE64)
118+
119+
@mcp.resource("test://template/{id}/data")
120+
def template_resource(id: str) -> str:
121+
"""A resource template with parameter substitution"""
122+
return json.dumps({"id": id, "templateTest": True, "data": f"Data for ID: {id}"})
123+
105124
mcp.run(transport="stdio")
106125

107126

0 commit comments

Comments
 (0)