Jeremiah Lowin commited on
Commit
7f513d6
·
unverified ·
2 Parent(s): 02badc5a358be3

Merge pull request #284 from jlowin/client-docs

Browse files

Fix client docs for advanced features, add tests for logging

docs/clients/client.mdx CHANGED
@@ -149,83 +149,90 @@ The `Client` provides methods corresponding to standard MCP requests:
149
  * **`list_prompts()`**: Retrieves available prompt templates.
150
  * **`get_prompt(name: str, arguments: dict[str, Any] | None = None)`**: Retrieves a rendered prompt message list.
151
 
152
- ### Callbacks
153
 
154
- MCP allows servers to make requests *back* to the client for certain capabilities. The `Client` constructor accepts callback functions to handle these server requests:
155
 
156
- #### Roots
157
 
158
- * **`roots: RootsList | RootsHandler | None`**: Provides the server with a list of root directories the client grants access to. This can be a static list or a function that dynamically determines roots.
159
- ```python
160
- from pathlib import Path
161
- from fastmcp.client.roots import RootsHandler, RootsList
162
- from mcp.shared.context import RequestContext # For type hint
163
-
164
- # Option 1: Static list
165
- static_roots: RootsList = [str(Path.home() / "Documents")]
166
-
167
- # Option 2: Dynamic function
168
- def dynamic_roots_handler(context: RequestContext) -> RootsList:
169
- # Logic to determine accessible roots based on context
170
- print(f"Server requested roots (Request ID: {context.request_id})")
171
- return [str(Path.home() / "Downloads")]
172
-
173
- client_with_roots = Client(
174
- "my_server.py",
175
- roots=dynamic_roots_handler # or roots=static_roots
176
- )
177
 
178
- # Tell the server the roots might have changed (if needed)
179
- # async with client_with_roots:
180
- # await client_with_roots.send_roots_list_changed()
181
- ```
182
- See `fastmcp.client.roots` for helpers.
183
 
184
- #### LLM Sampling
185
 
186
- * **`sampling_handler: SamplingHandler | None`**: Handles `sampling/createMessage` requests from the server. This callback receives messages from the server and should return an LLM completion.
187
- ```python
188
- from fastmcp.client.sampling import SamplingHandler, MessageResult
189
- from mcp.types import SamplingMessage, SamplingParams, TextContent
190
- from mcp.shared.context import RequestContext # For type hint
191
-
192
- async def my_llm_handler(
193
- messages: list[SamplingMessage],
194
- params: SamplingParams,
195
- context: RequestContext
196
- ) -> str | MessageResult:
197
- print(f"Server requested sampling (Request ID: {context.request_id})")
198
- # In a real scenario, call your LLM API here
199
- last_user_message = next((m for m in reversed(messages) if m.role == 'user'), None)
200
- prompt = last_user_message.content.text if last_user_message and isinstance(last_user_message.content, TextContent) else "Default prompt"
201
-
202
- # Simulate LLM response
203
- response_text = f"LLM processed: {prompt[:50]}..."
204
- # Return simple string (becomes TextContent) or a MessageResult object
205
- return response_text
206
-
207
- client_with_sampling = Client(
208
- "my_server.py",
209
- sampling_handler=my_llm_handler
210
  )
211
- ```
212
- See `fastmcp.client.sampling` for helpers.
 
 
 
 
213
 
214
  #### Logging
215
 
216
- * **`log_handler: LoggingFnT | None`**: Receives log messages sent from the server (`ctx.info`, `ctx.error`, etc.).
217
- ```python
218
- from mcp.client.session import LoggingFnT, LogLevel
219
 
220
- def my_log_handler(level: LogLevel, message: str, logger_name: str | None):
221
- print(f"[Server Log - {level.upper()}] {logger_name or 'default'}: {message}")
 
222
 
223
- client_with_logging = Client(
224
- "my_server.py",
225
- log_handler=my_log_handler
226
- )
227
- ```
228
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  ### Utility Methods
230
 
