Jeremiah Lowin commited on
Commit
5c7a672
·
1 Parent(s): ef5e55f

add clients

Browse files
src/fastmcp/client/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .websocket import WebSocketClient
2
+ from .sse import SSEClient
3
+ from .stdio import StdioClient
4
+
5
+ __all__ = ["StdioClient", "SSEClient", "WebSocketClient"]
src/fastmcp/client/base.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import abc
2
+ import contextlib
3
+ import datetime
4
+ from typing import Any, AsyncContextManager, Optional
5
+
6
+ import mcp.types
7
+ from mcp import ClientSession
8
+ from mcp.client.session import ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
9
+ from mcp.shared.context import LifespanContextT, RequestContext
10
+ from pydantic import AnyUrl
11
+
12
+
13
+ def _get_roots_callback(roots: list[mcp.types.Root]) -> ListRootsFnT | None:
14
+ async def _roots_callback(
15
+ context: RequestContext[ClientSession, LifespanContextT],
16
+ ) -> mcp.types.ListRootsResult:
17
+ return mcp.types.ListRootsResult(roots=roots)
18
+
19
+ return _roots_callback
20
+
21
+
22
+ class BaseClient(abc.ABC):
23
+ def __init__(
24
+ self,
25
+ roots: list[mcp.types.Root] | None = None,
26
+ sampling_callback: SamplingFnT | None = None,
27
+ list_roots_callback: ListRootsFnT | None = None,
28
+ logging_callback: LoggingFnT | None = None,
29
+ message_handler: MessageHandlerFnT | None = None,
30
+ read_timeout_seconds: datetime.timedelta | None = None,
31
+ ):
32
+ self._transport: Any = None
33
+ self._session: Optional[ClientSession] = None
34
+ self._cm: Optional[AsyncContextManager] = None
35
+
36
+ if roots is not None:
37
+ if list_roots_callback is not None:
38
+ raise ValueError(
39
+ "Cannot provide both `roots` and `list_roots_callback`. "
40
+ "Either provide a list of roots or a callback to list roots."
41
+ )
42
+ else:
43
+ list_roots_callback = _get_roots_callback(roots)
44
+
45
+ self._sampling_callback = sampling_callback
46
+ self._list_roots_callback = list_roots_callback
47
+ self._logging_callback = logging_callback
48
+ self._message_handler = message_handler
49
+ self._read_timeout_seconds = read_timeout_seconds
50
+
51
+ @property
52
+ def transport(self):
53
+ """Get the current transport connection"""
54
+ if self._transport is None:
55
+ raise RuntimeError(
56
+ "Client is not connected. Use 'async with client:' context manager first."
57
+ )
58
+ return self._transport
59
+
60
+ @property
61
+ def session(self):
62
+ """Get the current session"""
63
+ if self._session is None:
64
+ raise RuntimeError(
65
+ "Client is not connected. Use 'async with client:' context manager first."
66
+ )
67
+ return self._session
68
+
69
+ def is_connected(self):
70
+ """Check if the client is currently connected"""
71
+ return self._session is not None
72
+
73
+ @abc.abstractmethod
74
+ def _connect(
75
+ self,
76
+ sampling_callback: SamplingFnT | None = None,
77
+ list_roots_callback: ListRootsFnT | None = None,
78
+ logging_callback: LoggingFnT | None = None,
79
+ message_handler: MessageHandlerFnT | None = None,
80
+ ) -> AsyncContextManager:
81
+ """Return an async context manager that handles connection lifecycle.
82
+ This will be called by __aenter__ to establish the connection."""
83
+ raise NotImplementedError("Subclasses must implement this method")
84
+
85
+ @contextlib.asynccontextmanager
86
+ async def _create_connection_context(self):
87
+ """Create and manage the connection context if not already connected.
88
+ This handles both creating a new connection or reusing an existing one."""
89
+ created_connection = False
90
+ try:
91
+ if not self.is_connected():
92
+ # Only create a new connection if not already connected
93
+ self._cm = self._connect()
94
+ await self._cm.__aenter__()
95
+ created_connection = True
96
+ yield
97
+ finally:
98
+ if created_connection and self._cm is not None:
99
+ # Only close if we created the connection in this context
100
+ await self._cm.__aexit__(None, None, None)
101
+ self._transport = None
102
+ self._session = None
103
+ self._cm = None
104
+
105
+ @contextlib.asynccontextmanager
106
+ async def _set_session(self, transport: Any, session: ClientSession):
107
+ self._transport = transport
108
+ self._session = session
109
+ try:
110
+ await self._session.initialize()
111
+ yield
112
+ finally:
113
+ self._transport = None
114
+ self._session = None
115
+
116
+ async def __aenter__(self):
117
+ self._connection_ctx = self._create_connection_context()
118
+ await self._connection_ctx.__aenter__()
119
+ return self
120
+
121
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
122
+ await self._connection_ctx.__aexit__(exc_type, exc_val, exc_tb)
123
+
124
+ # --- MCP Client Methods ---
125
+
126
+ async def ping(self) -> None:
127
+ """Send a ping request."""
128
+ await self.session.send_ping()
129
+
130
+ async def progress(
131
+ self, progress_token: str | int, progress: float, total: float | None = None
132
+ ) -> None:
133
+ """Send a progress notification."""
134
+ await self.session.send_progress_notification(progress_token, progress, total)
135
+
136
+ async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
137
+ """Send a logging/setLevel request."""
138
+ await self.session.set_logging_level(level)
139
+
140
+ async def list_resources(self) -> mcp.types.ListResourcesResult:
141
+ """Send a resources/list request."""
142
+ return await self.session.list_resources()
143
+
144
+ async def list_resource_templates(self) -> mcp.types.ListResourceTemplatesResult:
145
+ """Send a resources/listResourceTemplates request."""
146
+ return await self.session.list_resource_templates()
147
+
148
+ async def read_resource(self, uri: AnyUrl) -> mcp.types.ReadResourceResult:
149
+ """Send a resources/read request."""
150
+ return await self.session.read_resource(uri)
151
+
152
+ async def subscribe_resource(self, uri: AnyUrl) -> None:
153
+ """Send a resources/subscribe request."""
154
+ await self.session.subscribe_resource(uri)
155
+
156
+ async def unsubscribe_resource(self, uri: AnyUrl) -> None:
157
+ """Send a resources/unsubscribe request."""
158
+ await self.session.unsubscribe_resource(uri)
159
+
160
+ async def list_prompts(self) -> mcp.types.ListPromptsResult:
161
+ """Send a prompts/list request."""
162
+ return await self.session.list_prompts()
163
+
164
+ async def get_prompt(
165
+ self, name: str, arguments: dict[str, str] | None = None
166
+ ) -> mcp.types.GetPromptResult:
167
+ """Send a prompts/get request."""
168
+ return await self.session.get_prompt(name, arguments)
169
+
170
+ async def complete(
171
+ self,
172
+ ref: mcp.types.ResourceReference | mcp.types.PromptReference,
173
+ argument: dict[str, str],
174
+ ) -> mcp.types.CompleteResult:
175
+ """Send a completion/complete request."""
176
+ return await self.session.complete(ref, argument)
177
+
178
+ async def list_tools(self) -> mcp.types.ListToolsResult:
179
+ """Send a tools/list request."""
180
+ return await self.session.list_tools()
181
+
182
+ async def call_tool(
183
+ self, name: str, arguments: dict[str, Any] | None = None
184
+ ) -> mcp.types.CallToolResult:
185
+ """Send a tools/call request."""
186
+ return await self.session.call_tool(name, arguments)
187
+
188
+ async def send_roots_list_changed(self) -> None:
189
+ """Send a roots/list_changed notification."""
190
+ await self.session.send_roots_list_changed()
src/fastmcp/client/sse.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import contextlib
2
+ import datetime
3
+
4
+ import mcp.types
5
+ from mcp import ClientSession
6
+ from mcp.client.sse import sse_client
7
+
8
+ from fastmcp.client.base import (
9
+ BaseClient,
10
+ ListRootsFnT,
11
+ LoggingFnT,
12
+ MessageHandlerFnT,
13
+ SamplingFnT,
14
+ )
15
+
16
+
17
+ class SSEClient(BaseClient):
18
+ def __init__(
19
+ self,
20
+ url: str,
21
+ headers: dict[str, str] | None = None,
22
+ roots: list[mcp.types.Root] | None = None,
23
+ sampling_callback: SamplingFnT | None = None,
24
+ list_roots_callback: ListRootsFnT | None = None,
25
+ logging_callback: LoggingFnT | None = None,
26
+ message_handler: MessageHandlerFnT | None = None,
27
+ read_timeout_seconds: datetime.timedelta | None = None,
28
+ ):
29
+ super().__init__(
30
+ roots=roots,
31
+ sampling_callback=sampling_callback,
32
+ list_roots_callback=list_roots_callback,
33
+ logging_callback=logging_callback,
34
+ message_handler=message_handler,
35
+ read_timeout_seconds=read_timeout_seconds,
36
+ )
37
+ self.url = url
38
+ self.headers = headers or {}
39
+
40
+ @contextlib.asynccontextmanager
41
+ async def _connect(self):
42
+ """Set up SSE connection and session"""
43
+ async with sse_client(self.url, headers=self.headers) as transport:
44
+ read_stream, write_stream = transport
45
+ async with ClientSession(
46
+ read_stream=read_stream,
47
+ write_stream=write_stream,
48
+ sampling_callback=self._sampling_callback,
49
+ list_roots_callback=self._list_roots_callback,
50
+ logging_callback=self._logging_callback,
51
+ message_handler=self._message_handler,
52
+ read_timeout_seconds=self._read_timeout_seconds,
53
+ ) as session:
54
+ async with self._set_session(transport, session):
55
+ yield self
src/fastmcp/client/stdio.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import contextlib
2
+ import datetime
3
+
4
+ import mcp.types
5
+ from mcp import ClientSession, StdioServerParameters
6
+ from mcp.client.stdio import stdio_client
7
+
8
+ from fastmcp.client.base import (
9
+ BaseClient,
10
+ ListRootsFnT,
11
+ LoggingFnT,
12
+ MessageHandlerFnT,
13
+ SamplingFnT,
14
+ )
15
+
16
+
17
+ class StdioClient(BaseClient):
18
+ def __init__(
19
+ self,
20
+ server_script_path: str,
21
+ roots: list[mcp.types.Root] | None = None,
22
+ sampling_callback: SamplingFnT | None = None,
23
+ list_roots_callback: ListRootsFnT | None = None,
24
+ logging_callback: LoggingFnT | None = None,
25
+ message_handler: MessageHandlerFnT | None = None,
26
+ read_timeout_seconds: datetime.timedelta | None = None,
27
+ ):
28
+ super().__init__(
29
+ roots=roots,
30
+ sampling_callback=sampling_callback,
31
+ list_roots_callback=list_roots_callback,
32
+ logging_callback=logging_callback,
33
+ message_handler=message_handler,
34
+ read_timeout_seconds=read_timeout_seconds,
35
+ )
36
+ self.server_script_path = server_script_path
37
+
38
+ @contextlib.asynccontextmanager
39
+ async def _connect(self):
40
+ """Set up stdio connection and session"""
41
+ is_python = self.server_script_path.endswith(".py")
42
+ is_js = self.server_script_path.endswith(".js")
43
+ if not (is_python or is_js):
44
+ raise ValueError("Server script must be a .py or .js file")
45
+
46
+ command = "python" if is_python else "node"
47
+ server_params = StdioServerParameters(
48
+ command=command, args=[self.server_script_path], env=None
49
+ )
50
+
51
+ async with stdio_client(server_params) as transport:
52
+ stdio, write = transport
53
+
54
+ async with ClientSession(
55
+ read_stream=stdio,
56
+ write_stream=write,
57
+ sampling_callback=self._sampling_callback,
58
+ list_roots_callback=self._list_roots_callback,
59
+ logging_callback=self._logging_callback,
60
+ message_handler=self._message_handler,
61
+ read_timeout_seconds=self._read_timeout_seconds,
62
+ ) as session:
63
+ async with self._set_session(transport, session):
64
+ yield self
src/fastmcp/client/websocket.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import contextlib
2
+ import datetime
3
+
4
+ import mcp.types
5
+ from mcp import ClientSession
6
+ from mcp.client.websocket import websocket_client
7
+
8
+ from fastmcp.client.base import (
9
+ BaseClient,
10
+ ListRootsFnT,
11
+ LoggingFnT,
12
+ MessageHandlerFnT,
13
+ SamplingFnT,
14
+ )
15
+
16
+
17
+ class WebSocketClient(BaseClient):
18
+ def __init__(
19
+ self,
20
+ url: str,
21
+ roots: list[mcp.types.Root] | None = None,
22
+ sampling_callback: SamplingFnT | None = None,
23
+ list_roots_callback: ListRootsFnT | None = None,
24
+ logging_callback: LoggingFnT | None = None,
25
+ message_handler: MessageHandlerFnT | None = None,
26
+ read_timeout_seconds: datetime.timedelta | None = None,
27
+ ):
28
+ super().__init__(
29
+ roots=roots,
30
+ sampling_callback=sampling_callback,
31
+ list_roots_callback=list_roots_callback,
32
+ logging_callback=logging_callback,
33
+ message_handler=message_handler,
34
+ read_timeout_seconds=read_timeout_seconds,
35
+ )
36
+ self.url = url
37
+
38
+ @contextlib.asynccontextmanager
39
+ async def _connect(self):
40
+ """Set up WebSocket connection and session"""
41
+ async with websocket_client(self.url) as transport:
42
+ read_stream, write_stream = transport
43
+
44
+ async with ClientSession(
45
+ read_stream=read_stream,
46
+ write_stream=write_stream,
47
+ sampling_callback=self._sampling_callback,
48
+ list_roots_callback=self._list_roots_callback,
49
+ logging_callback=self._logging_callback,
50
+ message_handler=self._message_handler,
51
+ read_timeout_seconds=self._read_timeout_seconds,
52
+ ) as session:
53
+ async with self._set_session(transport, session):
54
+ yield self