From 5cbbc70b183ee74174bec367661f964334dd7eca Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:54:32 +0000 Subject: [PATCH] feat: wire ResourceSecurity into MCPServer configuration Adds `resource_security` to `MCPServer.__init__` and a per-resource `security` override to the `@resource()` decorator. Templates inherit the server-wide policy unless overridden. Exports `ResourceSecurity` and `DEFAULT_RESOURCE_SECURITY` from `mcp.server.mcpserver` for user configuration. Usage: # Server-wide relaxation mcp = MCPServer(resource_security=ResourceSecurity(reject_path_traversal=False)) # Per-resource exemption for non-path parameters @mcp.resource( "git://diff/{+range}", security=ResourceSecurity(exempt_params=frozenset({"range"})), ) def git_diff(range: str) -> str: ... --- src/mcp/server/mcpserver/__init__.py | 11 ++++- .../server/mcpserver/resources/__init__.py | 8 +++- .../server/mcpserver/resources/templates.py | 3 -- src/mcp/server/mcpserver/server.py | 15 ++++++- .../resources/test_resource_template.py | 6 +-- tests/server/mcpserver/test_server.py | 43 ++++++++++++++++++- 6 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 0857e38b..35c98a00 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -3,7 +3,16 @@ from mcp.types import Icon from .context import Context +from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity from .server import MCPServer from .utilities.types import Audio, Image -__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"] +__all__ = [ + "MCPServer", + "Context", + "Image", + "Audio", + "Icon", + "ResourceSecurity", + "DEFAULT_RESOURCE_SECURITY", +] diff --git a/src/mcp/server/mcpserver/resources/__init__.py b/src/mcp/server/mcpserver/resources/__init__.py index b5805fb3..330edc32 100644 --- a/src/mcp/server/mcpserver/resources/__init__.py +++ b/src/mcp/server/mcpserver/resources/__init__.py @@ -1,6 +1,10 @@ from .base import Resource from .resource_manager import ResourceManager -from .templates import ResourceTemplate +from .templates import ( + DEFAULT_RESOURCE_SECURITY, + ResourceSecurity, + ResourceTemplate, +) from .types import ( BinaryResource, DirectoryResource, @@ -20,4 +24,6 @@ __all__ = [ "DirectoryResource", "ResourceTemplate", "ResourceManager", + "ResourceSecurity", + "DEFAULT_RESOURCE_SECURITY", ] diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index d7d9214c..aeef1370 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -75,9 +75,6 @@ class ResourceSecurity: DEFAULT_RESOURCE_SECURITY = ResourceSecurity() """Secure-by-default policy: traversal and absolute paths rejected.""" -UNSAFE_RESOURCE_SECURITY = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False) -"""No path checks. Use only when parameters are never used as filesystem paths.""" - class ResourceTemplate(BaseModel): """A template for dynamically creating resources.""" diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index c40c643c..cdf8e7eb 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -32,7 +32,13 @@ from mcp.server.lowlevel.server import lifespan as default_lifespan from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import ResourceError from mcp.server.mcpserver.prompts import Prompt, PromptManager -from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager +from mcp.server.mcpserver.resources import ( + DEFAULT_RESOURCE_SECURITY, + FunctionResource, + Resource, + ResourceManager, + ResourceSecurity, +) from mcp.server.mcpserver.tools import Tool, ToolManager from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger @@ -144,7 +150,9 @@ class MCPServer(Generic[LifespanResultT]): warn_on_duplicate_prompts: bool = True, lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, auth: AuthSettings | None = None, + resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, ): + self._resource_security = resource_security self.settings = Settings( debug=debug, log_level=log_level, @@ -626,6 +634,7 @@ class MCPServer(Generic[LifespanResultT]): icons: list[Icon] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, + security: ResourceSecurity | None = None, ) -> Callable[[_CallableT], _CallableT]: """Decorator to register a function as a resource. @@ -647,6 +656,9 @@ class MCPServer(Generic[LifespanResultT]): icons: Optional list of icons for the resource annotations: Optional annotations for the resource meta: Optional metadata dictionary for the resource + security: Path-safety policy for extracted template parameters. + Defaults to the server's ``resource_security`` setting. + Only applies to template resources. Example: ```python @@ -717,6 +729,7 @@ class MCPServer(Generic[LifespanResultT]): mime_type=mime_type, icons=icons, annotations=annotations, + security=security if security is not None else self._resource_security, meta=meta, ) else: diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 57011e01..22638b57 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -8,7 +8,6 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate from mcp.server.mcpserver.resources.templates import ( DEFAULT_RESOURCE_SECURITY, - UNSAFE_RESOURCE_SECURITY, ResourceSecurity, ) from mcp.types import Annotations @@ -61,8 +60,9 @@ def test_matches_exempt_params_skip_security(): assert t.matches("git://diff/../foo") == {"range": "../foo"} -def test_matches_unsafe_policy_disables_checks(): - t = _make("file://docs/{name}", security=UNSAFE_RESOURCE_SECURITY) +def test_matches_disabled_policy_allows_traversal(): + policy = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False) + t = _make("file://docs/{name}", security=policy) assert t.matches("file://docs/..") == {"name": ".."} diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index b117e157..c14ce56d 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -12,7 +12,7 @@ from starlette.routing import Mount, Route from mcp.client import Client from mcp.server.context import ServerRequestContext from mcp.server.experimental.request_context import Experimental -from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity from mcp.server.mcpserver.exceptions import ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource @@ -159,6 +159,47 @@ class TestServer: with pytest.raises(InvalidUriTemplate, match="Unclosed expression"): mcp.resource("file://{name") + async def test_resource_security_default_rejects_traversal(self): + mcp = MCPServer() + + @mcp.resource("data://items/{name}") + def get_item(name: str) -> str: + return f"item:{name}" + + async with Client(mcp) as client: + # ".." as a path component is rejected by default policy + with pytest.raises(MCPError, match="Unknown resource"): + await client.read_resource("data://items/..") + + async def test_resource_security_per_resource_override(self): + mcp = MCPServer() + + @mcp.resource( + "git://diff/{+range}", + security=ResourceSecurity(exempt_params=frozenset({"range"})), + ) + def git_diff(range: str) -> str: + return f"diff:{range}" + + async with Client(mcp) as client: + # "../foo" would be rejected by default, but "range" is exempt + result = await client.read_resource("git://diff/../foo") + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "diff:../foo" + + async def test_resource_security_server_wide_override(self): + mcp = MCPServer(resource_security=ResourceSecurity(reject_path_traversal=False)) + + @mcp.resource("data://items/{name}") + def get_item(name: str) -> str: + return f"item:{name}" + + async with Client(mcp) as client: + # Server-wide policy disabled traversal check; ".." now allowed + result = await client.read_resource("data://items/..") + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "item:.." + class TestDnsRebindingProtection: """Tests for automatic DNS rebinding protection on localhost.