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: ...
This commit is contained in:
Max Isbey
2026-03-26 16:54:32 +00:00
parent 0018eea38f
commit 5cbbc70b18
6 changed files with 76 additions and 10 deletions
+10 -1
View File
@@ -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",
]
@@ -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",
]
@@ -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."""
+14 -1
View File
@@ -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:
@@ -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": ".."}
+42 -1
View File
@@ -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.