231
  * **`ping()`**: Sends a ping request to the server to verify connectivity.
 
149
  * **`list_prompts()`**: Retrieves available prompt templates.
150
  * **`get_prompt(name: str, arguments: dict[str, Any] | None = None)`**: Retrieves a rendered prompt message list.
151
 
152
+ ### Advanced Features
153
 
154
+ MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
155
 
 
156
 
157
+ #### LLM Sampling
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
+ MCP Servers can request LLM completions from clients. The client can provide a `sampling_handler` to handle these requests. The sampling handler receives a list of messages and other parameters from the server, and should return a string completion.
 
 
 
 
160
 
161
+ The following example uses the `marvin` library to generate a completion:
162
 
163
+ ```python {8-17, 21}
164
+ import marvin
165
+ from fastmcp import Client
166
+ from fastmcp.client.sampling import (
167
+ SamplingMessage,
168
+ SamplingParams,
169
+ RequestContext,
170
+ )
171
+
172
+ async def sampling_handler(
173
+ messages: list[SamplingMessage],
174
+ params: SamplingParams,
175
+ context: RequestContext
176
+ ) -> str:
177
+ return await marvin.say_async(
178
+ message=[m.content.text for m in messages],
179
+ instructions=params.systemPrompt,
 
 
 
 
 
 
 
180
  )
181
+
182
+ client = Client(
183
+ ...,
184
+ sampling_handler=sampling_handler,
185
+ )
186
+ ```
187
 
188
  #### Logging
189
 
190
+ MCP servers can emit logs to clients. The client can set a logging callback to receive these logs.
 
 
191
 
192
+ ```python {4-5, 9}
193
+ from fastmcp import Client
194
+ from fastmcp.client.logging import LogHandler, LogMessage
195
 
196
+ async def my_log_handler(params: LogMessage):
197
+ print(f"[Server Log - {params.level.upper()}] {params.logger or 'default'}: {params.data}")
 
 
 
198
 
199
+ client_with_logging = Client(
200
+ ...,
201
+ log_handler=my_log_handler,
202
+ )
203
+ ```
204
+
205
+ #### Roots
206
+
207
+ Roots are a way for clients to inform servers about the resources they have access to or certain boundaries on their access. The server can use this information to adjust behavior or provide more accurate responses.
208
+
209
+ Servers can request roots from clients, and clients can notify servers when their roots change.
210
+
211
+ To set the roots when creating a client, users can either provide a list of roots (which can be a list of strings) or an async function that returns a list of roots.
212
+
213
+ <CodeGroup>
214
+ ```python Static Roots {5}
215
+ from fastmcp import Client
216
+
217
+ client = Client(
218
+ ...,
219
+ roots=["/path/to/root1", "/path/to/root2"],
220
+ )
221
+ ```
222
+ ```python Dynamic Roots Callback {4-6, 10}
223
+ from fastmcp import Client
224
+ from fastmcp.client.roots import RequestContext
225
+
226
+ async def roots_callback(context: RequestContext) -> list[str]:
227
+ print(f"Server requested roots (Request ID: {context.request_id})")
228
+ return ["/path/to/root1", "/path/to/root2"]
229
+
230
+ client = Client(
231
+ ...,
232
+ roots=roots_callback,
233
+ )
234
+ ```
235
+ </CodeGroup>
236
  ### Utility Methods
237
 
238
  * **`ping()`**: Sends a ping request to the server to verify connectivity.
src/fastmcp/client/client.py CHANGED
@@ -5,12 +5,9 @@ from typing import Any, Literal, cast, overload
5
 
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,
@@ -22,7 +19,14 @@ from fastmcp.server import FastMCP
22
 
23
  from .transports import ClientTransport, SessionKwargs, infer_transport
24
 
25
- __all__ = ["Client", "RootsHandler", "RootsList"]
 
 
 
 
 
 
 
26
 
27
 
28
  class Client:
@@ -39,8 +43,8 @@ class Client:
39
  # Common args
40
  roots: RootsList | RootsHandler | None = None,
41
  sampling_handler: SamplingHandler | None = None,
42
- log_handler: LoggingFnT | None = None,
43
- message_handler: MessageHandlerFnT | None = None,
44
  read_timeout_seconds: datetime.timedelta | None = None,
45
  ):
46
  self.transport = infer_transport(transport)
 
5
 
6
  import mcp.types
7
  from mcp import ClientSession
 
 
 
 
8
  from pydantic import AnyUrl
9
 
10
+ from fastmcp.client.logging import LogHandler, MessageHandler
11
  from fastmcp.client.roots import (
12
  RootsHandler,
13
  RootsList,
 
19
 
20
  from .transports import ClientTransport, SessionKwargs, infer_transport
21
 
22
+ __all__ = [
23
+ "Client",
24
+ "RootsHandler",
25
+ "RootsList",
26
+ "LogHandler",
27
+ "MessageHandler",
28
+ "SamplingHandler",
29
+ ]
30
 
31
 
32
  class Client:
 
43
  # Common args
44
  roots: RootsList | RootsHandler | None = None,
45
  sampling_handler: SamplingHandler | None = None,
46
+ log_handler: LogHandler | None = None,
47
+ message_handler: MessageHandler | None = None,
48
  read_timeout_seconds: datetime.timedelta | None = None,
49
  ):
50
  self.transport = infer_transport(transport)
src/fastmcp/client/logging.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypeAlias
2
+
3
+ from mcp.client.session import (
4
+ LoggingFnT,
5
+ MessageHandlerFnT,
6
+ )
7
+ from mcp.types import LoggingMessageNotificationParams
8
+
9
+ LogMessage: TypeAlias = LoggingMessageNotificationParams
10
+ LogHandler: TypeAlias = LoggingFnT
11
+ MessageHandler: TypeAlias = MessageHandlerFnT
12
+
13
+ __all__ = ["LogMessage", "LogHandler", "MessageHandler"]
src/fastmcp/client/sampling.py CHANGED
@@ -9,6 +9,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"
 
9
  from mcp.types import CreateMessageRequestParams as SamplingParams
10
  from mcp.types import SamplingMessage
11
 
12
+ __all__ = ["SamplingMessage", "SamplingParams", "MessageResult", "SamplingHandler"]
13
+
14
 
15
  class MessageResult(CreateMessageResult):
16
  role: mcp.types.Role = "assistant"
src/fastmcp/server/context.py CHANGED
@@ -1,7 +1,8 @@
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
6
  from mcp.server.session import ServerSessionT
7
  from mcp.shared.context import LifespanContextT, RequestContext
@@ -122,19 +123,20 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
122
 
123
  async def log(
124
  self,
125
- level: Literal["debug", "info", "warning", "error"],
126
  message: str,
127
- *,
128
  logger_name: str | None = None,
129
  ) -> None:
130
  """Send a log message to the client.
131
 
132
  Args:
133
- level: Log level (debug, info, warning, error)
134
  message: Log message
 
 
135
  logger_name: Optional logger name
136
- **extra: Additional structured data to include
137
  """
 
 
138
  await self.request_context.session.send_log_message(
139
  level=level, data=message, logger=logger_name
140
  )
@@ -159,21 +161,21 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT]):
159
  return self.request_context.session
160
 
161
  # Convenience methods for common log levels
162
- async def debug(self, message: str, **extra: Any) -> None:
163
  """Send a debug log message."""
164
- await self.log("debug", message, **extra)
165
 
166
- async def info(self, message: str, **extra: Any) -> None:
167
  """Send an info log message."""
168
- await self.log("info", message, **extra)
169
 
170
- async def warning(self, message: str, **extra: Any) -> None:
171
  """Send a warning log message."""
172
- await self.log("warning", message, **extra)
173
 
