refactor: remove request_ctx ContextVar, thread Context explicitly (#2203)

Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
Max Isbey
2026-03-04 13:23:02 +00:00
committed by GitHub
parent 62575edabd
commit cc22bf5464
23 changed files with 484 additions and 413 deletions
+32 -1
View File
@@ -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):**
+1 -8
View File
@@ -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 -1
View File
@@ -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"]
+280
View File
@@ -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)
+8 -4
View File
@@ -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}
+4 -4
View File
@@ -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)
+27 -289
View File
@@ -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)
+7 -3
View File
@@ -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)
+1 -2
View File
@@ -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
+5 -8
View File
@@ -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,
+5 -5
View File
@@ -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,
)
+20 -11
View File
@@ -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
+17 -4
View File
@@ -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!"
+33 -50
View File
@@ -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."""
+10
View File
@@ -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"