refactor: remove request_ctx ContextVar, thread Context explicitly (#2203)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
+32
-1
@@ -288,6 +288,37 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=Tru
|
||||
|
||||
**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor.
|
||||
|
||||
### `MCPServer.get_context()` removed
|
||||
|
||||
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
|
||||
|
||||
**If you were calling `get_context()` from inside a tool/resource/prompt:** use the `ctx: Context` parameter injection instead.
|
||||
|
||||
**Before (v1):**
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def my_tool(x: int) -> str:
|
||||
ctx = mcp.get_context()
|
||||
await ctx.info("Processing...")
|
||||
return str(x)
|
||||
```
|
||||
|
||||
**After (v2):**
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def my_tool(x: int, ctx: Context) -> str:
|
||||
await ctx.info("Processing...")
|
||||
return str(x)
|
||||
```
|
||||
|
||||
### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter
|
||||
|
||||
`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise.
|
||||
|
||||
The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.
|
||||
|
||||
### Replace `RootModel` by union types with `TypeAdapter` validation
|
||||
|
||||
The following union types are no longer `RootModel` subclasses:
|
||||
@@ -694,7 +725,7 @@ If you prefer the convenience of automatic wrapping, use `MCPServer` which still
|
||||
|
||||
### Lowlevel `Server`: `request_context` property removed
|
||||
|
||||
The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar is now an internal implementation detail and should not be relied upon.
|
||||
The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely.
|
||||
|
||||
**Before (v1):**
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ handler callables by method string.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import warnings
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
@@ -74,8 +73,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
LifespanResultT = TypeVar("LifespanResultT", default=Any)
|
||||
|
||||
request_ctx: contextvars.ContextVar[ServerRequestContext[Any]] = contextvars.ContextVar("request_ctx")
|
||||
|
||||
|
||||
class NotificationOptions:
|
||||
def __init__(self, prompts_changed: bool = False, resources_changed: bool = False, tools_changed: bool = False):
|
||||
@@ -474,11 +471,7 @@ class Server(Generic[LifespanResultT]):
|
||||
close_sse_stream=close_sse_stream_cb,
|
||||
close_standalone_sse_stream=close_standalone_sse_stream_cb,
|
||||
)
|
||||
token = request_ctx.set(ctx)
|
||||
try:
|
||||
response = await handler(ctx, req.params)
|
||||
finally:
|
||||
request_ctx.reset(token)
|
||||
response = await handler(ctx, req.params)
|
||||
except MCPError as err:
|
||||
response = err.error
|
||||
except anyio.get_cancelled_exc_class():
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from mcp.types import Icon
|
||||
|
||||
from .server import Context, MCPServer
|
||||
from .context import Context
|
||||
from .server import MCPServer
|
||||
from .utilities.types import Audio, Image
|
||||
|
||||
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal
|
||||
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
|
||||
from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
|
||||
from mcp.server.elicitation import (
|
||||
ElicitationResult,
|
||||
ElicitSchemaModelT,
|
||||
UrlElicitationResult,
|
||||
elicit_url,
|
||||
elicit_with_validation,
|
||||
)
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.mcpserver.server import MCPServer
|
||||
|
||||
|
||||
class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
||||
"""Context object providing access to MCP capabilities.
|
||||
|
||||
This provides a cleaner interface to MCP's RequestContext functionality.
|
||||
It gets injected into tool and resource functions that request it via type hints.
|
||||
|
||||
To use context in a tool function, add a parameter with the Context type annotation:
|
||||
|
||||
```python
|
||||
@server.tool()
|
||||
async def my_tool(x: int, ctx: Context) -> str:
|
||||
# Log messages to the client
|
||||
await ctx.info(f"Processing {x}")
|
||||
await ctx.debug("Debug info")
|
||||
await ctx.warning("Warning message")
|
||||
await ctx.error("Error message")
|
||||
|
||||
# Report progress
|
||||
await ctx.report_progress(50, 100)
|
||||
|
||||
# Access resources
|
||||
data = await ctx.read_resource("resource://data")
|
||||
|
||||
# Get request info
|
||||
request_id = ctx.request_id
|
||||
client_id = ctx.client_id
|
||||
|
||||
return str(x)
|
||||
```
|
||||
|
||||
The context parameter name can be anything as long as it's annotated with Context.
|
||||
The context is optional - tools that don't need it can omit the parameter.
|
||||
"""
|
||||
|
||||
_request_context: ServerRequestContext[LifespanContextT, RequestT] | None
|
||||
_mcp_server: MCPServer | None
|
||||
|
||||
# TODO(maxisbey): Consider making request_context/mcp_server required, or refactor Context entirely.
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None,
|
||||
mcp_server: MCPServer | None = None,
|
||||
# TODO(Marcelo): We should drop this kwargs parameter.
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._request_context = request_context
|
||||
self._mcp_server = mcp_server
|
||||
|
||||
@property
|
||||
def mcp_server(self) -> MCPServer:
|
||||
"""Access to the MCPServer instance."""
|
||||
if self._mcp_server is None: # pragma: no cover
|
||||
raise ValueError("Context is not available outside of a request")
|
||||
return self._mcp_server # pragma: no cover
|
||||
|
||||
@property
|
||||
def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]:
|
||||
"""Access to the underlying request context."""
|
||||
if self._request_context is None: # pragma: no cover
|
||||
raise ValueError("Context is not available outside of a request")
|
||||
return self._request_context
|
||||
|
||||
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
||||
"""Report progress for the current operation.
|
||||
|
||||
Args:
|
||||
progress: Current progress value (e.g., 24)
|
||||
total: Optional total value (e.g., 100)
|
||||
message: Optional message (e.g., "Starting render...")
|
||||
"""
|
||||
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None
|
||||
|
||||
if progress_token is None: # pragma: no cover
|
||||
return
|
||||
|
||||
await self.request_context.session.send_progress_notification(
|
||||
progress_token=progress_token,
|
||||
progress=progress,
|
||||
total=total,
|
||||
message=message,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
|
||||
"""Read a resource by URI.
|
||||
|
||||
Args:
|
||||
uri: Resource URI to read
|
||||
|
||||
Returns:
|
||||
The resource content as either text or bytes
|
||||
"""
|
||||
assert self._mcp_server is not None, "Context is not available outside of a request"
|
||||
return await self._mcp_server.read_resource(uri, self)
|
||||
|
||||
async def elicit(
|
||||
self,
|
||||
message: str,
|
||||
schema: type[ElicitSchemaModelT],
|
||||
) -> ElicitationResult[ElicitSchemaModelT]:
|
||||
"""Elicit information from the client/user.
|
||||
|
||||
This method can be used to interactively ask for additional information from the
|
||||
client within a tool's execution. The client might display the message to the
|
||||
user and collect a response according to the provided schema. If the client
|
||||
is an agent, it might decide how to handle the elicitation -- either by asking
|
||||
the user or automatically generating a response.
|
||||
|
||||
Args:
|
||||
message: Message to present to the user
|
||||
schema: A Pydantic model class defining the expected response structure.
|
||||
According to the specification, only primitive types are allowed.
|
||||
|
||||
Returns:
|
||||
An ElicitationResult containing the action taken and the data if accepted
|
||||
|
||||
Note:
|
||||
Check the result.action to determine if the user accepted, declined, or cancelled.
|
||||
The result.data will only be populated if action is "accept" and validation succeeded.
|
||||
"""
|
||||
|
||||
return await elicit_with_validation(
|
||||
session=self.request_context.session,
|
||||
message=message,
|
||||
schema=schema,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
async def elicit_url(
|
||||
self,
|
||||
message: str,
|
||||
url: str,
|
||||
elicitation_id: str,
|
||||
) -> UrlElicitationResult:
|
||||
"""Request URL mode elicitation from the client.
|
||||
|
||||
This directs the user to an external URL for out-of-band interactions
|
||||
that must not pass through the MCP client. Use this for:
|
||||
- Collecting sensitive credentials (API keys, passwords)
|
||||
- OAuth authorization flows with third-party services
|
||||
- Payment and subscription flows
|
||||
- Any interaction where data should not pass through the LLM context
|
||||
|
||||
The response indicates whether the user consented to navigate to the URL.
|
||||
The actual interaction happens out-of-band. When the elicitation completes,
|
||||
call `ctx.session.send_elicit_complete(elicitation_id)` to notify the client.
|
||||
|
||||
Args:
|
||||
message: Human-readable explanation of why the interaction is needed
|
||||
url: The URL the user should navigate to
|
||||
elicitation_id: Unique identifier for tracking this elicitation
|
||||
|
||||
Returns:
|
||||
UrlElicitationResult indicating accept, decline, or cancel
|
||||
"""
|
||||
return await elicit_url(
|
||||
session=self.request_context.session,
|
||||
message=message,
|
||||
url=url,
|
||||
elicitation_id=elicitation_id,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
async def log(
|
||||
self,
|
||||
level: Literal["debug", "info", "warning", "error"],
|
||||
message: str,
|
||||
*,
|
||||
logger_name: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Send a log message to the client.
|
||||
|
||||
Args:
|
||||
level: Log level (debug, info, warning, error)
|
||||
message: Log message
|
||||
logger_name: Optional logger name
|
||||
extra: Optional dictionary with additional structured data to include
|
||||
"""
|
||||
|
||||
if extra:
|
||||
log_data = {"message": message, **extra}
|
||||
else:
|
||||
log_data = message
|
||||
|
||||
await self.request_context.session.send_log_message(
|
||||
level=level,
|
||||
data=log_data,
|
||||
logger=logger_name,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def client_id(self) -> str | None:
|
||||
"""Get the client ID if available."""
|
||||
return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover
|
||||
|
||||
@property
|
||||
def request_id(self) -> str:
|
||||
"""Get the unique ID for this request."""
|
||||
return str(self.request_context.request_id)
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""Access to the underlying session for advanced usage."""
|
||||
return self.request_context.session
|
||||
|
||||
async def close_sse_stream(self) -> None:
|
||||
"""Close the SSE stream to trigger client reconnection.
|
||||
|
||||
This method closes the HTTP connection for the current request, triggering
|
||||
client reconnection. Events continue to be stored in the event store and will
|
||||
be replayed when the client reconnects with Last-Event-ID.
|
||||
|
||||
Use this to implement polling behavior during long-running operations -
|
||||
the client will reconnect after the retry interval specified in the priming event.
|
||||
|
||||
Note:
|
||||
This is a no-op if not using StreamableHTTP transport with event_store.
|
||||
The callback is only available when event_store is configured.
|
||||
"""
|
||||
if self._request_context and self._request_context.close_sse_stream: # pragma: no cover
|
||||
await self._request_context.close_sse_stream()
|
||||
|
||||
async def close_standalone_sse_stream(self) -> None:
|
||||
"""Close the standalone GET SSE stream to trigger client reconnection.
|
||||
|
||||
This method closes the HTTP connection for the standalone GET stream used
|
||||
for unsolicited server-to-client notifications. The client SHOULD reconnect
|
||||
with Last-Event-ID to resume receiving notifications.
|
||||
|
||||
Note:
|
||||
This is a no-op if not using StreamableHTTP transport with event_store.
|
||||
Currently, client reconnection for standalone GET streams is NOT
|
||||
implemented - this is a known gap.
|
||||
"""
|
||||
if self._request_context and self._request_context.close_standalone_sse_stream: # pragma: no cover
|
||||
await self._request_context.close_standalone_sse_stream()
|
||||
|
||||
# Convenience methods for common log levels
|
||||
async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
|
||||
"""Send a debug log message."""
|
||||
await self.log("debug", message, logger_name=logger_name, extra=extra)
|
||||
|
||||
async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
|
||||
"""Send an info log message."""
|
||||
await self.log("info", message, logger_name=logger_name, extra=extra)
|
||||
|
||||
async def warning(
|
||||
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Send a warning log message."""
|
||||
await self.log("warning", message, logger_name=logger_name, extra=extra)
|
||||
|
||||
async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
|
||||
"""Send an error log message."""
|
||||
await self.log("error", message, logger_name=logger_name, extra=extra)
|
||||
@@ -15,7 +15,7 @@ from mcp.types import ContentBlock, Icon, TextContent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.context import LifespanContextT, RequestT
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
@@ -135,10 +135,14 @@ class Prompt(BaseModel):
|
||||
|
||||
async def render(
|
||||
self,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
context: Context[LifespanContextT, RequestT] | None = None,
|
||||
arguments: dict[str, Any] | None,
|
||||
context: Context[LifespanContextT, RequestT],
|
||||
) -> list[Message]:
|
||||
"""Render the prompt with arguments."""
|
||||
"""Render the prompt with arguments.
|
||||
|
||||
Raises:
|
||||
ValueError: If required arguments are missing, or if rendering fails.
|
||||
"""
|
||||
# Validate required arguments
|
||||
if self.arguments:
|
||||
required = {arg.name for arg in self.arguments if arg.required}
|
||||
|
||||
@@ -9,7 +9,7 @@ from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.context import LifespanContextT, RequestT
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -48,12 +48,12 @@ class PromptManager:
|
||||
async def render_prompt(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
context: Context[LifespanContextT, RequestT] | None = None,
|
||||
arguments: dict[str, Any] | None,
|
||||
context: Context[LifespanContextT, RequestT],
|
||||
) -> list[Message]:
|
||||
"""Render a prompt by name with arguments."""
|
||||
prompt = self.get_prompt(name)
|
||||
if not prompt:
|
||||
raise ValueError(f"Unknown prompt: {name}")
|
||||
|
||||
return await prompt.render(arguments, context=context)
|
||||
return await prompt.render(arguments, context)
|
||||
|
||||
@@ -14,7 +14,7 @@ from mcp.types import Annotations, Icon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.context import LifespanContextT, RequestT
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -80,9 +80,7 @@ class ResourceManager:
|
||||
self._templates[template.uri_template] = template
|
||||
return template
|
||||
|
||||
async def get_resource(
|
||||
self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] | None = None
|
||||
) -> Resource:
|
||||
async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]) -> Resource:
|
||||
"""Get resource by URI, checking concrete resources first, then templates."""
|
||||
uri_str = str(uri)
|
||||
logger.debug("Getting resource", extra={"uri": uri_str})
|
||||
|
||||
@@ -17,7 +17,7 @@ from mcp.types import Annotations, Icon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.context import LifespanContextT, RequestT
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
|
||||
class ResourceTemplate(BaseModel):
|
||||
@@ -99,9 +99,13 @@ class ResourceTemplate(BaseModel):
|
||||
self,
|
||||
uri: str,
|
||||
params: dict[str, Any],
|
||||
context: Context[LifespanContextT, RequestT] | None = None,
|
||||
context: Context[LifespanContextT, RequestT],
|
||||
) -> Resource:
|
||||
"""Create a resource from the template with the given parameters."""
|
||||
"""Create a resource from the template with the given parameters.
|
||||
|
||||
Raises:
|
||||
ValueError: If creating the resource fails.
|
||||
"""
|
||||
try:
|
||||
# Add context to params if needed
|
||||
params = inject_context(self.fn, params, context, self.context_kwarg)
|
||||
|
||||
@@ -12,7 +12,6 @@ from typing import Any, Generic, Literal, TypeVar, overload
|
||||
|
||||
import anyio
|
||||
import pydantic_core
|
||||
from pydantic import BaseModel
|
||||
from pydantic.networks import AnyUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from starlette.applications import Starlette
|
||||
@@ -27,12 +26,11 @@ from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
|
||||
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
|
||||
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
|
||||
from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, UrlElicitationResult, elicit_with_validation
|
||||
from mcp.server.elicitation import elicit_url as _elicit_url
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.server.lowlevel.server import LifespanResultT, Server, request_ctx
|
||||
from mcp.server.lowlevel.server import LifespanResultT, Server
|
||||
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
|
||||
@@ -300,8 +298,9 @@ class MCPServer(Generic[LifespanResultT]):
|
||||
async def _handle_call_tool(
|
||||
self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams
|
||||
) -> CallToolResult:
|
||||
context = Context(request_context=ctx, mcp_server=self)
|
||||
try:
|
||||
result = await self.call_tool(params.name, params.arguments or {})
|
||||
result = await self.call_tool(params.name, params.arguments or {}, context)
|
||||
except MCPError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -332,7 +331,8 @@ class MCPServer(Generic[LifespanResultT]):
|
||||
async def _handle_read_resource(
|
||||
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
|
||||
) -> ReadResourceResult:
|
||||
results = await self.read_resource(params.uri)
|
||||
context = Context(request_context=ctx, mcp_server=self)
|
||||
results = await self.read_resource(params.uri, context)
|
||||
contents: list[TextResourceContents | BlobResourceContents] = []
|
||||
for item in results:
|
||||
if isinstance(item.content, bytes):
|
||||
@@ -368,7 +368,8 @@ class MCPServer(Generic[LifespanResultT]):
|
||||
async def _handle_get_prompt(
|
||||
self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams
|
||||
) -> GetPromptResult:
|
||||
return await self.get_prompt(params.name, params.arguments)
|
||||
context = Context(request_context=ctx, mcp_server=self)
|
||||
return await self.get_prompt(params.name, params.arguments, context)
|
||||
|
||||
async def list_tools(self) -> list[MCPTool]:
|
||||
"""List all available tools."""
|
||||
@@ -387,22 +388,13 @@ class MCPServer(Generic[LifespanResultT]):
|
||||
for info in tools
|
||||
]
|
||||
|
||||
def get_context(self) -> Context[LifespanResultT, Request]:
|
||||
"""Return a Context object.
|
||||
|
||||
Note that the context will only be valid during a request; outside a
|
||||
request, most methods will error.
|
||||
"""
|
||||
try:
|
||||
request_context = request_ctx.get()
|
||||
except LookupError:
|
||||
request_context = None
|
||||
return Context(request_context=request_context, mcp_server=self)
|
||||
|
||||
async def call_tool(self, name: str, arguments: dict[str, Any]) -> Sequence[ContentBlock] | dict[str, Any]:
|
||||
async def call_tool(
|
||||
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
|
||||
) -> Sequence[ContentBlock] | dict[str, Any]:
|
||||
"""Call a tool by name with arguments."""
|
||||
context = self.get_context()
|
||||
return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True)
|
||||
if context is None:
|
||||
context = Context(mcp_server=self)
|
||||
return await self._tool_manager.call_tool(name, arguments, context, convert_result=True)
|
||||
|
||||
async def list_resources(self) -> list[MCPResource]:
|
||||
"""List all available resources."""
|
||||
@@ -438,12 +430,14 @@ class MCPServer(Generic[LifespanResultT]):
|
||||
for template in templates
|
||||
]
|
||||
|
||||
async def read_resource(self, uri: AnyUrl | str) -> Iterable[ReadResourceContents]:
|
||||
async def read_resource(
|
||||
self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None
|
||||
) -> Iterable[ReadResourceContents]:
|
||||
"""Read a resource by URI."""
|
||||
|
||||
context = self.get_context()
|
||||
if context is None:
|
||||
context = Context(mcp_server=self)
|
||||
try:
|
||||
resource = await self._resource_manager.get_resource(uri, context=context)
|
||||
resource = await self._resource_manager.get_resource(uri, context)
|
||||
except ValueError:
|
||||
raise ResourceError(f"Unknown resource: {uri}")
|
||||
|
||||
@@ -1087,14 +1081,18 @@ class MCPServer(Generic[LifespanResultT]):
|
||||
for prompt in prompts
|
||||
]
|
||||
|
||||
async def get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult:
|
||||
async def get_prompt(
|
||||
self, name: str, arguments: dict[str, Any] | None = None, context: Context[LifespanResultT, Any] | None = None
|
||||
) -> GetPromptResult:
|
||||
"""Get a prompt by name with arguments."""
|
||||
if context is None:
|
||||
context = Context(mcp_server=self)
|
||||
try:
|
||||
prompt = self._prompt_manager.get_prompt(name)
|
||||
if not prompt:
|
||||
raise ValueError(f"Unknown prompt: {name}")
|
||||
|
||||
messages = await prompt.render(arguments, context=self.get_context())
|
||||
messages = await prompt.render(arguments, context)
|
||||
|
||||
return GetPromptResult(
|
||||
description=prompt.description,
|
||||
@@ -1103,263 +1101,3 @@ class MCPServer(Generic[LifespanResultT]):
|
||||
except Exception as e:
|
||||
logger.exception(f"Error getting prompt {name}")
|
||||
raise ValueError(str(e))
|
||||
|
||||
|
||||
class Context(BaseModel, Generic[LifespanContextT, RequestT]):
|
||||
"""Context object providing access to MCP capabilities.
|
||||
|
||||
This provides a cleaner interface to MCP's RequestContext functionality.
|
||||
It gets injected into tool and resource functions that request it via type hints.
|
||||
|
||||
To use context in a tool function, add a parameter with the Context type annotation:
|
||||
|
||||
```python
|
||||
@server.tool()
|
||||
async def my_tool(x: int, ctx: Context) -> str:
|
||||
# Log messages to the client
|
||||
await ctx.info(f"Processing {x}")
|
||||
await ctx.debug("Debug info")
|
||||
await ctx.warning("Warning message")
|
||||
await ctx.error("Error message")
|
||||
|
||||
# Report progress
|
||||
await ctx.report_progress(50, 100)
|
||||
|
||||
# Access resources
|
||||
data = await ctx.read_resource("resource://data")
|
||||
|
||||
# Get request info
|
||||
request_id = ctx.request_id
|
||||
client_id = ctx.client_id
|
||||
|
||||
return str(x)
|
||||
```
|
||||
|
||||
The context parameter name can be anything as long as it's annotated with Context.
|
||||
The context is optional - tools that don't need it can omit the parameter.
|
||||
"""
|
||||
|
||||
_request_context: ServerRequestContext[LifespanContextT, RequestT] | None
|
||||
_mcp_server: MCPServer | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None,
|
||||
mcp_server: MCPServer | None = None,
|
||||
# TODO(Marcelo): We should drop this kwargs parameter.
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self._request_context = request_context
|
||||
self._mcp_server = mcp_server
|
||||
|
||||
@property
|
||||
def mcp_server(self) -> MCPServer:
|
||||
"""Access to the MCPServer instance."""
|
||||
if self._mcp_server is None: # pragma: no cover
|
||||
raise ValueError("Context is not available outside of a request")
|
||||
return self._mcp_server # pragma: no cover
|
||||
|
||||
@property
|
||||
def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]:
|
||||
"""Access to the underlying request context."""
|
||||
if self._request_context is None: # pragma: no cover
|
||||
raise ValueError("Context is not available outside of a request")
|
||||
return self._request_context
|
||||
|
||||
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
||||
"""Report progress for the current operation.
|
||||
|
||||
Args:
|
||||
progress: Current progress value (e.g., 24)
|
||||
total: Optional total value (e.g., 100)
|
||||
message: Optional message (e.g., "Starting render...")
|
||||
"""
|
||||
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None
|
||||
|
||||
if progress_token is None: # pragma: no cover
|
||||
return
|
||||
|
||||
await self.request_context.session.send_progress_notification(
|
||||
progress_token=progress_token,
|
||||
progress=progress,
|
||||
total=total,
|
||||
message=message,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
|
||||
"""Read a resource by URI.
|
||||
|
||||
Args:
|
||||
uri: Resource URI to read
|
||||
|
||||
Returns:
|
||||
The resource content as either text or bytes
|
||||
"""
|
||||
assert self._mcp_server is not None, "Context is not available outside of a request"
|
||||
return await self._mcp_server.read_resource(uri)
|
||||
|
||||
async def elicit(
|
||||
self,
|
||||
message: str,
|
||||
schema: type[ElicitSchemaModelT],
|
||||
) -> ElicitationResult[ElicitSchemaModelT]:
|
||||
"""Elicit information from the client/user.
|
||||
|
||||
This method can be used to interactively ask for additional information from the
|
||||
client within a tool's execution. The client might display the message to the
|
||||
user and collect a response according to the provided schema. If the client
|
||||
is an agent, it might decide how to handle the elicitation -- either by asking
|
||||
the user or automatically generating a response.
|
||||
|
||||
Args:
|
||||
message: Message to present to the user
|
||||
schema: A Pydantic model class defining the expected response structure.
|
||||
According to the specification, only primitive types are allowed.
|
||||
|
||||
Returns:
|
||||
An ElicitationResult containing the action taken and the data if accepted
|
||||
|
||||
Note:
|
||||
Check the result.action to determine if the user accepted, declined, or cancelled.
|
||||
The result.data will only be populated if action is "accept" and validation succeeded.
|
||||
"""
|
||||
|
||||
return await elicit_with_validation(
|
||||
session=self.request_context.session,
|
||||
message=message,
|
||||
schema=schema,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
async def elicit_url(
|
||||
self,
|
||||
message: str,
|
||||
url: str,
|
||||
elicitation_id: str,
|
||||
) -> UrlElicitationResult:
|
||||
"""Request URL mode elicitation from the client.
|
||||
|
||||
This directs the user to an external URL for out-of-band interactions
|
||||
that must not pass through the MCP client. Use this for:
|
||||
- Collecting sensitive credentials (API keys, passwords)
|
||||
- OAuth authorization flows with third-party services
|
||||
- Payment and subscription flows
|
||||
- Any interaction where data should not pass through the LLM context
|
||||
|
||||
The response indicates whether the user consented to navigate to the URL.
|
||||
The actual interaction happens out-of-band. When the elicitation completes,
|
||||
call `ctx.session.send_elicit_complete(elicitation_id)` to notify the client.
|
||||
|
||||
Args:
|
||||
message: Human-readable explanation of why the interaction is needed
|
||||
url: The URL the user should navigate to
|
||||
elicitation_id: Unique identifier for tracking this elicitation
|
||||
|
||||
Returns:
|
||||
UrlElicitationResult indicating accept, decline, or cancel
|
||||
"""
|
||||
return await _elicit_url(
|
||||
session=self.request_context.session,
|
||||
message=message,
|
||||
url=url,
|
||||
elicitation_id=elicitation_id,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
async def log(
|
||||
self,
|
||||
level: Literal["debug", "info", "warning", "error"],
|
||||
message: str,
|
||||
*,
|
||||
logger_name: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Send a log message to the client.
|
||||
|
||||
Args:
|
||||
level: Log level (debug, info, warning, error)
|
||||
message: Log message
|
||||
logger_name: Optional logger name
|
||||
extra: Optional dictionary with additional structured data to include
|
||||
"""
|
||||
|
||||
if extra:
|
||||
log_data = {"message": message, **extra}
|
||||
else:
|
||||
log_data = message
|
||||
|
||||
await self.request_context.session.send_log_message(
|
||||
level=level,
|
||||
data=log_data,
|
||||
logger=logger_name,
|
||||
related_request_id=self.request_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def client_id(self) -> str | None:
|
||||
"""Get the client ID if available."""
|
||||
return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover
|
||||
|
||||
@property
|
||||
def request_id(self) -> str:
|
||||
"""Get the unique ID for this request."""
|
||||
return str(self.request_context.request_id)
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""Access to the underlying session for advanced usage."""
|
||||
return self.request_context.session
|
||||
|
||||
async def close_sse_stream(self) -> None:
|
||||
"""Close the SSE stream to trigger client reconnection.
|
||||
|
||||
This method closes the HTTP connection for the current request, triggering
|
||||
client reconnection. Events continue to be stored in the event store and will
|
||||
be replayed when the client reconnects with Last-Event-ID.
|
||||
|
||||
Use this to implement polling behavior during long-running operations -
|
||||
the client will reconnect after the retry interval specified in the priming event.
|
||||
|
||||
Note:
|
||||
This is a no-op if not using StreamableHTTP transport with event_store.
|
||||
The callback is only available when event_store is configured.
|
||||
"""
|
||||
if self._request_context and self._request_context.close_sse_stream: # pragma: no cover
|
||||
await self._request_context.close_sse_stream()
|
||||
|
||||
async def close_standalone_sse_stream(self) -> None:
|
||||
"""Close the standalone GET SSE stream to trigger client reconnection.
|
||||
|
||||
This method closes the HTTP connection for the standalone GET stream used
|
||||
for unsolicited server-to-client notifications. The client SHOULD reconnect
|
||||
with Last-Event-ID to resume receiving notifications.
|
||||
|
||||
Note:
|
||||
This is a no-op if not using StreamableHTTP transport with event_store.
|
||||
Currently, client reconnection for standalone GET streams is NOT
|
||||
implemented - this is a known gap.
|
||||
"""
|
||||
if self._request_context and self._request_context.close_standalone_sse_stream: # pragma: no cover
|
||||
await self._request_context.close_standalone_sse_stream()
|
||||
|
||||
# Convenience methods for common log levels
|
||||
async def debug(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
|
||||
"""Send a debug log message."""
|
||||
await self.log("debug", message, logger_name=logger_name, extra=extra)
|
||||
|
||||
async def info(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
|
||||
"""Send an info log message."""
|
||||
await self.log("info", message, logger_name=logger_name, extra=extra)
|
||||
|
||||
async def warning(
|
||||
self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Send a warning log message."""
|
||||
await self.log("warning", message, logger_name=logger_name, extra=extra)
|
||||
|
||||
async def error(self, message: str, *, logger_name: str | None = None, extra: dict[str, Any] | None = None) -> None:
|
||||
"""Send an error log message."""
|
||||
await self.log("error", message, logger_name=logger_name, extra=extra)
|
||||
|
||||
@@ -17,7 +17,7 @@ from mcp.types import Icon, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.context import LifespanContextT, RequestT
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
|
||||
class Tool(BaseModel):
|
||||
@@ -92,10 +92,14 @@ class Tool(BaseModel):
|
||||
async def run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
context: Context[LifespanContextT, RequestT] | None = None,
|
||||
context: Context[LifespanContextT, RequestT],
|
||||
convert_result: bool = False,
|
||||
) -> Any:
|
||||
"""Run the tool with arguments."""
|
||||
"""Run the tool with arguments.
|
||||
|
||||
Raises:
|
||||
ToolError: If the tool function raises during execution.
|
||||
"""
|
||||
try:
|
||||
result = await self.fn_metadata.call_fn_with_arg_validation(
|
||||
self.fn,
|
||||
|
||||
@@ -10,7 +10,7 @@ from mcp.types import Icon, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.context import LifespanContextT, RequestT
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -81,7 +81,7 @@ class ToolManager:
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
context: Context[LifespanContextT, RequestT] | None = None,
|
||||
context: Context[LifespanContextT, RequestT],
|
||||
convert_result: bool = False,
|
||||
) -> Any:
|
||||
"""Call a tool by name with arguments."""
|
||||
@@ -89,4 +89,4 @@ class ToolManager:
|
||||
if not tool:
|
||||
raise ToolError(f"Unknown tool: {name}")
|
||||
|
||||
return await tool.run(arguments, context=context, convert_result=convert_result)
|
||||
return await tool.run(arguments, context, convert_result=convert_result)
|
||||
|
||||
@@ -7,6 +7,8 @@ import typing
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
|
||||
def find_context_parameter(fn: Callable[..., Any]) -> str | None:
|
||||
"""Find the parameter that should receive the Context object.
|
||||
@@ -20,8 +22,6 @@ def find_context_parameter(fn: Callable[..., Any]) -> str | None:
|
||||
Returns:
|
||||
The name of the context parameter, or None if not found
|
||||
"""
|
||||
from mcp.server.mcpserver.server import Context
|
||||
|
||||
# Get type hints to properly resolve string annotations
|
||||
try:
|
||||
hints = typing.get_type_hints(fn)
|
||||
|
||||
@@ -3,8 +3,7 @@ from pydantic import FileUrl
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.types import ListRootsResult, Root, TextContent
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any, Literal
|
||||
import pytest
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
LoggingMessageNotificationParams,
|
||||
@@ -33,14 +33,10 @@ async def test_logging_callback():
|
||||
# Create a function that can send a log notification
|
||||
@server.tool("test_tool_with_log")
|
||||
async def test_tool_with_log(
|
||||
message: str, level: Literal["debug", "info", "warning", "error"], logger: str
|
||||
message: str, level: Literal["debug", "info", "warning", "error"], logger: str, ctx: Context
|
||||
) -> bool:
|
||||
"""Send a log notification to the client."""
|
||||
await server.get_context().log(
|
||||
level=level,
|
||||
message=message,
|
||||
logger_name=logger,
|
||||
)
|
||||
await ctx.log(level=level, message=message, logger_name=logger)
|
||||
return True
|
||||
|
||||
@server.tool("test_tool_with_log_extra")
|
||||
@@ -50,9 +46,10 @@ async def test_logging_callback():
|
||||
logger: str,
|
||||
extra_string: str,
|
||||
extra_dict: dict[str, Any],
|
||||
ctx: Context,
|
||||
) -> bool:
|
||||
"""Send a log notification to the client with extra fields."""
|
||||
await server.get_context().log(
|
||||
await ctx.log(
|
||||
level=level,
|
||||
message=message,
|
||||
logger_name=logger,
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytest
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.types import (
|
||||
CreateMessageRequestParams,
|
||||
@@ -32,8 +32,8 @@ async def test_sampling_callback():
|
||||
return callback_return
|
||||
|
||||
@server.tool("test_sampling")
|
||||
async def test_sampling_tool(message: str):
|
||||
value = await server.get_context().session.create_message(
|
||||
async def test_sampling_tool(message: str, ctx: Context) -> bool:
|
||||
value = await ctx.session.create_message(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
|
||||
max_tokens=100,
|
||||
)
|
||||
@@ -77,9 +77,9 @@ async def test_create_message_backwards_compat_single_content():
|
||||
return callback_return
|
||||
|
||||
@server.tool("test_backwards_compat")
|
||||
async def test_tool(message: str):
|
||||
async def test_tool(message: str, ctx: Context) -> bool:
|
||||
# Call create_message WITHOUT tools
|
||||
result = await server.get_context().session.create_message(
|
||||
result = await ctx.session.create_message(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
|
||||
from mcp.types import EmbeddedResource, TextContent, TextResourceContents
|
||||
|
||||
@@ -13,7 +14,9 @@ class TestRenderPrompt:
|
||||
return "Hello, world!"
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
assert await prompt.render(None, Context()) == [
|
||||
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_fn(self):
|
||||
@@ -21,7 +24,9 @@ class TestRenderPrompt:
|
||||
return "Hello, world!"
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
assert await prompt.render(None, Context()) == [
|
||||
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_with_args(self):
|
||||
@@ -29,7 +34,7 @@ class TestRenderPrompt:
|
||||
return f"Hello, {name}! You're {age} years old."
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render(arguments={"name": "World"}) == [
|
||||
assert await prompt.render({"name": "World"}, Context()) == [
|
||||
UserMessage(content=TextContent(type="text", text="Hello, World! You're 30 years old."))
|
||||
]
|
||||
|
||||
@@ -40,7 +45,7 @@ class TestRenderPrompt:
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
with pytest.raises(ValueError):
|
||||
await prompt.render(arguments={"age": 40})
|
||||
await prompt.render({"age": 40}, Context())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_message(self):
|
||||
@@ -48,7 +53,9 @@ class TestRenderPrompt:
|
||||
return UserMessage(content="Hello, world!")
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
assert await prompt.render(None, Context()) == [
|
||||
UserMessage(content=TextContent(type="text", text="Hello, world!"))
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_assistant_message(self):
|
||||
@@ -56,7 +63,9 @@ class TestRenderPrompt:
|
||||
return AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [AssistantMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
assert await prompt.render(None, Context()) == [
|
||||
AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_multiple_messages(self):
|
||||
@@ -70,7 +79,7 @@ class TestRenderPrompt:
|
||||
return expected
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == expected
|
||||
assert await prompt.render(None, Context()) == expected
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_list_of_strings(self):
|
||||
@@ -83,7 +92,7 @@ class TestRenderPrompt:
|
||||
return expected
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [UserMessage(t) for t in expected]
|
||||
assert await prompt.render(None, Context()) == [UserMessage(t) for t in expected]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_resource_content(self):
|
||||
@@ -102,7 +111,7 @@ class TestRenderPrompt:
|
||||
)
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [
|
||||
assert await prompt.render(None, Context()) == [
|
||||
UserMessage(
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
@@ -136,7 +145,7 @@ class TestRenderPrompt:
|
||||
]
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [
|
||||
assert await prompt.render(None, Context()) == [
|
||||
UserMessage(content=TextContent(type="text", text="Please analyze this file:")),
|
||||
UserMessage(
|
||||
content=EmbeddedResource(
|
||||
@@ -169,7 +178,7 @@ class TestRenderPrompt:
|
||||
}
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [
|
||||
assert await prompt.render(None, Context()) == [
|
||||
UserMessage(
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
|
||||
from mcp.server.mcpserver.prompts.manager import PromptManager
|
||||
from mcp.types import TextContent
|
||||
@@ -72,7 +73,7 @@ class TestPromptManager:
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(fn)
|
||||
manager.add_prompt(prompt)
|
||||
messages = await manager.render_prompt("fn")
|
||||
messages = await manager.render_prompt("fn", None, Context())
|
||||
assert messages == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -85,7 +86,7 @@ class TestPromptManager:
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(fn)
|
||||
manager.add_prompt(prompt)
|
||||
messages = await manager.render_prompt("fn", arguments={"name": "World"})
|
||||
messages = await manager.render_prompt("fn", {"name": "World"}, Context())
|
||||
assert messages == [UserMessage(content=TextContent(type="text", text="Hello, World!"))]
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -93,7 +94,7 @@ class TestPromptManager:
|
||||
"""Test rendering a non-existent prompt."""
|
||||
manager = PromptManager()
|
||||
with pytest.raises(ValueError, match="Unknown prompt: unknown"):
|
||||
await manager.render_prompt("unknown")
|
||||
await manager.render_prompt("unknown", None, Context())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_prompt_with_missing_args(self):
|
||||
@@ -106,4 +107,4 @@ class TestPromptManager:
|
||||
prompt = Prompt.from_function(fn)
|
||||
manager.add_prompt(prompt)
|
||||
with pytest.raises(ValueError, match="Missing required arguments"):
|
||||
await manager.render_prompt("fn")
|
||||
await manager.render_prompt("fn", None, Context())
|
||||
|
||||
@@ -4,6 +4,7 @@ from tempfile import NamedTemporaryFile
|
||||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate
|
||||
|
||||
|
||||
@@ -86,7 +87,7 @@ class TestResourceManager:
|
||||
path=temp_file,
|
||||
)
|
||||
manager.add_resource(resource)
|
||||
retrieved = await manager.get_resource(resource.uri)
|
||||
retrieved = await manager.get_resource(resource.uri, Context())
|
||||
assert retrieved == resource
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -104,7 +105,7 @@ class TestResourceManager:
|
||||
)
|
||||
manager._templates[template.uri_template] = template
|
||||
|
||||
resource = await manager.get_resource(AnyUrl("greet://world"))
|
||||
resource = await manager.get_resource(AnyUrl("greet://world"), Context())
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
@@ -114,7 +115,7 @@ class TestResourceManager:
|
||||
"""Test getting a non-existent resource."""
|
||||
manager = ResourceManager()
|
||||
with pytest.raises(ValueError, match="Unknown resource"):
|
||||
await manager.get_resource(AnyUrl("unknown://test"))
|
||||
await manager.get_resource(AnyUrl("unknown://test"), Context())
|
||||
|
||||
def test_list_resources(self, temp_file: Path):
|
||||
"""Test listing all resources."""
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate
|
||||
from mcp.types import Annotations
|
||||
|
||||
@@ -64,6 +64,7 @@ class TestResourceTemplate:
|
||||
resource = await template.create_resource(
|
||||
"test://foo/123",
|
||||
{"key": "foo", "value": 123},
|
||||
Context(),
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
@@ -86,7 +87,7 @@ class TestResourceTemplate:
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Error creating resource from template"):
|
||||
await template.create_resource("fail://test", {"x": "test"})
|
||||
await template.create_resource("fail://test", {"x": "test"}, Context())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_text_resource(self):
|
||||
@@ -104,6 +105,7 @@ class TestResourceTemplate:
|
||||
resource = await template.create_resource(
|
||||
"greet://world",
|
||||
{"name": "world"},
|
||||
Context(),
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
@@ -126,6 +128,7 @@ class TestResourceTemplate:
|
||||
resource = await template.create_resource(
|
||||
"bytes://test",
|
||||
{"value": "test"},
|
||||
Context(),
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
@@ -152,6 +155,7 @@ class TestResourceTemplate:
|
||||
resource = await template.create_resource(
|
||||
"test://foo/123",
|
||||
{"key": "foo", "value": 123},
|
||||
Context(),
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
@@ -183,6 +187,7 @@ class TestResourceTemplate:
|
||||
resource = await template.create_resource(
|
||||
"test://hello",
|
||||
{"value": "hello"},
|
||||
Context(),
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
@@ -249,7 +254,7 @@ class TestResourceTemplateAnnotations:
|
||||
)
|
||||
|
||||
# Create a resource from the template
|
||||
resource = await template.create_resource("resource://items/123", {"item_id": "123"})
|
||||
resource = await template.create_resource("resource://items/123", {"item_id": "123"}, Context())
|
||||
|
||||
# The resource should inherit the template's annotations
|
||||
assert resource.annotations is not None
|
||||
@@ -298,7 +303,7 @@ class TestResourceTemplateMetadata:
|
||||
)
|
||||
|
||||
# Create a resource from the template
|
||||
resource = await template.create_resource("resource://items/123", {"item_id": "123"})
|
||||
resource = await template.create_resource("resource://items/123", {"item_id": "123"}, Context())
|
||||
|
||||
# The resource should inherit the template's metadata
|
||||
assert resource.meta is not None
|
||||
|
||||
@@ -891,7 +891,7 @@ class TestServerResourceTemplates:
|
||||
assert len(await mcp.list_resources()) == 0
|
||||
|
||||
# When accessed, should create a concrete resource
|
||||
resource = await mcp._resource_manager.get_resource("resource://test/data")
|
||||
resource = await mcp._resource_manager.get_resource("resource://test/data", Context())
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result == "Data for test"
|
||||
@@ -1231,6 +1231,19 @@ class TestContextInjection:
|
||||
class TestServerPrompts:
|
||||
"""Test prompt functionality in MCPServer server."""
|
||||
|
||||
async def test_get_prompt_direct_call_without_context(self):
|
||||
"""Test calling mcp.get_prompt() directly without passing context."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.prompt()
|
||||
def fn() -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
result = await mcp.get_prompt("fn")
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "Hello, world!"
|
||||
|
||||
async def test_prompt_decorator(self):
|
||||
"""Test that the prompt decorator registers prompts correctly."""
|
||||
mcp = MCPServer()
|
||||
@@ -1243,7 +1256,7 @@ class TestServerPrompts:
|
||||
assert len(prompts) == 1
|
||||
assert prompts[0].name == "fn"
|
||||
# Don't compare functions directly since validate_call wraps them
|
||||
content = await prompts[0].render()
|
||||
content = await prompts[0].render(None, Context())
|
||||
assert isinstance(content[0].content, TextContent)
|
||||
assert content[0].content.text == "Hello, world!"
|
||||
|
||||
@@ -1258,7 +1271,7 @@ class TestServerPrompts:
|
||||
prompts = mcp._prompt_manager.list_prompts()
|
||||
assert len(prompts) == 1
|
||||
assert prompts[0].name == "custom_name"
|
||||
content = await prompts[0].render()
|
||||
content = await prompts[0].render(None, Context())
|
||||
assert isinstance(content[0].content, TextContent)
|
||||
assert content[0].content.text == "Hello, world!"
|
||||
|
||||
@@ -1273,7 +1286,7 @@ class TestServerPrompts:
|
||||
prompts = mcp._prompt_manager.list_prompts()
|
||||
assert len(prompts) == 1
|
||||
assert prompts[0].description == "A custom description"
|
||||
content = await prompts[0].render()
|
||||
content = await prompts[0].render(None, Context())
|
||||
assert isinstance(content[0].content, TextContent)
|
||||
assert content[0].content.text == "Hello, world!"
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ class TestCallTools:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum)
|
||||
result = await manager.call_tool("sum", {"a": 1, "b": 2})
|
||||
result = await manager.call_tool("sum", {"a": 1, "b": 2}, Context())
|
||||
assert result == 3
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -199,7 +199,7 @@ class TestCallTools:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(double)
|
||||
result = await manager.call_tool("double", {"n": 5})
|
||||
result = await manager.call_tool("double", {"n": 5}, Context())
|
||||
assert result == 10
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -213,7 +213,7 @@ class TestCallTools:
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(MyTool())
|
||||
result = await tool.run({"x": 5})
|
||||
result = await tool.run({"x": 5}, Context())
|
||||
assert result == 10
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -227,7 +227,7 @@ class TestCallTools:
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(MyAsyncTool())
|
||||
result = await tool.run({"x": 5})
|
||||
result = await tool.run({"x": 5}, Context())
|
||||
assert result == 10
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -238,7 +238,7 @@ class TestCallTools:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum)
|
||||
result = await manager.call_tool("sum", {"a": 1})
|
||||
result = await manager.call_tool("sum", {"a": 1}, Context())
|
||||
assert result == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -250,13 +250,13 @@ class TestCallTools:
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum)
|
||||
with pytest.raises(ToolError):
|
||||
await manager.call_tool("sum", {"a": 1})
|
||||
await manager.call_tool("sum", {"a": 1}, Context())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_unknown_tool(self):
|
||||
manager = ToolManager()
|
||||
with pytest.raises(ToolError):
|
||||
await manager.call_tool("unknown", {"a": 1})
|
||||
await manager.call_tool("unknown", {"a": 1}, Context())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_tool_with_list_int_input(self):
|
||||
@@ -266,9 +266,9 @@ class TestCallTools:
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum_vals)
|
||||
# Try both with plain list and with JSON list
|
||||
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
|
||||
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"}, Context())
|
||||
assert result == 6
|
||||
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
|
||||
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}, Context())
|
||||
assert result == 6
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -279,13 +279,13 @@ class TestCallTools:
|
||||
manager = ToolManager()
|
||||
manager.add_tool(concat_strs)
|
||||
# Try both with plain python object and with JSON list
|
||||
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
|
||||
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}, Context())
|
||||
assert result == "abc"
|
||||
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
|
||||
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'}, Context())
|
||||
assert result == "abc"
|
||||
result = await manager.call_tool("concat_strs", {"vals": "a"})
|
||||
result = await manager.call_tool("concat_strs", {"vals": "a"}, Context())
|
||||
assert result == "a"
|
||||
result = await manager.call_tool("concat_strs", {"vals": '"a"'})
|
||||
result = await manager.call_tool("concat_strs", {"vals": '"a"'}, Context())
|
||||
assert result == '"a"'
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -297,7 +297,7 @@ class TestCallTools:
|
||||
shrimp: list[Shrimp]
|
||||
x: None
|
||||
|
||||
def name_shrimp(tank: MyShrimpTank, ctx: Context[ServerSessionT, None]) -> list[str]:
|
||||
def name_shrimp(tank: MyShrimpTank) -> list[str]:
|
||||
return [x.name for x in tank.shrimp]
|
||||
|
||||
manager = ToolManager()
|
||||
@@ -305,11 +305,13 @@ class TestCallTools:
|
||||
result = await manager.call_tool(
|
||||
"name_shrimp",
|
||||
{"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
|
||||
Context(),
|
||||
)
|
||||
assert result == ["rex", "gertrude"]
|
||||
result = await manager.call_tool(
|
||||
"name_shrimp",
|
||||
{"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
|
||||
Context(),
|
||||
)
|
||||
assert result == ["rex", "gertrude"]
|
||||
|
||||
@@ -364,9 +366,7 @@ class TestContextHandling:
|
||||
manager = ToolManager()
|
||||
manager.add_tool(tool_with_context)
|
||||
|
||||
mcp = MCPServer()
|
||||
ctx = mcp.get_context()
|
||||
result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
|
||||
result = await manager.call_tool("tool_with_context", {"x": 42}, context=Context())
|
||||
assert result == "42"
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -380,22 +380,7 @@ class TestContextHandling:
|
||||
manager = ToolManager()
|
||||
manager.add_tool(async_tool)
|
||||
|
||||
mcp = MCPServer()
|
||||
ctx = mcp.get_context()
|
||||
result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
|
||||
assert result == "42"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_optional(self):
|
||||
"""Test that context is optional when calling tools."""
|
||||
|
||||
def tool_with_context(x: int, ctx: Context[ServerSessionT, None] | None = None) -> str:
|
||||
return str(x)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(tool_with_context)
|
||||
# Should not raise an error when context is not provided
|
||||
result = await manager.call_tool("tool_with_context", {"x": 42})
|
||||
result = await manager.call_tool("async_tool", {"x": 42}, context=Context())
|
||||
assert result == "42"
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -408,10 +393,8 @@ class TestContextHandling:
|
||||
manager = ToolManager()
|
||||
manager.add_tool(tool_with_context)
|
||||
|
||||
mcp = MCPServer()
|
||||
ctx = mcp.get_context()
|
||||
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
|
||||
await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
|
||||
await manager.call_tool("tool_with_context", {"x": 42}, context=Context())
|
||||
|
||||
|
||||
class TestToolAnnotations:
|
||||
@@ -471,7 +454,7 @@ class TestStructuredOutput:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_user)
|
||||
result = await manager.call_tool("get_user", {"user_id": 1}, convert_result=True)
|
||||
result = await manager.call_tool("get_user", {"user_id": 1}, Context(), convert_result=True)
|
||||
# don't test unstructured output here, just the structured conversion
|
||||
assert len(result) == 2 and result[1] == {"name": "John", "age": 30}
|
||||
|
||||
@@ -485,9 +468,9 @@ class TestStructuredOutput:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(double_number)
|
||||
result = await manager.call_tool("double_number", {"n": 5})
|
||||
result = await manager.call_tool("double_number", {"n": 5}, Context())
|
||||
assert result == 10
|
||||
result = await manager.call_tool("double_number", {"n": 5}, convert_result=True)
|
||||
result = await manager.call_tool("double_number", {"n": 5}, Context(), convert_result=True)
|
||||
assert isinstance(result[0][0], TextContent) and result[1] == {"result": 10}
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -506,7 +489,7 @@ class TestStructuredOutput:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_user_dict)
|
||||
result = await manager.call_tool("get_user_dict", {"user_id": 1})
|
||||
result = await manager.call_tool("get_user_dict", {"user_id": 1}, Context())
|
||||
assert result == expected_output
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -526,7 +509,7 @@ class TestStructuredOutput:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_person)
|
||||
result = await manager.call_tool("get_person", {}, convert_result=True)
|
||||
result = await manager.call_tool("get_person", {}, Context(), convert_result=True)
|
||||
# don't test unstructured output here, just the structured conversion
|
||||
assert len(result) == 2 and result[1] == expected_output
|
||||
|
||||
@@ -543,9 +526,9 @@ class TestStructuredOutput:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_numbers)
|
||||
result = await manager.call_tool("get_numbers", {})
|
||||
result = await manager.call_tool("get_numbers", {}, Context())
|
||||
assert result == expected_list
|
||||
result = await manager.call_tool("get_numbers", {}, convert_result=True)
|
||||
result = await manager.call_tool("get_numbers", {}, Context(), convert_result=True)
|
||||
assert isinstance(result[0][0], TextContent) and result[1] == expected_output
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -558,7 +541,7 @@ class TestStructuredOutput:
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_dict, structured_output=False)
|
||||
result = await manager.call_tool("get_dict", {})
|
||||
result = await manager.call_tool("get_dict", {}, Context())
|
||||
assert isinstance(result, dict)
|
||||
assert result == {"key": "value"}
|
||||
|
||||
@@ -601,12 +584,12 @@ class TestStructuredOutput:
|
||||
assert "properties" not in tool.output_schema # dict[str, Any] has no constraints
|
||||
|
||||
# Test raw result
|
||||
result = await manager.call_tool("get_config", {})
|
||||
result = await manager.call_tool("get_config", {}, Context())
|
||||
expected = {"debug": True, "port": 8080, "features": ["auth", "logging"]}
|
||||
assert result == expected
|
||||
|
||||
# Test converted result
|
||||
result = await manager.call_tool("get_config", {})
|
||||
result = await manager.call_tool("get_config", {}, Context())
|
||||
assert result == expected
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -626,12 +609,12 @@ class TestStructuredOutput:
|
||||
assert tool.output_schema["additionalProperties"]["type"] == "integer"
|
||||
|
||||
# Test raw result
|
||||
result = await manager.call_tool("get_scores", {})
|
||||
result = await manager.call_tool("get_scores", {}, Context())
|
||||
expected = {"alice": 100, "bob": 85, "charlie": 92}
|
||||
assert result == expected
|
||||
|
||||
# Test converted result
|
||||
result = await manager.call_tool("get_scores", {})
|
||||
result = await manager.call_tool("get_scores", {}, Context())
|
||||
assert result == expected
|
||||
|
||||
|
||||
@@ -885,7 +868,7 @@ class TestRemoveTools:
|
||||
manager.add_tool(greet)
|
||||
|
||||
# Verify tool works before removal
|
||||
result = await manager.call_tool("greet", {"name": "World"})
|
||||
result = await manager.call_tool("greet", {"name": "World"}, Context())
|
||||
assert result == "Hello, World!"
|
||||
|
||||
# Remove the tool
|
||||
@@ -893,7 +876,7 @@ class TestRemoveTools:
|
||||
|
||||
# Verify calling removed tool raises error
|
||||
with pytest.raises(ToolError, match="Unknown tool: greet"):
|
||||
await manager.call_tool("greet", {"name": "World"})
|
||||
await manager.call_tool("greet", {"name": "World"}, Context())
|
||||
|
||||
def test_remove_tool_case_sensitive(self):
|
||||
"""Test that tool removal is case-sensitive."""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.server.mcpserver.tools.base import Tool
|
||||
|
||||
|
||||
def test_context_detected_in_union_annotation():
|
||||
def my_tool(x: int, ctx: Context | None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
tool = Tool.from_function(my_tool)
|
||||
assert tool.context_kwarg == "ctx"
|
||||
Reference in New Issue
Block a user