Jeremiah Lowin commited on
Commit
09d1b8e
·
1 Parent(s): 4963138

Add sampling and roots functionality and tests

Browse files
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: list[mcp.types.Root] | None = None,
44
- sampling_callback: SamplingFnT | None = None,
45
- list_roots_callback: ListRootsFnT | None = None,
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": sampling_callback,
63
- "list_roots_callback": resolved_list_roots_callback,
64
- "logging_callback": 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) -> Iterable[ReadResourceContents]:
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
- message: str,
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
- assert self._request_context is not None
197
- assert self._request_context.session is not None
198
-
199
- sampling_message = SamplingMessage(
200
- content=TextContent(text=message, type="text"),
201
- role="user",
202
- )
 
 
 
 
 
 
203
 
204
  result: CreateMessageResult = await self.request_context.session.create_message(
205
- messages=[sampling_message],
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