Skip to content

Commit 5cbbc70

Browse files
committed
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: ...
1 parent 0018eea commit 5cbbc70

File tree

6 files changed

+76
-10
lines changed

6 files changed

+76
-10
lines changed

src/mcp/server/mcpserver/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33
from mcp.types import Icon
44

55
from .context import Context
6+
from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity
67
from .server import MCPServer
78
from .utilities.types import Audio, Image
89

9-
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
10+
__all__ = [
11+
"MCPServer",
12+
"Context",
13+
"Image",
14+
"Audio",
15+
"Icon",
16+
"ResourceSecurity",
17+
"DEFAULT_RESOURCE_SECURITY",
18+
]

src/mcp/server/mcpserver/resources/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
from .base import Resource
22
from .resource_manager import ResourceManager
3-
from .templates import ResourceTemplate
3+
from .templates import (
4+
DEFAULT_RESOURCE_SECURITY,
5+
ResourceSecurity,
6+
ResourceTemplate,
7+
)
48
from .types import (
59
BinaryResource,
610
DirectoryResource,
@@ -20,4 +24,6 @@
2024
"DirectoryResource",
2125
"ResourceTemplate",
2226
"ResourceManager",
27+
"ResourceSecurity",
28+
"DEFAULT_RESOURCE_SECURITY",
2329
]

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,6 @@ def validate(self, params: Mapping[str, str | list[str]]) -> bool:
7575
DEFAULT_RESOURCE_SECURITY = ResourceSecurity()
7676
"""Secure-by-default policy: traversal and absolute paths rejected."""
7777

78-
UNSAFE_RESOURCE_SECURITY = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False)
79-
"""No path checks. Use only when parameters are never used as filesystem paths."""
80-
8178

8279
class ResourceTemplate(BaseModel):
8380
"""A template for dynamically creating resources."""

src/mcp/server/mcpserver/server.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@
3232
from mcp.server.mcpserver.context import Context
3333
from mcp.server.mcpserver.exceptions import ResourceError
3434
from mcp.server.mcpserver.prompts import Prompt, PromptManager
35-
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
35+
from mcp.server.mcpserver.resources import (
36+
DEFAULT_RESOURCE_SECURITY,
37+
FunctionResource,
38+
Resource,
39+
ResourceManager,
40+
ResourceSecurity,
41+
)
3642
from mcp.server.mcpserver.tools import Tool, ToolManager
3743
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
3844
from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger
@@ -144,7 +150,9 @@ def __init__(
144150
warn_on_duplicate_prompts: bool = True,
145151
lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None,
146152
auth: AuthSettings | None = None,
153+
resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
147154
):
155+
self._resource_security = resource_security
148156
self.settings = Settings(
149157
debug=debug,
150158
log_level=log_level,
@@ -626,6 +634,7 @@ def resource(
626634
icons: list[Icon] | None = None,
627635
annotations: Annotations | None = None,
628636
meta: dict[str, Any] | None = None,
637+
security: ResourceSecurity | None = None,
629638
) -> Callable[[_CallableT], _CallableT]:
630639
"""Decorator to register a function as a resource.
631640
@@ -647,6 +656,9 @@ def resource(
647656
icons: Optional list of icons for the resource
648657
annotations: Optional annotations for the resource
649658
meta: Optional metadata dictionary for the resource
659+
security: Path-safety policy for extracted template parameters.
660+
Defaults to the server's ``resource_security`` setting.
661+
Only applies to template resources.
650662
651663
Example:
652664
```python
@@ -717,6 +729,7 @@ def decorator(fn: _CallableT) -> _CallableT:
717729
mime_type=mime_type,
718730
icons=icons,
719731
annotations=annotations,
732+
security=security if security is not None else self._resource_security,
720733
meta=meta,
721734
)
722735
else:

tests/server/mcpserver/resources/test_resource_template.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate
99
from mcp.server.mcpserver.resources.templates import (
1010
DEFAULT_RESOURCE_SECURITY,
11-
UNSAFE_RESOURCE_SECURITY,
1211
ResourceSecurity,
1312
)
1413
from mcp.types import Annotations
@@ -61,8 +60,9 @@ def test_matches_exempt_params_skip_security():
6160
assert t.matches("git://diff/../foo") == {"range": "../foo"}
6261

6362

64-
def test_matches_unsafe_policy_disables_checks():
65-
t = _make("file://docs/{name}", security=UNSAFE_RESOURCE_SECURITY)
63+
def test_matches_disabled_policy_allows_traversal():
64+
policy = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False)
65+
t = _make("file://docs/{name}", security=policy)
6666
assert t.matches("file://docs/..") == {"name": ".."}
6767

6868

tests/server/mcpserver/test_server.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from mcp.client import Client
1313
from mcp.server.context import ServerRequestContext
1414
from mcp.server.experimental.request_context import Experimental
15-
from mcp.server.mcpserver import Context, MCPServer
15+
from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity
1616
from mcp.server.mcpserver.exceptions import ToolError
1717
from mcp.server.mcpserver.prompts.base import Message, UserMessage
1818
from mcp.server.mcpserver.resources import FileResource, FunctionResource
@@ -159,6 +159,47 @@ async def test_resource_decorator_rejects_malformed_template(self):
159159
with pytest.raises(InvalidUriTemplate, match="Unclosed expression"):
160160
mcp.resource("file://{name")
161161

162+
async def test_resource_security_default_rejects_traversal(self):
163+
mcp = MCPServer()
164+
165+
@mcp.resource("data://items/{name}")
166+
def get_item(name: str) -> str:
167+
return f"item:{name}"
168+
169+
async with Client(mcp) as client:
170+
# ".." as a path component is rejected by default policy
171+
with pytest.raises(MCPError, match="Unknown resource"):
172+
await client.read_resource("data://items/..")
173+
174+
async def test_resource_security_per_resource_override(self):
175+
mcp = MCPServer()
176+
177+
@mcp.resource(
178+
"git://diff/{+range}",
179+
security=ResourceSecurity(exempt_params=frozenset({"range"})),
180+
)
181+
def git_diff(range: str) -> str:
182+
return f"diff:{range}"
183+
184+
async with Client(mcp) as client:
185+
# "../foo" would be rejected by default, but "range" is exempt
186+
result = await client.read_resource("git://diff/../foo")
187+
assert isinstance(result.contents[0], TextResourceContents)
188+
assert result.contents[0].text == "diff:../foo"
189+
190+
async def test_resource_security_server_wide_override(self):
191+
mcp = MCPServer(resource_security=ResourceSecurity(reject_path_traversal=False))
192+
193+
@mcp.resource("data://items/{name}")
194+
def get_item(name: str) -> str:
195+
return f"item:{name}"
196+
197+
async with Client(mcp) as client:
198+
# Server-wide policy disabled traversal check; ".." now allowed
199+
result = await client.read_resource("data://items/..")
200+
assert isinstance(result.contents[0], TextResourceContents)
201+
assert result.contents[0].text == "item:.."
202+
162203

163204
class TestDnsRebindingProtection:
164205
"""Tests for automatic DNS rebinding protection on localhost.

0 commit comments

Comments
 (0)