@@ -142,99 +142,6 @@ async def test_add_resource_decorator_incorrect_usage(self):
142142 def get_data (x : str ) -> str : # pragma: no cover
143143 return f"Data: { x } "
144144
145- async def test_resource_decorator_rfc6570_reserved_expansion (self ):
146- # Regression: old regex-based param extraction couldn't see `path`
147- # in `{+path}` and failed with a confusing mismatch error.
148- mcp = MCPServer ()
149-
150- @mcp .resource ("file://docs/{+path}" )
151- def read_doc (path : str ) -> str :
152- raise NotImplementedError
153-
154- templates = await mcp .list_resource_templates ()
155- assert [t .uri_template for t in templates ] == ["file://docs/{+path}" ]
156-
157- async def test_resource_decorator_rejects_malformed_template (self ):
158- mcp = MCPServer ()
159- with pytest .raises (InvalidUriTemplate , match = "Unclosed expression" ):
160- mcp .resource ("file://{name" )
161-
162- async def test_resource_optional_query_params_use_function_defaults (self ):
163- """Omitted {?...} query params should fall through to the
164- handler's Python defaults. Partial and reordered params work."""
165- mcp = MCPServer ()
166-
167- @mcp .resource ("logs://{service}{?since,level}" )
168- def tail_logs (service : str , since : str = "1h" , level : str = "info" ) -> str :
169- return f"{ service } |{ since } |{ level } "
170-
171- async with Client (mcp ) as client :
172- # No query → all defaults
173- r = await client .read_resource ("logs://api" )
174- assert isinstance (r .contents [0 ], TextResourceContents )
175- assert r .contents [0 ].text == "api|1h|info"
176-
177- # Partial query → one default
178- r = await client .read_resource ("logs://api?since=15m" )
179- assert isinstance (r .contents [0 ], TextResourceContents )
180- assert r .contents [0 ].text == "api|15m|info"
181-
182- # Reordered, both present
183- r = await client .read_resource ("logs://api?level=error&since=5m" )
184- assert isinstance (r .contents [0 ], TextResourceContents )
185- assert r .contents [0 ].text == "api|5m|error"
186-
187- # Extra param ignored
188- r = await client .read_resource ("logs://api?since=2h&utm=x" )
189- assert isinstance (r .contents [0 ], TextResourceContents )
190- assert r .contents [0 ].text == "api|2h|info"
191-
192- async def test_resource_security_default_rejects_traversal (self ):
193- mcp = MCPServer ()
194-
195- @mcp .resource ("data://items/{name}" )
196- def get_item (name : str ) -> str :
197- return f"item:{ name } "
198-
199- async with Client (mcp ) as client :
200- # Safe value passes through to the handler
201- r = await client .read_resource ("data://items/widget" )
202- assert isinstance (r .contents [0 ], TextResourceContents )
203- assert r .contents [0 ].text == "item:widget"
204-
205- # ".." as a path component is rejected by default policy
206- with pytest .raises (MCPError , match = "Unknown resource" ):
207- await client .read_resource ("data://items/.." )
208-
209- async def test_resource_security_per_resource_override (self ):
210- mcp = MCPServer ()
211-
212- @mcp .resource (
213- "git://diff/{+range}" ,
214- security = ResourceSecurity (exempt_params = {"range" }),
215- )
216- def git_diff (range : str ) -> str :
217- return f"diff:{ range } "
218-
219- async with Client (mcp ) as client :
220- # "../foo" would be rejected by default, but "range" is exempt
221- result = await client .read_resource ("git://diff/../foo" )
222- assert isinstance (result .contents [0 ], TextResourceContents )
223- assert result .contents [0 ].text == "diff:../foo"
224-
225- async def test_resource_security_server_wide_override (self ):
226- mcp = MCPServer (resource_security = ResourceSecurity (reject_path_traversal = False ))
227-
228- @mcp .resource ("data://items/{name}" )
229- def get_item (name : str ) -> str :
230- return f"item:{ name } "
231-
232- async with Client (mcp ) as client :
233- # Server-wide policy disabled traversal check; ".." now allowed
234- result = await client .read_resource ("data://items/.." )
235- assert isinstance (result .contents [0 ], TextResourceContents )
236- assert result .contents [0 ].text == "item:.."
237-
238145
239146class TestDnsRebindingProtection :
240147 """Tests for automatic DNS rebinding protection on localhost.
@@ -1227,29 +1134,6 @@ def resource_with_context(name: str, ctx: Context) -> str:
12271134 # Should have either request_id or indication that context was injected
12281135 assert "Resource test - context injected" == content .text
12291136
1230- async def test_static_resource_with_context_param_errors (self ):
1231- """A non-template URI with a Context-only handler should error
1232- at decoration time with a clear message, not silently register
1233- an unreachable resource."""
1234- mcp = MCPServer ()
1235-
1236- with pytest .raises (ValueError , match = "Context injection for static resources is not yet supported" ):
1237-
1238- @mcp .resource ("weather://current" )
1239- def current_weather (ctx : Context ) -> str :
1240- raise NotImplementedError
1241-
1242- async def test_static_resource_with_extra_params_errors (self ):
1243- """A non-template URI with non-Context params should error at
1244- decoration time."""
1245- mcp = MCPServer ()
1246-
1247- with pytest .raises (ValueError , match = "has no URI template variables" ):
1248-
1249- @mcp .resource ("data://fixed" )
1250- def get_data (name : str ) -> str :
1251- raise NotImplementedError
1252-
12531137 async def test_resource_without_context (self ):
12541138 """Test that resources without context work normally."""
12551139 mcp = MCPServer ()
@@ -1536,6 +1420,130 @@ def prompt_fn(name: str) -> str: ... # pragma: no branch
15361420 await client .get_prompt ("prompt_fn" )
15371421
15381422
1423+ async def test_resource_decorator_rfc6570_reserved_expansion ():
1424+ # Regression: old regex-based param extraction couldn't see `path`
1425+ # in `{+path}` and failed with a confusing mismatch error.
1426+ mcp = MCPServer ()
1427+
1428+ @mcp .resource ("file://docs/{+path}" )
1429+ def read_doc (path : str ) -> str :
1430+ raise NotImplementedError
1431+
1432+ templates = await mcp .list_resource_templates ()
1433+ assert [t .uri_template for t in templates ] == ["file://docs/{+path}" ]
1434+
1435+
1436+ async def test_resource_decorator_rejects_malformed_template ():
1437+ mcp = MCPServer ()
1438+ with pytest .raises (InvalidUriTemplate , match = "Unclosed expression" ):
1439+ mcp .resource ("file://{name" )
1440+
1441+
1442+ async def test_resource_optional_query_params_use_function_defaults ():
1443+ """Omitted {?...} query params should fall through to the
1444+ handler's Python defaults. Partial and reordered params work."""
1445+ mcp = MCPServer ()
1446+
1447+ @mcp .resource ("logs://{service}{?since,level}" )
1448+ def tail_logs (service : str , since : str = "1h" , level : str = "info" ) -> str :
1449+ return f"{ service } |{ since } |{ level } "
1450+
1451+ async with Client (mcp ) as client :
1452+ # No query → all defaults
1453+ r = await client .read_resource ("logs://api" )
1454+ assert isinstance (r .contents [0 ], TextResourceContents )
1455+ assert r .contents [0 ].text == "api|1h|info"
1456+
1457+ # Partial query → one default
1458+ r = await client .read_resource ("logs://api?since=15m" )
1459+ assert isinstance (r .contents [0 ], TextResourceContents )
1460+ assert r .contents [0 ].text == "api|15m|info"
1461+
1462+ # Reordered, both present
1463+ r = await client .read_resource ("logs://api?level=error&since=5m" )
1464+ assert isinstance (r .contents [0 ], TextResourceContents )
1465+ assert r .contents [0 ].text == "api|5m|error"
1466+
1467+ # Extra param ignored
1468+ r = await client .read_resource ("logs://api?since=2h&utm=x" )
1469+ assert isinstance (r .contents [0 ], TextResourceContents )
1470+ assert r .contents [0 ].text == "api|2h|info"
1471+
1472+
1473+ async def test_resource_security_default_rejects_traversal ():
1474+ mcp = MCPServer ()
1475+
1476+ @mcp .resource ("data://items/{name}" )
1477+ def get_item (name : str ) -> str :
1478+ return f"item:{ name } "
1479+
1480+ async with Client (mcp ) as client :
1481+ # Safe value passes through to the handler
1482+ r = await client .read_resource ("data://items/widget" )
1483+ assert isinstance (r .contents [0 ], TextResourceContents )
1484+ assert r .contents [0 ].text == "item:widget"
1485+
1486+ # ".." as a path component is rejected by default policy
1487+ with pytest .raises (MCPError , match = "Unknown resource" ):
1488+ await client .read_resource ("data://items/.." )
1489+
1490+
1491+ async def test_resource_security_per_resource_override ():
1492+ mcp = MCPServer ()
1493+
1494+ @mcp .resource (
1495+ "git://diff/{+range}" ,
1496+ security = ResourceSecurity (exempt_params = {"range" }),
1497+ )
1498+ def git_diff (range : str ) -> str :
1499+ return f"diff:{ range } "
1500+
1501+ async with Client (mcp ) as client :
1502+ # "../foo" would be rejected by default, but "range" is exempt
1503+ result = await client .read_resource ("git://diff/../foo" )
1504+ assert isinstance (result .contents [0 ], TextResourceContents )
1505+ assert result .contents [0 ].text == "diff:../foo"
1506+
1507+
1508+ async def test_resource_security_server_wide_override ():
1509+ mcp = MCPServer (resource_security = ResourceSecurity (reject_path_traversal = False ))
1510+
1511+ @mcp .resource ("data://items/{name}" )
1512+ def get_item (name : str ) -> str :
1513+ return f"item:{ name } "
1514+
1515+ async with Client (mcp ) as client :
1516+ # Server-wide policy disabled traversal check; ".." now allowed
1517+ result = await client .read_resource ("data://items/.." )
1518+ assert isinstance (result .contents [0 ], TextResourceContents )
1519+ assert result .contents [0 ].text == "item:.."
1520+
1521+
1522+ async def test_static_resource_with_context_param_errors ():
1523+ """A non-template URI with a Context-only handler should error
1524+ at decoration time with a clear message, not silently register
1525+ an unreachable resource."""
1526+ mcp = MCPServer ()
1527+
1528+ with pytest .raises (ValueError , match = "Context injection for static resources is not yet supported" ):
1529+
1530+ @mcp .resource ("weather://current" )
1531+ def current_weather (ctx : Context ) -> str :
1532+ raise NotImplementedError
1533+
1534+
1535+ async def test_static_resource_with_extra_params_errors ():
1536+ """A non-template URI with non-Context params should error at
1537+ decoration time."""
1538+ mcp = MCPServer ()
1539+
1540+ with pytest .raises (ValueError , match = "has no URI template variables" ):
1541+
1542+ @mcp .resource ("data://fixed" )
1543+ def get_data (name : str ) -> str :
1544+ raise NotImplementedError
1545+
1546+
15391547async def test_completion_decorator () -> None :
15401548 """Test that the completion decorator registers a working handler."""
15411549 mcp = MCPServer ()
0 commit comments