Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
09d1b8e
1
Parent(s): 4963138
Add sampling and roots functionality and tests
Browse files- examples/sampling.py +52 -0
- src/fastmcp/client/client.py +29 -25
- src/fastmcp/client/roots.py +75 -0
- src/fastmcp/client/sampling.py +50 -0
- src/fastmcp/server/context.py +22 -11
- tests/client/test_roots.py +48 -0
- tests/client/test_sampling.py +85 -0
- tests/server.py +0 -0
examples/sampling.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Example of using sampling to request an LLM completion via Marvin
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
|
| 7 |
+
import marvin
|
| 8 |
+
from mcp.types import TextContent
|
| 9 |
+
|
| 10 |
+
from fastmcp import Client, Context, FastMCP
|
| 11 |
+
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
|
| 12 |
+
|
| 13 |
+
# -- Create a server that sends a sampling request to the LLM
|
| 14 |
+
|
| 15 |
+
mcp = FastMCP("Sampling Example")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@mcp.tool()
|
| 19 |
+
async def example_tool(prompt: str, context: Context) -> str:
|
| 20 |
+
"""Sample a completion from the LLM."""
|
| 21 |
+
response = await context.sample(
|
| 22 |
+
"What is your favorite programming language?",
|
| 23 |
+
system_prompt="You love languages named after snakes.",
|
| 24 |
+
)
|
| 25 |
+
assert isinstance(response, TextContent)
|
| 26 |
+
return response.text
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# -- Create a client that can handle the sampling request
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def sampling_fn(
|
| 33 |
+
messages: list[SamplingMessage],
|
| 34 |
+
params: SamplingParams,
|
| 35 |
+
ctx: RequestContext,
|
| 36 |
+
) -> str:
|
| 37 |
+
return await marvin.say_async(
|
| 38 |
+
message=[m.content.text for m in messages],
|
| 39 |
+
instructions=params.systemPrompt,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def run():
|
| 44 |
+
async with Client(mcp, sampling_handler=sampling_fn) as client:
|
| 45 |
+
result = await client.call_tool(
|
| 46 |
+
"example_tool", {"prompt": "What is the best programming language?"}
|
| 47 |
+
)
|
| 48 |
+
print(result)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
asyncio.run(run())
|
src/fastmcp/client/client.py
CHANGED
|
@@ -6,26 +6,22 @@ from typing import Any
|
|
| 6 |
import mcp.types
|
| 7 |
from mcp import ClientSession
|
| 8 |
from mcp.client.session import (
|
| 9 |
-
ListRootsFnT,
|
| 10 |
LoggingFnT,
|
| 11 |
MessageHandlerFnT,
|
| 12 |
-
SamplingFnT,
|
| 13 |
)
|
| 14 |
-
from mcp.shared.context import LifespanContextT, RequestContext
|
| 15 |
from pydantic import AnyUrl
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
from fastmcp.server import FastMCP
|
| 18 |
|
| 19 |
from .transports import ClientTransport, SessionKwargs, infer_transport
|
| 20 |
|
| 21 |
-
|
| 22 |
-
def _get_roots_callback(roots: list[mcp.types.Root]) -> ListRootsFnT | None:
|
| 23 |
-
async def _roots_callback(
|
| 24 |
-
context: RequestContext[ClientSession, LifespanContextT],
|
| 25 |
-
) -> mcp.types.ListRootsResult:
|
| 26 |
-
return mcp.types.ListRootsResult(roots=roots)
|
| 27 |
-
|
| 28 |
-
return _roots_callback
|
| 29 |
|
| 30 |
|
| 31 |
class Client:
|
|
@@ -40,10 +36,9 @@ class Client:
|
|
| 40 |
self,
|
| 41 |
transport: ClientTransport | FastMCP | AnyUrl | Path | str,
|
| 42 |
# Common args
|
| 43 |
-
roots:
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
logging_callback: LoggingFnT | None = None,
|
| 47 |
message_handler: MessageHandlerFnT | None = None,
|
| 48 |
read_timeout_seconds: datetime.timedelta | None = None,
|
| 49 |
):
|
|
@@ -51,21 +46,20 @@ class Client:
|
|
| 51 |
self._session: ClientSession | None = None
|
| 52 |
self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None
|
| 53 |
|
| 54 |
-
# Store common kwargs to pass to transport.connect_session
|
| 55 |
-
if roots is not None and list_roots_callback is not None:
|
| 56 |
-
raise ValueError("Cannot provide both `roots` and `list_roots_callback`.")
|
| 57 |
-
resolved_list_roots_callback = list_roots_callback or (
|
| 58 |
-
_get_roots_callback(roots) if roots else None
|
| 59 |
-
)
|
| 60 |
-
|
| 61 |
self._session_kwargs: SessionKwargs = {
|
| 62 |
-
"sampling_callback":
|
| 63 |
-
"list_roots_callback":
|
| 64 |
-
"logging_callback":
|
| 65 |
"message_handler": message_handler,
|
| 66 |
"read_timeout_seconds": read_timeout_seconds,
|
| 67 |
}
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
@property
|
| 70 |
def session(self) -> ClientSession:
|
| 71 |
"""Get the current active session. Raises RuntimeError if not connected."""
|
|
@@ -75,6 +69,16 @@ class Client:
|
|
| 75 |
)
|
| 76 |
return self._session
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
def is_connected(self) -> bool:
|
| 79 |
"""Check if the client is currently connected."""
|
| 80 |
return self._session is not None
|
|
|
|
| 6 |
import mcp.types
|
| 7 |
from mcp import ClientSession
|
| 8 |
from mcp.client.session import (
|
|
|
|
| 9 |
LoggingFnT,
|
| 10 |
MessageHandlerFnT,
|
|
|
|
| 11 |
)
|
|
|
|
| 12 |
from pydantic import AnyUrl
|
| 13 |
|
| 14 |
+
from fastmcp.client.roots import (
|
| 15 |
+
RootsHandler,
|
| 16 |
+
RootsList,
|
| 17 |
+
create_roots_callback,
|
| 18 |
+
)
|
| 19 |
+
from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
|
| 20 |
from fastmcp.server import FastMCP
|
| 21 |
|
| 22 |
from .transports import ClientTransport, SessionKwargs, infer_transport
|
| 23 |
|
| 24 |
+
__all__ = ["Client", "RootsHandler", "RootsList"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class Client:
|
|
|
|
| 36 |
self,
|
| 37 |
transport: ClientTransport | FastMCP | AnyUrl | Path | str,
|
| 38 |
# Common args
|
| 39 |
+
roots: RootsList | RootsHandler | None = None,
|
| 40 |
+
sampling_handler: SamplingHandler | None = None,
|
| 41 |
+
log_handler: LoggingFnT | None = None,
|
|
|
|
| 42 |
message_handler: MessageHandlerFnT | None = None,
|
| 43 |
read_timeout_seconds: datetime.timedelta | None = None,
|
| 44 |
):
|
|
|
|
| 46 |
self._session: ClientSession | None = None
|
| 47 |
self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
self._session_kwargs: SessionKwargs = {
|
| 50 |
+
"sampling_callback": None,
|
| 51 |
+
"list_roots_callback": None,
|
| 52 |
+
"logging_callback": log_handler,
|
| 53 |
"message_handler": message_handler,
|
| 54 |
"read_timeout_seconds": read_timeout_seconds,
|
| 55 |
}
|
| 56 |
|
| 57 |
+
if roots is not None:
|
| 58 |
+
self.set_roots(roots)
|
| 59 |
+
|
| 60 |
+
if sampling_handler is not None:
|
| 61 |
+
self.set_sampling_callback(sampling_handler)
|
| 62 |
+
|
| 63 |
@property
|
| 64 |
def session(self) -> ClientSession:
|
| 65 |
"""Get the current active session. Raises RuntimeError if not connected."""
|
|
|
|
| 69 |
)
|
| 70 |
return self._session
|
| 71 |
|
| 72 |
+
def set_roots(self, roots: RootsList | RootsHandler) -> None:
|
| 73 |
+
"""Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
|
| 74 |
+
self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
|
| 75 |
+
|
| 76 |
+
def set_sampling_callback(self, sampling_callback: SamplingHandler) -> None:
|
| 77 |
+
"""Set the sampling callback for the client."""
|
| 78 |
+
self._session_kwargs["sampling_callback"] = create_sampling_callback(
|
| 79 |
+
sampling_callback
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
def is_connected(self) -> bool:
|
| 83 |
"""Check if the client is currently connected."""
|
| 84 |
return self._session is not None
|
src/fastmcp/client/roots.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import inspect
|
| 2 |
+
from collections.abc import Awaitable, Callable
|
| 3 |
+
from typing import TypeAlias
|
| 4 |
+
|
| 5 |
+
import mcp.types
|
| 6 |
+
import pydantic
|
| 7 |
+
from mcp import ClientSession
|
| 8 |
+
from mcp.client.session import ListRootsFnT
|
| 9 |
+
from mcp.shared.context import LifespanContextT, RequestContext
|
| 10 |
+
|
| 11 |
+
RootsList: TypeAlias = list[str] | list[mcp.types.Root] | list[str | mcp.types.Root]
|
| 12 |
+
|
| 13 |
+
RootsHandler: TypeAlias = (
|
| 14 |
+
Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList]
|
| 15 |
+
| Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]]
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def convert_roots_list(roots: RootsList) -> list[mcp.types.Root]:
|
| 20 |
+
roots_list = []
|
| 21 |
+
for r in roots:
|
| 22 |
+
if isinstance(r, mcp.types.Root):
|
| 23 |
+
roots_list.append(r)
|
| 24 |
+
elif isinstance(r, pydantic.FileUrl):
|
| 25 |
+
roots_list.append(mcp.types.Root(uri=r))
|
| 26 |
+
elif isinstance(r, str):
|
| 27 |
+
roots_list.append(mcp.types.Root(uri=pydantic.FileUrl(r)))
|
| 28 |
+
else:
|
| 29 |
+
raise ValueError(f"Invalid root: {r}")
|
| 30 |
+
return roots_list
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def create_roots_callback(
|
| 34 |
+
handler: RootsList | RootsHandler,
|
| 35 |
+
) -> ListRootsFnT:
|
| 36 |
+
if isinstance(handler, list):
|
| 37 |
+
return _create_roots_callback_from_roots(handler)
|
| 38 |
+
elif inspect.isfunction(handler):
|
| 39 |
+
return _create_roots_callback_from_fn(handler)
|
| 40 |
+
else:
|
| 41 |
+
raise ValueError(f"Invalid roots handler: {handler}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _create_roots_callback_from_roots(
|
| 45 |
+
roots: RootsList,
|
| 46 |
+
) -> ListRootsFnT:
|
| 47 |
+
roots = convert_roots_list(roots)
|
| 48 |
+
|
| 49 |
+
async def _roots_callback(
|
| 50 |
+
context: RequestContext[ClientSession, LifespanContextT],
|
| 51 |
+
) -> mcp.types.ListRootsResult:
|
| 52 |
+
return mcp.types.ListRootsResult(roots=roots)
|
| 53 |
+
|
| 54 |
+
return _roots_callback
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _create_roots_callback_from_fn(
|
| 58 |
+
fn: Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList]
|
| 59 |
+
| Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]],
|
| 60 |
+
) -> ListRootsFnT:
|
| 61 |
+
async def _roots_callback(
|
| 62 |
+
context: RequestContext[ClientSession, LifespanContextT],
|
| 63 |
+
) -> mcp.types.ListRootsResult | mcp.types.ErrorData:
|
| 64 |
+
try:
|
| 65 |
+
roots = fn(context)
|
| 66 |
+
if inspect.isawaitable(roots):
|
| 67 |
+
roots = await roots
|
| 68 |
+
return mcp.types.ListRootsResult(roots=convert_roots_list(roots))
|
| 69 |
+
except Exception as e:
|
| 70 |
+
return mcp.types.ErrorData(
|
| 71 |
+
code=mcp.types.INTERNAL_ERROR,
|
| 72 |
+
message=str(e),
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
return _roots_callback
|
src/fastmcp/client/sampling.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import inspect
|
| 2 |
+
from collections.abc import Awaitable, Callable
|
| 3 |
+
from typing import TypeAlias
|
| 4 |
+
|
| 5 |
+
import mcp.types
|
| 6 |
+
from mcp import ClientSession, CreateMessageResult
|
| 7 |
+
from mcp.client.session import SamplingFnT
|
| 8 |
+
from mcp.shared.context import LifespanContextT, RequestContext
|
| 9 |
+
from mcp.types import CreateMessageRequestParams as SamplingParams
|
| 10 |
+
from mcp.types import SamplingMessage
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MessageResult(CreateMessageResult):
|
| 14 |
+
role: mcp.types.Role = "assistant"
|
| 15 |
+
content: mcp.types.TextContent | mcp.types.ImageContent
|
| 16 |
+
model: str = "client-model"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
SamplingHandler: TypeAlias = Callable[
|
| 20 |
+
[
|
| 21 |
+
list[SamplingMessage],
|
| 22 |
+
SamplingParams,
|
| 23 |
+
RequestContext[ClientSession, LifespanContextT],
|
| 24 |
+
],
|
| 25 |
+
str | CreateMessageResult | Awaitable[str | CreateMessageResult],
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def create_sampling_callback(sampling_handler: SamplingHandler) -> SamplingFnT:
|
| 30 |
+
async def _sampling_handler(
|
| 31 |
+
context: RequestContext[ClientSession, LifespanContextT],
|
| 32 |
+
params: SamplingParams,
|
| 33 |
+
) -> CreateMessageResult | mcp.types.ErrorData:
|
| 34 |
+
try:
|
| 35 |
+
result = sampling_handler(params.messages, params, context)
|
| 36 |
+
if inspect.isawaitable(result):
|
| 37 |
+
result = await result
|
| 38 |
+
|
| 39 |
+
if isinstance(result, str):
|
| 40 |
+
result = MessageResult(
|
| 41 |
+
content=mcp.types.TextContent(type="text", text=result)
|
| 42 |
+
)
|
| 43 |
+
return result
|
| 44 |
+
except Exception as e:
|
| 45 |
+
return mcp.types.ErrorData(
|
| 46 |
+
code=mcp.types.INTERNAL_ERROR,
|
| 47 |
+
message=str(e),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
return _sampling_handler
|
src/fastmcp/server/context.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
from __future__ import annotations as _annotations
|
| 2 |
|
| 3 |
-
from collections.abc import Iterable
|
| 4 |
from typing import Any, Generic, Literal
|
| 5 |
|
| 6 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
@@ -9,6 +8,7 @@ from mcp.shared.context import LifespanContextT, RequestContext
|
|
| 9 |
from mcp.types import (
|
| 10 |
CreateMessageResult,
|
| 11 |
ImageContent,
|
|
|
|
| 12 |
SamplingMessage,
|
| 13 |
TextContent,
|
| 14 |
)
|
|
@@ -106,7 +106,7 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
| 106 |
progress_token=progress_token, progress=progress, total=total
|
| 107 |
)
|
| 108 |
|
| 109 |
-
async def read_resource(self, uri: str | AnyUrl) ->
|
| 110 |
"""Read a resource by URI.
|
| 111 |
|
| 112 |
Args:
|
|
@@ -175,9 +175,14 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
| 175 |
"""Send an error log message."""
|
| 176 |
await self.log("error", message, **extra)
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
async def sample(
|
| 179 |
self,
|
| 180 |
-
|
| 181 |
system_prompt: str | None = None,
|
| 182 |
temperature: float | None = None,
|
| 183 |
max_tokens: int | None = None,
|
|
@@ -193,16 +198,22 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
|
|
| 193 |
if max_tokens is None:
|
| 194 |
max_tokens = 512
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
result: CreateMessageResult = await self.request_context.session.create_message(
|
| 205 |
-
messages=
|
| 206 |
system_prompt=system_prompt,
|
| 207 |
temperature=temperature,
|
| 208 |
max_tokens=max_tokens,
|
|
|
|
| 1 |
from __future__ import annotations as _annotations
|
| 2 |
|
|
|
|
| 3 |
from typing import Any, Generic, Literal
|
| 4 |
|
| 5 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
|
| 8 |
from mcp.types import (
|
| 9 |
CreateMessageResult,
|
| 10 |
ImageContent,
|
| 11 |
+
Root,
|
| 12 |
SamplingMessage,
|
| 13 |
TextContent,
|
| 14 |
)
|
|
|
|
| 106 |
progress_token=progress_token, progress=progress, total=total
|
| 107 |
)
|
| 108 |
|
| 109 |
+
async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
|
| 110 |
"""Read a resource by URI.
|
| 111 |
|
| 112 |
Args:
|
|
|
|
| 175 |
"""Send an error log message."""
|
| 176 |
await self.log("error", message, **extra)
|
| 177 |
|
| 178 |
+
async def list_roots(self) -> list[Root]:
|
| 179 |
+
"""List the roots available to the server, as indicated by the client."""
|
| 180 |
+
result = await self.request_context.session.list_roots()
|
| 181 |
+
return result.roots
|
| 182 |
+
|
| 183 |
async def sample(
|
| 184 |
self,
|
| 185 |
+
messages: str | list[str | SamplingMessage],
|
| 186 |
system_prompt: str | None = None,
|
| 187 |
temperature: float | None = None,
|
| 188 |
max_tokens: int | None = None,
|
|
|
|
| 198 |
if max_tokens is None:
|
| 199 |
max_tokens = 512
|
| 200 |
|
| 201 |
+
if isinstance(messages, str):
|
| 202 |
+
sampling_messages = [
|
| 203 |
+
SamplingMessage(
|
| 204 |
+
content=TextContent(text=messages, type="text"), role="user"
|
| 205 |
+
)
|
| 206 |
+
]
|
| 207 |
+
elif isinstance(messages, list):
|
| 208 |
+
sampling_messages = [
|
| 209 |
+
SamplingMessage(content=TextContent(text=m, type="text"), role="user")
|
| 210 |
+
if isinstance(m, str)
|
| 211 |
+
else m
|
| 212 |
+
for m in messages
|
| 213 |
+
]
|
| 214 |
|
| 215 |
result: CreateMessageResult = await self.request_context.session.create_message(
|
| 216 |
+
messages=sampling_messages,
|
| 217 |
system_prompt=system_prompt,
|
| 218 |
temperature=temperature,
|
| 219 |
max_tokens=max_tokens,
|
tests/client/test_roots.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from mcp.types import TextContent
|
| 5 |
+
|
| 6 |
+
from fastmcp import Client, Context, FastMCP
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def fastmcp_server():
|
| 11 |
+
mcp = FastMCP()
|
| 12 |
+
|
| 13 |
+
@mcp.tool()
|
| 14 |
+
async def list_roots(context: Context) -> list[str]:
|
| 15 |
+
roots = await context.list_roots()
|
| 16 |
+
return [str(r.uri) for r in roots]
|
| 17 |
+
|
| 18 |
+
return mcp
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TestClientRoots:
|
| 22 |
+
@pytest.mark.parametrize("roots", [["x"], ["x", "y"]])
|
| 23 |
+
async def test_invalid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
|
| 24 |
+
"""
|
| 25 |
+
Roots must be URIs
|
| 26 |
+
"""
|
| 27 |
+
with pytest.raises(ValueError, match="Input should be a valid URL"):
|
| 28 |
+
async with Client(fastmcp_server, roots=roots):
|
| 29 |
+
pass
|
| 30 |
+
|
| 31 |
+
@pytest.mark.parametrize("roots", [["https://x.com"]])
|
| 32 |
+
async def test_invalid_urls(self, fastmcp_server: FastMCP, roots: list[str]):
|
| 33 |
+
"""
|
| 34 |
+
At this time, root URIs must start with file://
|
| 35 |
+
"""
|
| 36 |
+
with pytest.raises(ValueError, match="URL scheme should be 'file'"):
|
| 37 |
+
async with Client(fastmcp_server, roots=roots):
|
| 38 |
+
pass
|
| 39 |
+
|
| 40 |
+
@pytest.mark.parametrize("roots", [["file://x/y/z", "file://x/y/z"]])
|
| 41 |
+
async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]):
|
| 42 |
+
async with Client(fastmcp_server, roots=roots) as client:
|
| 43 |
+
result = await client.call_tool("list_roots", {})
|
| 44 |
+
assert isinstance(result.content[0], TextContent)
|
| 45 |
+
assert json.loads(result.content[0].text) == [
|
| 46 |
+
"file://x/y/z",
|
| 47 |
+
"file://x/y/z",
|
| 48 |
+
]
|
tests/client/test_sampling.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import cast
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from mcp.types import TextContent
|
| 5 |
+
|
| 6 |
+
from fastmcp import Client, Context, FastMCP
|
| 7 |
+
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def fastmcp_server():
|
| 12 |
+
mcp = FastMCP()
|
| 13 |
+
|
| 14 |
+
@mcp.tool()
|
| 15 |
+
async def simple_sample(message: str, context: Context) -> str:
|
| 16 |
+
result = await context.sample("Hello, world!")
|
| 17 |
+
return cast(TextContent, result).text
|
| 18 |
+
|
| 19 |
+
@mcp.tool()
|
| 20 |
+
async def sample_with_system_prompt(message: str, context: Context) -> str:
|
| 21 |
+
result = await context.sample("Hello, world!", system_prompt="You love FastMCP")
|
| 22 |
+
return cast(TextContent, result).text
|
| 23 |
+
|
| 24 |
+
@mcp.tool()
|
| 25 |
+
async def sample_with_messages(message: str, context: Context) -> str:
|
| 26 |
+
result = await context.sample(
|
| 27 |
+
[
|
| 28 |
+
"Hello!",
|
| 29 |
+
SamplingMessage(
|
| 30 |
+
content=TextContent(
|
| 31 |
+
type="text", text="How can I assist you today?"
|
| 32 |
+
),
|
| 33 |
+
role="assistant",
|
| 34 |
+
),
|
| 35 |
+
]
|
| 36 |
+
)
|
| 37 |
+
return cast(TextContent, result).text
|
| 38 |
+
|
| 39 |
+
return mcp
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def test_simple_sampling(fastmcp_server: FastMCP):
|
| 43 |
+
def sampling_handler(
|
| 44 |
+
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
| 45 |
+
) -> str:
|
| 46 |
+
return "This is the sample message!"
|
| 47 |
+
|
| 48 |
+
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
| 49 |
+
result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
|
| 50 |
+
reply = cast(TextContent, result.content[0])
|
| 51 |
+
assert reply.text == "This is the sample message!"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
|
| 55 |
+
def sampling_handler(
|
| 56 |
+
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
| 57 |
+
) -> str:
|
| 58 |
+
assert params.systemPrompt is not None
|
| 59 |
+
return params.systemPrompt
|
| 60 |
+
|
| 61 |
+
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
| 62 |
+
result = await client.call_tool(
|
| 63 |
+
"sample_with_system_prompt", {"message": "Hello, world!"}
|
| 64 |
+
)
|
| 65 |
+
reply = cast(TextContent, result.content[0])
|
| 66 |
+
assert reply.text == "You love FastMCP"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def test_sampling_with_messages(fastmcp_server: FastMCP):
|
| 70 |
+
def sampling_handler(
|
| 71 |
+
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
| 72 |
+
) -> str:
|
| 73 |
+
assert len(messages) == 2
|
| 74 |
+
assert messages[0].content.type == "text"
|
| 75 |
+
assert messages[0].content.text == "Hello!"
|
| 76 |
+
assert messages[1].content.type == "text"
|
| 77 |
+
assert messages[1].content.text == "How can I assist you today?"
|
| 78 |
+
return "I need to think."
|
| 79 |
+
|
| 80 |
+
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
| 81 |
+
result = await client.call_tool(
|
| 82 |
+
"sample_with_messages", {"message": "Hello, world!"}
|
| 83 |
+
)
|
| 84 |
+
reply = cast(TextContent, result.content[0])
|
| 85 |
+
assert reply.text == "I need to think."
|
tests/server.py
DELETED
|
File without changes
|