174
- async def error(self, message: str, **extra: Any) -> None:
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."""
 
1
  from __future__ import annotations as _annotations
2
 
3
+ from typing import Any, Generic
4
 
5
+ from mcp import LoggingLevel
6
  from mcp.server.lowlevel.helper_types import ReadResourceContents
7
  from mcp.server.session import ServerSessionT
8
  from mcp.shared.context import LifespanContextT, RequestContext
 
123
 
124
  async def log(
125
  self,
 
126
  message: str,
127
+ level: LoggingLevel | None = None,
128
  logger_name: str | None = None,
129
  ) -> None:
130
  """Send a log message to the client.
131
 
132
  Args:
 
133
  message: Log message
134
+ level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical",
135
+ "alert", or "emergency". Default is "info".
136
  logger_name: Optional logger name
 
137
  """
138
+ if level is None:
139
+ level = "info"
140
  await self.request_context.session.send_log_message(
141
  level=level, data=message, logger=logger_name
142
  )
 
161
  return self.request_context.session
162
 
163
  # Convenience methods for common log levels
164
+ async def debug(self, message: str, logger_name: str | None = None) -> None:
165
  """Send a debug log message."""
166
+ await self.log(level="debug", message=message, logger_name=logger_name)
167
 
168
+ async def info(self, message: str, logger_name: str | None = None) -> None:
169
  """Send an info log message."""
170
+ await self.log(level="info", message=message, logger_name=logger_name)
171
 
172
+ async def warning(self, message: str, logger_name: str | None = None) -> None:
173
  """Send a warning log message."""
174
+ await self.log(level="warning", message=message, logger_name=logger_name)
175
 
176
+ async def error(self, message: str, logger_name: str | None = None) -> None:
177
  """Send an error log message."""
178
+ await self.log(level="error", message=message, logger_name=logger_name)
179
 
180
  async def list_roots(self) -> list[Root]:
181
  """List the roots available to the server, as indicated by the client."""
tests/client/test_logs.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from mcp import LoggingLevel
3
+
4
+ from fastmcp import Client, Context, FastMCP
5
+ from fastmcp.client.logging import LogMessage
6
+
7
+
8
+ class LogHandler:
9
+ def __init__(self):
10
+ self.logs: list[LogMessage] = []
11
+
12
+ async def handle_log(self, params: LogMessage) -> None:
13
+ self.logs.append(params)
14
+
15
+
16
+ @pytest.fixture
17
+ def fastmcp_server():
18
+ mcp = FastMCP()
19
+
20
+ @mcp.tool()
21
+ async def log(context: Context) -> None:
22
+ await context.info(message="hello?")
23
+
24
+ @mcp.tool()
25
+ async def echo_log(
26
+ message: str,
27
+ context: Context,
28
+ level: LoggingLevel | None = None,
29
+ logger: str | None = None,
30
+ ) -> None:
31
+ await context.log(message=message, level=level)
32
+
33
+ return mcp
34
+
35
+
36
+ class TestClientLogs:
37
+ async def test_log(self, fastmcp_server: FastMCP):
38
+ log_handler = LogHandler()
39
+ async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
40
+ await client.call_tool("log", {})
41
+
42
+ assert len(log_handler.logs) == 1
43
+ assert log_handler.logs[0].data == "hello?"
44
+ assert log_handler.logs[0].level == "info"
45
+
46
+ async def test_echo_log(self, fastmcp_server: FastMCP):
47
+ log_handler = LogHandler()
48
+ async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
49
+ await client.call_tool("echo_log", {"message": "this is a log"})
50
+
51
+ assert len(log_handler.logs) == 1
52
+ await client.call_tool(
53
+ "echo_log", {"message": "this is a warning log", "level": "warning"}
54
+ )
55
+ assert len(log_handler.logs) == 2
56
+
57
+ assert log_handler.logs[0].data == "this is a log"
58
+ assert log_handler.logs[0].level == "info"
59
+ assert log_handler.logs[1].data == "this is a warning log"
60
+ assert log_handler.logs[1].level == "warning"
tests/server/test_server_interactions.py CHANGED
@@ -689,10 +689,10 @@ class TestToolContextInjection:
689
  """Test that context parameters are properly detected."""
690
  mcp = FastMCP()
691
 
 
692
  def tool_with_context(x: int, ctx: Context) -> str:
693
  return f"Request {ctx.request_id}: {x}"
694
 
695
- mcp.add_tool(tool_with_context)
696
  async with Client(mcp) as client:
697
  tools = await client.list_tools()
698
  assert len(tools) == 1
@@ -719,11 +719,11 @@ class TestToolContextInjection:
719
  """Test that context works in async functions."""
720
  mcp = FastMCP()
721
 
 
722
  async def async_tool(x: int, ctx: Context) -> str:
723
  assert ctx.request_id is not None
724
  return f"Async request {ctx.request_id}: {x}"
725
 
726
- mcp.add_tool(async_tool)
727
  async with Client(mcp) as client:
728
  result = await client.call_tool("async_tool", {"x": 42})
729
  assert len(result) == 1
@@ -732,51 +732,14 @@ class TestToolContextInjection:
732
  assert "Async request" in content.text
733
  assert "42" in content.text
734
 
735
- async def test_context_logging(self):
736
- from unittest.mock import patch
737
-
738
- import mcp.server.session
739
-
740
- """Test that context logging methods work."""
741
- mcp = FastMCP()
742
-
743
- async def logging_tool(msg: str, ctx: Context) -> str:
744
- await ctx.debug("Debug message")
745
- await ctx.info("Info message")
746
- await ctx.warning("Warning message")
747
- await ctx.error("Error message")
748
- return f"Logged messages for {msg}"
749
-
750
- mcp.add_tool(logging_tool)
751
-
752
- with patch("mcp.server.session.ServerSession.send_log_message") as mock_log:
753
- async with Client(mcp) as client:
754
- result = await client.call_tool("logging_tool", {"msg": "test"})
755
- assert len(result) == 1
756
- content = result[0]
757
- assert isinstance(content, TextContent)
758
- assert "Logged messages for test" in content.text
759
-
760
- assert mock_log.call_count == 4
761
- mock_log.assert_any_call(
762
- level="debug", data="Debug message", logger=None
763
- )
764
- mock_log.assert_any_call(level="info", data="Info message", logger=None)
765
- mock_log.assert_any_call(
766
- level="warning", data="Warning message", logger=None
767
- )
768
- mock_log.assert_any_call(
769
- level="error", data="Error message", logger=None
770
- )
771
-
772
  async def test_optional_context(self):
773
  """Test that context is optional."""
774
  mcp = FastMCP()
775
 
 
776
  def no_context(x: int) -> int:
777
  return x * 2
778
 
779
- mcp.add_tool(no_context)
780
  async with Client(mcp) as client:
781
  result = await client.call_tool("no_context", {"x": 21})
782
  assert len(result) == 1
 
689
  """Test that context parameters are properly detected."""
690
  mcp = FastMCP()
691
 
692
+ @mcp.tool()
693
  def tool_with_context(x: int, ctx: Context) -> str:
694
  return f"Request {ctx.request_id}: {x}"
695
 
 
696
  async with Client(mcp) as client:
697
  tools = await client.list_tools()
698
  assert len(tools) == 1
 
719
  """Test that context works in async functions."""
720
  mcp = FastMCP()
721
 
722
+ @mcp.tool()
723
  async def async_tool(x: int, ctx: Context) -> str:
724
  assert ctx.request_id is not None
725
  return f"Async request {ctx.request_id}: {x}"
726
 
 
727
  async with Client(mcp) as client:
728
  result = await client.call_tool("async_tool", {"x": 42})
729
  assert len(result) == 1
 
732
  assert "Async request" in content.text
733
  assert "42" in content.text
734
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  async def test_optional_context(self):
736
  """Test that context is optional."""
737
  mcp = FastMCP()
738
 
739
+ @mcp.tool()
740
  def no_context(x: int) -> int:
741
  return x * 2
742
 
 
743
  async with Client(mcp) as client:
744
  result = await client.call_tool("no_context", {"x": 21})
745
  assert len(result) == 1