refactor: remove request_ctx ContextVar, thread Context explicitly (#2203)
Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
This commit is contained in:
parent
62575edabd
commit
cc22bf5464
23 changed files with 484 additions and 413 deletions
|
|
@ -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.
|
**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
|
### Replace `RootModel` by union types with `TypeAdapter` validation
|
||||||
|
|
||||||
The following union types are no longer `RootModel` subclasses:
|
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
|
### 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):**
|
**Before (v1):**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ handler callables by method string.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import contextvars
|
|
||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
|
|
@ -74,8 +73,6 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
LifespanResultT = TypeVar("LifespanResultT", default=Any)
|
LifespanResultT = TypeVar("LifespanResultT", default=Any)
|
||||||
|
|
||||||
request_ctx: contextvars.ContextVar[ServerRequestContext[Any]] = contextvars.ContextVar("request_ctx")
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationOptions:
|
class NotificationOptions:
|
||||||
def __init__(self, prompts_changed: bool = False, resources_changed: bool = False, tools_changed: bool = False):
|
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_sse_stream=close_sse_stream_cb,
|
||||||
close_standalone_sse_stream=close_standalone_sse_stream_cb,
|
close_standalone_sse_stream=close_standalone_sse_stream_cb,
|
||||||
)
|
)
|
||||||
token = request_ctx.set(ctx)
|
response = await handler(ctx, req.params)
|
||||||
try:
|
|
||||||
response = await handler(ctx, req.params)
|
|
||||||
finally:
|
|
||||||
request_ctx.reset(token)
|
|
||||||
except MCPError as err:
|
except MCPError as err:
|
||||||
response = err.error
|
response = err.error
|
||||||
except anyio.get_cancelled_exc_class():
|
except anyio.get_cancelled_exc_class():
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
from mcp.types import Icon
|
from mcp.types import Icon
|
||||||
|
|
||||||
from .server import Context, MCPServer
|
from .context import Context
|
||||||
|
from .server import MCPServer
|
||||||
from .utilities.types import Audio, Image
|
from .utilities.types import Audio, Image
|
||||||
|
|
||||||
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
|
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
|
||||||
|
|
|
||||||
280
src/mcp/server/mcpserver/context.py
Normal file
280
src/mcp/server/mcpserver/context.py
Normal 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)
|
||||||
|
|
@ -15,7 +15,7 @@ from mcp.types import ContentBlock, Icon, TextContent
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.context import LifespanContextT, RequestT
|
from mcp.server.context import LifespanContextT, RequestT
|
||||||
from mcp.server.mcpserver.server import Context
|
from mcp.server.mcpserver.context import Context
|
||||||
|
|
||||||
|
|
||||||
class Message(BaseModel):
|
class Message(BaseModel):
|
||||||
|
|
@ -135,10 +135,14 @@ class Prompt(BaseModel):
|
||||||
|
|
||||||
async def render(
|
async def render(
|
||||||
self,
|
self,
|
||||||
arguments: dict[str, Any] | None = None,
|
arguments: dict[str, Any] | None,
|
||||||
context: Context[LifespanContextT, RequestT] | None = None,
|
context: Context[LifespanContextT, RequestT],
|
||||||
) -> list[Message]:
|
) -> 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
|
# Validate required arguments
|
||||||
if self.arguments:
|
if self.arguments:
|
||||||
required = {arg.name for arg in self.arguments if arg.required}
|
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:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.context import LifespanContextT, RequestT
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -48,12 +48,12 @@ class PromptManager:
|
||||||
async def render_prompt(
|
async def render_prompt(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
arguments: dict[str, Any] | None = None,
|
arguments: dict[str, Any] | None,
|
||||||
context: Context[LifespanContextT, RequestT] | None = None,
|
context: Context[LifespanContextT, RequestT],
|
||||||
) -> list[Message]:
|
) -> list[Message]:
|
||||||
"""Render a prompt by name with arguments."""
|
"""Render a prompt by name with arguments."""
|
||||||
prompt = self.get_prompt(name)
|
prompt = self.get_prompt(name)
|
||||||
if not prompt:
|
if not prompt:
|
||||||
raise ValueError(f"Unknown prompt: {name}")
|
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:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.context import LifespanContextT, RequestT
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -80,9 +80,7 @@ class ResourceManager:
|
||||||
self._templates[template.uri_template] = template
|
self._templates[template.uri_template] = template
|
||||||
return template
|
return template
|
||||||
|
|
||||||
async def get_resource(
|
async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]) -> Resource:
|
||||||
self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] | None = None
|
|
||||||
) -> Resource:
|
|
||||||
"""Get resource by URI, checking concrete resources first, then templates."""
|
"""Get resource by URI, checking concrete resources first, then templates."""
|
||||||
uri_str = str(uri)
|
uri_str = str(uri)
|
||||||
logger.debug("Getting resource", extra={"uri": uri_str})
|
logger.debug("Getting resource", extra={"uri": uri_str})
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from mcp.types import Annotations, Icon
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.context import LifespanContextT, RequestT
|
from mcp.server.context import LifespanContextT, RequestT
|
||||||
from mcp.server.mcpserver.server import Context
|
from mcp.server.mcpserver.context import Context
|
||||||
|
|
||||||
|
|
||||||
class ResourceTemplate(BaseModel):
|
class ResourceTemplate(BaseModel):
|
||||||
|
|
@ -99,9 +99,13 @@ class ResourceTemplate(BaseModel):
|
||||||
self,
|
self,
|
||||||
uri: str,
|
uri: str,
|
||||||
params: dict[str, Any],
|
params: dict[str, Any],
|
||||||
context: Context[LifespanContextT, RequestT] | None = None,
|
context: Context[LifespanContextT, RequestT],
|
||||||
) -> Resource:
|
) -> 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:
|
try:
|
||||||
# Add context to params if needed
|
# Add context to params if needed
|
||||||
params = inject_context(self.fn, params, context, self.context_kwarg)
|
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 anyio
|
||||||
import pydantic_core
|
import pydantic_core
|
||||||
from pydantic import BaseModel
|
|
||||||
from pydantic.networks import AnyUrl
|
from pydantic.networks import AnyUrl
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from starlette.applications import Starlette
|
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.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
|
||||||
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
|
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
|
from mcp.server.context import 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.lowlevel.helper_types import ReadResourceContents
|
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.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.exceptions import ResourceError
|
||||||
from mcp.server.mcpserver.prompts import Prompt, PromptManager
|
from mcp.server.mcpserver.prompts import Prompt, PromptManager
|
||||||
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
|
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
|
||||||
|
|
@ -300,8 +298,9 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
async def _handle_call_tool(
|
async def _handle_call_tool(
|
||||||
self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams
|
self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams
|
||||||
) -> CallToolResult:
|
) -> CallToolResult:
|
||||||
|
context = Context(request_context=ctx, mcp_server=self)
|
||||||
try:
|
try:
|
||||||
result = await self.call_tool(params.name, params.arguments or {})
|
result = await self.call_tool(params.name, params.arguments or {}, context)
|
||||||
except MCPError:
|
except MCPError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -332,7 +331,8 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
async def _handle_read_resource(
|
async def _handle_read_resource(
|
||||||
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
|
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
|
||||||
) -> ReadResourceResult:
|
) -> 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] = []
|
contents: list[TextResourceContents | BlobResourceContents] = []
|
||||||
for item in results:
|
for item in results:
|
||||||
if isinstance(item.content, bytes):
|
if isinstance(item.content, bytes):
|
||||||
|
|
@ -368,7 +368,8 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
async def _handle_get_prompt(
|
async def _handle_get_prompt(
|
||||||
self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams
|
self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams
|
||||||
) -> GetPromptResult:
|
) -> 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]:
|
async def list_tools(self) -> list[MCPTool]:
|
||||||
"""List all available tools."""
|
"""List all available tools."""
|
||||||
|
|
@ -387,22 +388,13 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
for info in tools
|
for info in tools
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_context(self) -> Context[LifespanResultT, Request]:
|
async def call_tool(
|
||||||
"""Return a Context object.
|
self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None
|
||||||
|
) -> Sequence[ContentBlock] | dict[str, Any]:
|
||||||
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]:
|
|
||||||
"""Call a tool by name with arguments."""
|
"""Call a tool by name with arguments."""
|
||||||
context = self.get_context()
|
if context is None:
|
||||||
return await self._tool_manager.call_tool(name, arguments, context=context, convert_result=True)
|
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]:
|
async def list_resources(self) -> list[MCPResource]:
|
||||||
"""List all available resources."""
|
"""List all available resources."""
|
||||||
|
|
@ -438,12 +430,14 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
for template in templates
|
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."""
|
"""Read a resource by URI."""
|
||||||
|
if context is None:
|
||||||
context = self.get_context()
|
context = Context(mcp_server=self)
|
||||||
try:
|
try:
|
||||||
resource = await self._resource_manager.get_resource(uri, context=context)
|
resource = await self._resource_manager.get_resource(uri, context)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ResourceError(f"Unknown resource: {uri}")
|
raise ResourceError(f"Unknown resource: {uri}")
|
||||||
|
|
||||||
|
|
@ -1087,14 +1081,18 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
for prompt in prompts
|
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."""
|
"""Get a prompt by name with arguments."""
|
||||||
|
if context is None:
|
||||||
|
context = Context(mcp_server=self)
|
||||||
try:
|
try:
|
||||||
prompt = self._prompt_manager.get_prompt(name)
|
prompt = self._prompt_manager.get_prompt(name)
|
||||||
if not prompt:
|
if not prompt:
|
||||||
raise ValueError(f"Unknown prompt: {name}")
|
raise ValueError(f"Unknown prompt: {name}")
|
||||||
|
|
||||||
messages = await prompt.render(arguments, context=self.get_context())
|
messages = await prompt.render(arguments, context)
|
||||||
|
|
||||||
return GetPromptResult(
|
return GetPromptResult(
|
||||||
description=prompt.description,
|
description=prompt.description,
|
||||||
|
|
@ -1103,263 +1101,3 @@ class MCPServer(Generic[LifespanResultT]):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error getting prompt {name}")
|
logger.exception(f"Error getting prompt {name}")
|
||||||
raise ValueError(str(e))
|
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:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.context import LifespanContextT, RequestT
|
from mcp.server.context import LifespanContextT, RequestT
|
||||||
from mcp.server.mcpserver.server import Context
|
from mcp.server.mcpserver.context import Context
|
||||||
|
|
||||||
|
|
||||||
class Tool(BaseModel):
|
class Tool(BaseModel):
|
||||||
|
|
@ -92,10 +92,14 @@ class Tool(BaseModel):
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
context: Context[LifespanContextT, RequestT] | None = None,
|
context: Context[LifespanContextT, RequestT],
|
||||||
convert_result: bool = False,
|
convert_result: bool = False,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Run the tool with arguments."""
|
"""Run the tool with arguments.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ToolError: If the tool function raises during execution.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = await self.fn_metadata.call_fn_with_arg_validation(
|
result = await self.fn_metadata.call_fn_with_arg_validation(
|
||||||
self.fn,
|
self.fn,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from mcp.types import Icon, ToolAnnotations
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcp.server.context import LifespanContextT, RequestT
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -81,7 +81,7 @@ class ToolManager:
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
arguments: dict[str, Any],
|
arguments: dict[str, Any],
|
||||||
context: Context[LifespanContextT, RequestT] | None = None,
|
context: Context[LifespanContextT, RequestT],
|
||||||
convert_result: bool = False,
|
convert_result: bool = False,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Call a tool by name with arguments."""
|
"""Call a tool by name with arguments."""
|
||||||
|
|
@ -89,4 +89,4 @@ class ToolManager:
|
||||||
if not tool:
|
if not tool:
|
||||||
raise ToolError(f"Unknown tool: {name}")
|
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 collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from mcp.server.mcpserver.context import Context
|
||||||
|
|
||||||
|
|
||||||
def find_context_parameter(fn: Callable[..., Any]) -> str | None:
|
def find_context_parameter(fn: Callable[..., Any]) -> str | None:
|
||||||
"""Find the parameter that should receive the Context object.
|
"""Find the parameter that should receive the Context object.
|
||||||
|
|
@ -20,8 +22,6 @@ def find_context_parameter(fn: Callable[..., Any]) -> str | None:
|
||||||
Returns:
|
Returns:
|
||||||
The name of the context parameter, or None if not found
|
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
|
# Get type hints to properly resolve string annotations
|
||||||
try:
|
try:
|
||||||
hints = typing.get_type_hints(fn)
|
hints = typing.get_type_hints(fn)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ from pydantic import FileUrl
|
||||||
|
|
||||||
from mcp import Client
|
from mcp import Client
|
||||||
from mcp.client.session import ClientSession
|
from mcp.client.session import ClientSession
|
||||||
from mcp.server.mcpserver import MCPServer
|
from mcp.server.mcpserver import Context, MCPServer
|
||||||
from mcp.server.mcpserver.server import Context
|
|
||||||
from mcp.shared._context import RequestContext
|
from mcp.shared._context import RequestContext
|
||||||
from mcp.types import ListRootsResult, Root, TextContent
|
from mcp.types import ListRootsResult, Root, TextContent
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from typing import Any, Literal
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mcp import Client, types
|
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.shared.session import RequestResponder
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
LoggingMessageNotificationParams,
|
LoggingMessageNotificationParams,
|
||||||
|
|
@ -33,14 +33,10 @@ async def test_logging_callback():
|
||||||
# Create a function that can send a log notification
|
# Create a function that can send a log notification
|
||||||
@server.tool("test_tool_with_log")
|
@server.tool("test_tool_with_log")
|
||||||
async def 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:
|
) -> bool:
|
||||||
"""Send a log notification to the client."""
|
"""Send a log notification to the client."""
|
||||||
await server.get_context().log(
|
await ctx.log(level=level, message=message, logger_name=logger)
|
||||||
level=level,
|
|
||||||
message=message,
|
|
||||||
logger_name=logger,
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@server.tool("test_tool_with_log_extra")
|
@server.tool("test_tool_with_log_extra")
|
||||||
|
|
@ -50,9 +46,10 @@ async def test_logging_callback():
|
||||||
logger: str,
|
logger: str,
|
||||||
extra_string: str,
|
extra_string: str,
|
||||||
extra_dict: dict[str, Any],
|
extra_dict: dict[str, Any],
|
||||||
|
ctx: Context,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send a log notification to the client with extra fields."""
|
"""Send a log notification to the client with extra fields."""
|
||||||
await server.get_context().log(
|
await ctx.log(
|
||||||
level=level,
|
level=level,
|
||||||
message=message,
|
message=message,
|
||||||
logger_name=logger,
|
logger_name=logger,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import pytest
|
||||||
|
|
||||||
from mcp import Client
|
from mcp import Client
|
||||||
from mcp.client.session import ClientSession
|
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.shared._context import RequestContext
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
CreateMessageRequestParams,
|
CreateMessageRequestParams,
|
||||||
|
|
@ -32,8 +32,8 @@ async def test_sampling_callback():
|
||||||
return callback_return
|
return callback_return
|
||||||
|
|
||||||
@server.tool("test_sampling")
|
@server.tool("test_sampling")
|
||||||
async def test_sampling_tool(message: str):
|
async def test_sampling_tool(message: str, ctx: Context) -> bool:
|
||||||
value = await server.get_context().session.create_message(
|
value = await ctx.session.create_message(
|
||||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
|
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
|
||||||
max_tokens=100,
|
max_tokens=100,
|
||||||
)
|
)
|
||||||
|
|
@ -77,9 +77,9 @@ async def test_create_message_backwards_compat_single_content():
|
||||||
return callback_return
|
return callback_return
|
||||||
|
|
||||||
@server.tool("test_backwards_compat")
|
@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
|
# 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))],
|
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=message))],
|
||||||
max_tokens=100,
|
max_tokens=100,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from mcp.server.mcpserver import Context
|
||||||
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
|
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
|
||||||
from mcp.types import EmbeddedResource, TextContent, TextResourceContents
|
from mcp.types import EmbeddedResource, TextContent, TextResourceContents
|
||||||
|
|
||||||
|
|
@ -13,7 +14,9 @@ class TestRenderPrompt:
|
||||||
return "Hello, world!"
|
return "Hello, world!"
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_async_fn(self):
|
async def test_async_fn(self):
|
||||||
|
|
@ -21,7 +24,9 @@ class TestRenderPrompt:
|
||||||
return "Hello, world!"
|
return "Hello, world!"
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_fn_with_args(self):
|
async def test_fn_with_args(self):
|
||||||
|
|
@ -29,7 +34,7 @@ class TestRenderPrompt:
|
||||||
return f"Hello, {name}! You're {age} years old."
|
return f"Hello, {name}! You're {age} years old."
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
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."))
|
UserMessage(content=TextContent(type="text", text="Hello, World! You're 30 years old."))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -40,7 +45,7 @@ class TestRenderPrompt:
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
prompt = Prompt.from_function(fn)
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await prompt.render(arguments={"age": 40})
|
await prompt.render({"age": 40}, Context())
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_fn_returns_message(self):
|
async def test_fn_returns_message(self):
|
||||||
|
|
@ -48,7 +53,9 @@ class TestRenderPrompt:
|
||||||
return UserMessage(content="Hello, world!")
|
return UserMessage(content="Hello, world!")
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_fn_returns_assistant_message(self):
|
async def test_fn_returns_assistant_message(self):
|
||||||
|
|
@ -56,7 +63,9 @@ class TestRenderPrompt:
|
||||||
return AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
|
return AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_fn_returns_multiple_messages(self):
|
async def test_fn_returns_multiple_messages(self):
|
||||||
|
|
@ -70,7 +79,7 @@ class TestRenderPrompt:
|
||||||
return expected
|
return expected
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
prompt = Prompt.from_function(fn)
|
||||||
assert await prompt.render() == expected
|
assert await prompt.render(None, Context()) == expected
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_fn_returns_list_of_strings(self):
|
async def test_fn_returns_list_of_strings(self):
|
||||||
|
|
@ -83,7 +92,7 @@ class TestRenderPrompt:
|
||||||
return expected
|
return expected
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_fn_returns_resource_content(self):
|
async def test_fn_returns_resource_content(self):
|
||||||
|
|
@ -102,7 +111,7 @@ class TestRenderPrompt:
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
prompt = Prompt.from_function(fn)
|
||||||
assert await prompt.render() == [
|
assert await prompt.render(None, Context()) == [
|
||||||
UserMessage(
|
UserMessage(
|
||||||
content=EmbeddedResource(
|
content=EmbeddedResource(
|
||||||
type="resource",
|
type="resource",
|
||||||
|
|
@ -136,7 +145,7 @@ class TestRenderPrompt:
|
||||||
]
|
]
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
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=TextContent(type="text", text="Please analyze this file:")),
|
||||||
UserMessage(
|
UserMessage(
|
||||||
content=EmbeddedResource(
|
content=EmbeddedResource(
|
||||||
|
|
@ -169,7 +178,7 @@ class TestRenderPrompt:
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = Prompt.from_function(fn)
|
prompt = Prompt.from_function(fn)
|
||||||
assert await prompt.render() == [
|
assert await prompt.render(None, Context()) == [
|
||||||
UserMessage(
|
UserMessage(
|
||||||
content=EmbeddedResource(
|
content=EmbeddedResource(
|
||||||
type="resource",
|
type="resource",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from mcp.server.mcpserver import Context
|
||||||
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
|
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
|
||||||
from mcp.server.mcpserver.prompts.manager import PromptManager
|
from mcp.server.mcpserver.prompts.manager import PromptManager
|
||||||
from mcp.types import TextContent
|
from mcp.types import TextContent
|
||||||
|
|
@ -72,7 +73,7 @@ class TestPromptManager:
|
||||||
manager = PromptManager()
|
manager = PromptManager()
|
||||||
prompt = Prompt.from_function(fn)
|
prompt = Prompt.from_function(fn)
|
||||||
manager.add_prompt(prompt)
|
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!"))]
|
assert messages == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -85,7 +86,7 @@ class TestPromptManager:
|
||||||
manager = PromptManager()
|
manager = PromptManager()
|
||||||
prompt = Prompt.from_function(fn)
|
prompt = Prompt.from_function(fn)
|
||||||
manager.add_prompt(prompt)
|
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!"))]
|
assert messages == [UserMessage(content=TextContent(type="text", text="Hello, World!"))]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -93,7 +94,7 @@ class TestPromptManager:
|
||||||
"""Test rendering a non-existent prompt."""
|
"""Test rendering a non-existent prompt."""
|
||||||
manager = PromptManager()
|
manager = PromptManager()
|
||||||
with pytest.raises(ValueError, match="Unknown prompt: unknown"):
|
with pytest.raises(ValueError, match="Unknown prompt: unknown"):
|
||||||
await manager.render_prompt("unknown")
|
await manager.render_prompt("unknown", None, Context())
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_render_prompt_with_missing_args(self):
|
async def test_render_prompt_with_missing_args(self):
|
||||||
|
|
@ -106,4 +107,4 @@ class TestPromptManager:
|
||||||
prompt = Prompt.from_function(fn)
|
prompt = Prompt.from_function(fn)
|
||||||
manager.add_prompt(prompt)
|
manager.add_prompt(prompt)
|
||||||
with pytest.raises(ValueError, match="Missing required arguments"):
|
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
|
import pytest
|
||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl
|
||||||
|
|
||||||
|
from mcp.server.mcpserver import Context
|
||||||
from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate
|
from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,7 +87,7 @@ class TestResourceManager:
|
||||||
path=temp_file,
|
path=temp_file,
|
||||||
)
|
)
|
||||||
manager.add_resource(resource)
|
manager.add_resource(resource)
|
||||||
retrieved = await manager.get_resource(resource.uri)
|
retrieved = await manager.get_resource(resource.uri, Context())
|
||||||
assert retrieved == resource
|
assert retrieved == resource
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -104,7 +105,7 @@ class TestResourceManager:
|
||||||
)
|
)
|
||||||
manager._templates[template.uri_template] = template
|
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)
|
assert isinstance(resource, FunctionResource)
|
||||||
content = await resource.read()
|
content = await resource.read()
|
||||||
assert content == "Hello, world!"
|
assert content == "Hello, world!"
|
||||||
|
|
@ -114,7 +115,7 @@ class TestResourceManager:
|
||||||
"""Test getting a non-existent resource."""
|
"""Test getting a non-existent resource."""
|
||||||
manager = ResourceManager()
|
manager = ResourceManager()
|
||||||
with pytest.raises(ValueError, match="Unknown resource"):
|
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):
|
def test_list_resources(self, temp_file: Path):
|
||||||
"""Test listing all resources."""
|
"""Test listing all resources."""
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from typing import Any
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import BaseModel
|
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.server.mcpserver.resources import FunctionResource, ResourceTemplate
|
||||||
from mcp.types import Annotations
|
from mcp.types import Annotations
|
||||||
|
|
||||||
|
|
@ -64,6 +64,7 @@ class TestResourceTemplate:
|
||||||
resource = await template.create_resource(
|
resource = await template.create_resource(
|
||||||
"test://foo/123",
|
"test://foo/123",
|
||||||
{"key": "foo", "value": 123},
|
{"key": "foo", "value": 123},
|
||||||
|
Context(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(resource, FunctionResource)
|
assert isinstance(resource, FunctionResource)
|
||||||
|
|
@ -86,7 +87,7 @@ class TestResourceTemplate:
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Error creating resource from template"):
|
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
|
@pytest.mark.anyio
|
||||||
async def test_async_text_resource(self):
|
async def test_async_text_resource(self):
|
||||||
|
|
@ -104,6 +105,7 @@ class TestResourceTemplate:
|
||||||
resource = await template.create_resource(
|
resource = await template.create_resource(
|
||||||
"greet://world",
|
"greet://world",
|
||||||
{"name": "world"},
|
{"name": "world"},
|
||||||
|
Context(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(resource, FunctionResource)
|
assert isinstance(resource, FunctionResource)
|
||||||
|
|
@ -126,6 +128,7 @@ class TestResourceTemplate:
|
||||||
resource = await template.create_resource(
|
resource = await template.create_resource(
|
||||||
"bytes://test",
|
"bytes://test",
|
||||||
{"value": "test"},
|
{"value": "test"},
|
||||||
|
Context(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(resource, FunctionResource)
|
assert isinstance(resource, FunctionResource)
|
||||||
|
|
@ -152,6 +155,7 @@ class TestResourceTemplate:
|
||||||
resource = await template.create_resource(
|
resource = await template.create_resource(
|
||||||
"test://foo/123",
|
"test://foo/123",
|
||||||
{"key": "foo", "value": 123},
|
{"key": "foo", "value": 123},
|
||||||
|
Context(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(resource, FunctionResource)
|
assert isinstance(resource, FunctionResource)
|
||||||
|
|
@ -183,6 +187,7 @@ class TestResourceTemplate:
|
||||||
resource = await template.create_resource(
|
resource = await template.create_resource(
|
||||||
"test://hello",
|
"test://hello",
|
||||||
{"value": "hello"},
|
{"value": "hello"},
|
||||||
|
Context(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert isinstance(resource, FunctionResource)
|
assert isinstance(resource, FunctionResource)
|
||||||
|
|
@ -249,7 +254,7 @@ class TestResourceTemplateAnnotations:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a resource from the template
|
# 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
|
# The resource should inherit the template's annotations
|
||||||
assert resource.annotations is not None
|
assert resource.annotations is not None
|
||||||
|
|
@ -298,7 +303,7 @@ class TestResourceTemplateMetadata:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a resource from the template
|
# 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
|
# The resource should inherit the template's metadata
|
||||||
assert resource.meta is not None
|
assert resource.meta is not None
|
||||||
|
|
|
||||||
|
|
@ -891,7 +891,7 @@ class TestServerResourceTemplates:
|
||||||
assert len(await mcp.list_resources()) == 0
|
assert len(await mcp.list_resources()) == 0
|
||||||
|
|
||||||
# When accessed, should create a concrete resource
|
# 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)
|
assert isinstance(resource, FunctionResource)
|
||||||
result = await resource.read()
|
result = await resource.read()
|
||||||
assert result == "Data for test"
|
assert result == "Data for test"
|
||||||
|
|
@ -1231,6 +1231,19 @@ class TestContextInjection:
|
||||||
class TestServerPrompts:
|
class TestServerPrompts:
|
||||||
"""Test prompt functionality in MCPServer server."""
|
"""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):
|
async def test_prompt_decorator(self):
|
||||||
"""Test that the prompt decorator registers prompts correctly."""
|
"""Test that the prompt decorator registers prompts correctly."""
|
||||||
mcp = MCPServer()
|
mcp = MCPServer()
|
||||||
|
|
@ -1243,7 +1256,7 @@ class TestServerPrompts:
|
||||||
assert len(prompts) == 1
|
assert len(prompts) == 1
|
||||||
assert prompts[0].name == "fn"
|
assert prompts[0].name == "fn"
|
||||||
# Don't compare functions directly since validate_call wraps them
|
# 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 isinstance(content[0].content, TextContent)
|
||||||
assert content[0].content.text == "Hello, world!"
|
assert content[0].content.text == "Hello, world!"
|
||||||
|
|
||||||
|
|
@ -1258,7 +1271,7 @@ class TestServerPrompts:
|
||||||
prompts = mcp._prompt_manager.list_prompts()
|
prompts = mcp._prompt_manager.list_prompts()
|
||||||
assert len(prompts) == 1
|
assert len(prompts) == 1
|
||||||
assert prompts[0].name == "custom_name"
|
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 isinstance(content[0].content, TextContent)
|
||||||
assert content[0].content.text == "Hello, world!"
|
assert content[0].content.text == "Hello, world!"
|
||||||
|
|
||||||
|
|
@ -1273,7 +1286,7 @@ class TestServerPrompts:
|
||||||
prompts = mcp._prompt_manager.list_prompts()
|
prompts = mcp._prompt_manager.list_prompts()
|
||||||
assert len(prompts) == 1
|
assert len(prompts) == 1
|
||||||
assert prompts[0].description == "A custom description"
|
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 isinstance(content[0].content, TextContent)
|
||||||
assert content[0].content.text == "Hello, world!"
|
assert content[0].content.text == "Hello, world!"
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue