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:
@@ -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."""
|
||||
|
||||
@@ -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": ".."}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user