- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
248 lines
9.9 KiB
Python
248 lines
9.9 KiB
Python
"""Resource template functionality."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
from collections.abc import Callable, Mapping, Set
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import anyio.to_thread
|
|
from mcp_types import Annotations, Icon, InputRequiredResult
|
|
from pydantic import BaseModel, Field, validate_call
|
|
|
|
from mcp.server.mcpserver.exceptions import ResourceError, UnexpectedResourceError
|
|
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
|
|
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
|
|
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
|
|
from mcp.shared._callable_inspection import is_async_callable
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
|
|
from mcp.shared.uri_template import UriTemplate
|
|
|
|
if TYPE_CHECKING:
|
|
from mcp.server.context import LifespanContextT, RequestT
|
|
from mcp.server.mcpserver.context import Context
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResourceSecurity:
|
|
"""Security policy applied to extracted resource template parameters.
|
|
|
|
These checks run after :meth:`~mcp.shared.uri_template.UriTemplate.match`
|
|
has extracted and decoded parameter values. They catch path-traversal
|
|
and absolute-path injection regardless of how the value was encoded in
|
|
the URI (literal, ``%2F``, ``%5C``, ``%2E%2E``).
|
|
|
|
Example::
|
|
|
|
# Opt out for a parameter that legitimately contains ..
|
|
@mcp.resource(
|
|
"git://diff/{+range}",
|
|
security=ResourceSecurity(exempt_params={"range"}),
|
|
)
|
|
def git_diff(range: str) -> str: ...
|
|
"""
|
|
|
|
reject_path_traversal: bool = True
|
|
"""Reject values containing ``..`` as a path component."""
|
|
|
|
reject_absolute_paths: bool = True
|
|
"""Reject values that look like absolute filesystem paths."""
|
|
|
|
reject_null_bytes: bool = True
|
|
"""Reject values containing NUL (``\\x00``). Null bytes defeat string
|
|
comparisons (``"..\\x00" != ".."``) and can cause truncation in C
|
|
extensions or subprocess calls."""
|
|
|
|
exempt_params: Set[str] = field(default_factory=frozenset[str])
|
|
"""Parameter names to skip all checks for."""
|
|
|
|
def validate(self, params: Mapping[str, str | list[str]]) -> str | None:
|
|
"""Check all parameter values against the configured policy.
|
|
|
|
Args:
|
|
params: Extracted template parameters. List values (from
|
|
explode variables) are checked element-wise.
|
|
|
|
Returns:
|
|
The name of the first parameter that fails, or ``None`` if
|
|
all values pass.
|
|
"""
|
|
for name, value in params.items():
|
|
if name in self.exempt_params:
|
|
continue
|
|
values = value if isinstance(value, list) else [value]
|
|
for v in values:
|
|
if self.reject_null_bytes and "\0" in v:
|
|
return name
|
|
if self.reject_path_traversal and contains_path_traversal(v):
|
|
return name
|
|
if self.reject_absolute_paths and is_absolute_path(v):
|
|
return name
|
|
return None
|
|
|
|
|
|
DEFAULT_RESOURCE_SECURITY = ResourceSecurity()
|
|
"""Secure-by-default policy: traversal, absolute paths, and null bytes rejected."""
|
|
|
|
|
|
class ResourceSecurityError(ValueError):
|
|
"""Raised when an extracted parameter fails :class:`ResourceSecurity` checks.
|
|
|
|
Distinct from a simple ``None`` non-match so that template
|
|
iteration can stop at the first security rejection rather than
|
|
falling through to a later, possibly more permissive, template.
|
|
"""
|
|
|
|
def __init__(self, template: str, param: str) -> None:
|
|
super().__init__(f"Parameter {param!r} of template {template!r} failed security validation")
|
|
self.template = template
|
|
self.param = param
|
|
|
|
|
|
class ResourceTemplate(BaseModel):
|
|
"""A template for dynamically creating resources."""
|
|
|
|
uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)")
|
|
name: str = Field(description="Name of the resource")
|
|
title: str | None = Field(description="Human-readable title of the resource", default=None)
|
|
description: str | None = Field(description="Description of what the resource does")
|
|
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
|
|
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template")
|
|
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template")
|
|
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template")
|
|
fn: Callable[..., Any] = Field(exclude=True)
|
|
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
|
|
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
|
|
parsed_template: UriTemplate = Field(exclude=True, description="Parsed RFC 6570 template")
|
|
security: ResourceSecurity = Field(exclude=True, description="Path-safety policy for extracted parameters")
|
|
|
|
@classmethod
|
|
def from_function(
|
|
cls,
|
|
fn: Callable[..., Any],
|
|
uri_template: str,
|
|
name: str | None = None,
|
|
title: str | None = None,
|
|
description: str | None = None,
|
|
mime_type: str | None = None,
|
|
icons: list[Icon] | None = None,
|
|
annotations: Annotations | None = None,
|
|
meta: dict[str, Any] | None = None,
|
|
context_kwarg: str | None = None,
|
|
security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY,
|
|
) -> ResourceTemplate:
|
|
"""Create a template from a function.
|
|
|
|
Raises:
|
|
InvalidUriTemplate: If ``uri_template`` is malformed or uses
|
|
unsupported RFC 6570 features.
|
|
"""
|
|
func_name = name or fn.__name__
|
|
if func_name == "<lambda>":
|
|
raise ValueError("You must provide a name for lambda functions") # pragma: no cover
|
|
|
|
parsed = UriTemplate.parse(uri_template)
|
|
|
|
# Find context parameter if it exists
|
|
if context_kwarg is None: # pragma: no branch
|
|
context_kwarg = find_context_parameter(fn)
|
|
|
|
# Only the argument model is needed; a resource has no output schema to derive
|
|
func_arg_metadata = func_metadata(
|
|
fn, skip_names=[context_kwarg] if context_kwarg is not None else [], structured_output=False
|
|
)
|
|
parameters = func_arg_metadata.arg_model.model_json_schema()
|
|
|
|
# ensure the arguments are properly cast
|
|
fn = validate_call(fn)
|
|
|
|
return cls(
|
|
uri_template=uri_template,
|
|
name=func_name,
|
|
title=title,
|
|
description=description or fn.__doc__ or "",
|
|
mime_type=mime_type or "text/plain",
|
|
icons=icons,
|
|
annotations=annotations,
|
|
meta=meta,
|
|
fn=fn,
|
|
parameters=parameters,
|
|
context_kwarg=context_kwarg,
|
|
parsed_template=parsed,
|
|
security=security,
|
|
)
|
|
|
|
def matches(self, uri: str) -> dict[str, str | list[str]] | None:
|
|
"""Check if a URI matches this template and extract parameters.
|
|
|
|
Delegates to :meth:`UriTemplate.match` for RFC 6570 extraction,
|
|
then applies this template's :class:`ResourceSecurity` policy
|
|
(path traversal, absolute paths).
|
|
|
|
Returns:
|
|
Extracted parameters on success, or ``None`` if the URI
|
|
doesn't match the template.
|
|
|
|
Raises:
|
|
ResourceSecurityError: If the URI matches but an extracted
|
|
parameter fails security validation. Raising (rather
|
|
than returning ``None``) prevents the resource manager
|
|
from silently falling through to a later, possibly more
|
|
permissive, template.
|
|
"""
|
|
params = self.parsed_template.match(uri)
|
|
if params is None:
|
|
return None
|
|
failed = self.security.validate(params)
|
|
if failed is not None:
|
|
raise ResourceSecurityError(self.uri_template, failed)
|
|
return params
|
|
|
|
async def create_resource(
|
|
self,
|
|
uri: str,
|
|
params: dict[str, Any],
|
|
context: Context[LifespanContextT, RequestT],
|
|
) -> Resource | InputRequiredResult:
|
|
"""Create a resource from the template with the given parameters.
|
|
|
|
An `InputRequiredResult` returned by the template function is passed
|
|
through unchanged (the 2026-07-28 multi-round-trip flow); the retry's
|
|
answers arrive on `ctx.input_responses`, with `ctx.request_state`
|
|
carrying the echoed opaque state.
|
|
|
|
Raises:
|
|
ResourceError: If the template function raises `ResourceError`.
|
|
UnexpectedResourceError: If the template function raises anything other
|
|
than `ResourceError` or `MCPError`. `__cause__` is the original exception.
|
|
"""
|
|
try:
|
|
# Add context to params if needed
|
|
params = inject_context(self.fn, params, context, self.context_kwarg)
|
|
|
|
fn = self.fn
|
|
if is_async_callable(fn):
|
|
result = await fn(**params)
|
|
else:
|
|
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params))
|
|
|
|
if isinstance(result, InputRequiredResult):
|
|
return result
|
|
|
|
return FunctionResource(
|
|
uri=uri, # type: ignore
|
|
name=self.name,
|
|
title=self.title,
|
|
description=self.description,
|
|
mime_type=self.mime_type,
|
|
icons=self.icons,
|
|
annotations=self.annotations,
|
|
meta=self.meta,
|
|
fn=lambda: result, # Capture result in closure
|
|
)
|
|
except (ResourceError, MCPError):
|
|
raise
|
|
except Exception as exc:
|
|
raise UnexpectedResourceError(f"Error creating resource from template {uri}") from exc
|