Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: standardize resource response format #186

Merged
merged 1 commit into from
Feb 4, 2025
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
11 changes: 11 additions & 0 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,17 @@ async def list_resources(self) -> types.ListResourcesResult:
types.ListResourcesResult,
)

async def list_resource_templates(self) -> types.ListResourceTemplatesResult:
"""Send a resources/templates/list request."""
return await self.send_request(
types.ClientRequest(
types.ListResourceTemplatesRequest(
method="resources/templates/list",
)
),
types.ListResourceTemplatesResult,
)

async def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult:
"""Send a resources/read request."""
return await self.send_request(
Expand Down
118 changes: 118 additions & 0 deletions tests/issues/test_141_resource_templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import pytest
from pydantic import AnyUrl

from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import (
create_connected_server_and_client_session as client_session,
)
from mcp.types import (
ListResourceTemplatesResult,
TextResourceContents,
)


@pytest.mark.anyio
async def test_resource_template_edge_cases():
"""Test server-side resource template validation"""
mcp = FastMCP("Demo")

# Test case 1: Template with multiple parameters
@mcp.resource("resource://users/{user_id}/posts/{post_id}")
def get_user_post(user_id: str, post_id: str) -> str:
return f"Post {post_id} by user {user_id}"

# Test case 2: Template with optional parameter (should fail)
with pytest.raises(ValueError, match="Mismatch between URI parameters"):

@mcp.resource("resource://users/{user_id}/profile")
Copy link
Contributor

Choose a reason for hiding this comment

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

[q] is this because it should be optional in the template also (i.e. as a query param)?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, it should match the numbedr of arguments. If you dont want an argument it shouldnt be specified in the resource.

def get_user_profile(user_id: str, optional_param: str | None = None) -> str:
return f"Profile for user {user_id}"

# Test case 3: Template with mismatched parameters
with pytest.raises(ValueError, match="Mismatch between URI parameters"):

@mcp.resource("resource://users/{user_id}/profile")
def get_user_profile_mismatch(different_param: str) -> str:
return f"Profile for user {different_param}"

# Test case 4: Template with extra function parameters
with pytest.raises(ValueError, match="Mismatch between URI parameters"):

@mcp.resource("resource://users/{user_id}/profile")
def get_user_profile_extra(user_id: str, extra_param: str) -> str:
return f"Profile for user {user_id}"

# Test case 5: Template with missing function parameters
with pytest.raises(ValueError, match="Mismatch between URI parameters"):

@mcp.resource("resource://users/{user_id}/profile/{section}")
def get_user_profile_missing(user_id: str) -> str:
return f"Profile for user {user_id}"

# Verify valid template works
result = await mcp.read_resource("resource://users/123/posts/456")
assert result.content == "Post 456 by user 123"
assert result.mime_type == "text/plain"

# Verify invalid parameters raise error
with pytest.raises(ValueError, match="Unknown resource"):
await mcp.read_resource("resource://users/123/posts") # Missing post_id

with pytest.raises(ValueError, match="Unknown resource"):
await mcp.read_resource(
"resource://users/123/posts/456/extra"
) # Extra path component


@pytest.mark.anyio
async def test_resource_template_client_interaction():
"""Test client-side resource template interaction"""
mcp = FastMCP("Demo")

# Register some templated resources
@mcp.resource("resource://users/{user_id}/posts/{post_id}")
def get_user_post(user_id: str, post_id: str) -> str:
return f"Post {post_id} by user {user_id}"

@mcp.resource("resource://users/{user_id}/profile")
def get_user_profile(user_id: str) -> str:
return f"Profile for user {user_id}"

async with client_session(mcp._mcp_server) as session:
# Initialize the session
await session.initialize()

# List available resources
resources = await session.list_resource_templates()
assert isinstance(resources, ListResourceTemplatesResult)
assert len(resources.resourceTemplates) == 2

# Verify resource templates are listed correctly
templates = [r.uriTemplate for r in resources.resourceTemplates]
assert "resource://users/{user_id}/posts/{post_id}" in templates
assert "resource://users/{user_id}/profile" in templates

# Read a resource with valid parameters
result = await session.read_resource(AnyUrl("resource://users/123/posts/456"))
contents = result.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.text == "Post 456 by user 123"
assert contents.mimeType == "text/plain"

# Read another resource with valid parameters
result = await session.read_resource(AnyUrl("resource://users/789/profile"))
contents = result.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.text == "Profile for user 789"
assert contents.mimeType == "text/plain"

# Verify invalid resource URIs raise appropriate errors
with pytest.raises(Exception): # Specific exception type may vary
await session.read_resource(
AnyUrl("resource://users/123/posts")
) # Missing post_id

with pytest.raises(Exception): # Specific exception type may vary
await session.read_resource(
AnyUrl("resource://users/123/invalid")
) # Invalid template