fastmcp 2.5.1__py3-none-any.whl → 2.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,25 @@
1
1
  import abc
2
+ import asyncio
2
3
  import contextlib
3
4
  import datetime
4
5
  import os
5
6
  import shutil
6
7
  import sys
7
- from collections.abc import AsyncIterator
8
+ import warnings
9
+ from collections.abc import AsyncIterator, Callable
8
10
  from pathlib import Path
9
- from typing import TYPE_CHECKING, Any, TypedDict, cast
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Literal,
15
+ TypedDict,
16
+ TypeVar,
17
+ cast,
18
+ overload,
19
+ )
10
20
 
21
+ import anyio
22
+ import httpx
11
23
  from mcp import ClientSession, StdioServerParameters
12
24
  from mcp.client.session import (
13
25
  ListRootsFnT,
@@ -24,7 +36,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
24
36
  from pydantic import AnyUrl
25
37
  from typing_extensions import Unpack
26
38
 
27
- from fastmcp.server import FastMCP as FastMCPServer
39
+ from fastmcp.client.auth.oauth import OAuth
28
40
  from fastmcp.server.dependencies import get_http_headers
29
41
  from fastmcp.server.server import FastMCP
30
42
  from fastmcp.utilities.logging import get_logger
@@ -35,6 +47,23 @@ if TYPE_CHECKING:
35
47
 
36
48
  logger = get_logger(__name__)
37
49
 
50
+ # TypeVar for preserving specific ClientTransport subclass types
51
+ ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
52
+
53
+ __all__ = [
54
+ "ClientTransport",
55
+ "SSETransport",
56
+ "StreamableHttpTransport",
57
+ "StdioTransport",
58
+ "PythonStdioTransport",
59
+ "FastMCPStdioTransport",
60
+ "NodeStdioTransport",
61
+ "UvxStdioTransport",
62
+ "NpxStdioTransport",
63
+ "FastMCPTransport",
64
+ "infer_transport",
65
+ ]
66
+
38
67
 
39
68
  class SessionKwargs(TypedDict, total=False):
40
69
  """Keyword arguments for the MCP ClientSession constructor."""
@@ -83,11 +112,25 @@ class ClientTransport(abc.ABC):
83
112
  # Basic representation for subclasses
84
113
  return f"<{self.__class__.__name__}>"
85
114
 
115
+ async def close(self):
116
+ """Close the transport."""
117
+ pass
118
+
119
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
120
+ if auth is not None:
121
+ raise ValueError("This transport does not support auth")
122
+
86
123
 
87
124
  class WSTransport(ClientTransport):
88
125
  """Transport implementation that connects to an MCP server via WebSockets."""
89
126
 
90
127
  def __init__(self, url: str | AnyUrl):
128
+ # we never really used this transport, so it can be removed at any time
129
+ warnings.warn(
130
+ "WSTransport is a deprecated MCP transport and will be removed in a future version. Use StreamableHttpTransport instead.",
131
+ DeprecationWarning,
132
+ stacklevel=2,
133
+ )
91
134
  if isinstance(url, AnyUrl):
92
135
  url = str(url)
93
136
  if not isinstance(url, str) or not url.startswith("ws"):
@@ -116,7 +159,9 @@ class SSETransport(ClientTransport):
116
159
  self,
117
160
  url: str | AnyUrl,
118
161
  headers: dict[str, str] | None = None,
162
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
119
163
  sse_read_timeout: datetime.timedelta | float | int | None = None,
164
+ httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
120
165
  ):
121
166
  if isinstance(url, AnyUrl):
122
167
  url = str(url)
@@ -124,11 +169,21 @@ class SSETransport(ClientTransport):
124
169
  raise ValueError("Invalid HTTP/S URL provided for SSE.")
125
170
  self.url = url
126
171
  self.headers = headers or {}
172
+ self._set_auth(auth)
173
+ self.httpx_client_factory = httpx_client_factory
127
174
 
128
175
  if isinstance(sse_read_timeout, int | float):
129
176
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
130
177
  self.sse_read_timeout = sse_read_timeout
131
178
 
179
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
180
+ if auth == "oauth":
181
+ auth = OAuth(self.url)
182
+ elif isinstance(auth, str):
183
+ self.headers["Authorization"] = auth
184
+ auth = None
185
+ self.auth = auth
186
+
132
187
  @contextlib.asynccontextmanager
133
188
  async def connect_session(
134
189
  self, **session_kwargs: Unpack[SessionKwargs]
@@ -150,7 +205,10 @@ class SSETransport(ClientTransport):
150
205
  )
151
206
  client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
152
207
 
153
- async with sse_client(self.url, **client_kwargs) as transport:
208
+ if self.httpx_client_factory is not None:
209
+ client_kwargs["httpx_client_factory"] = self.httpx_client_factory
210
+
211
+ async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport:
154
212
  read_stream, write_stream = transport
155
213
  async with ClientSession(
156
214
  read_stream, write_stream, **session_kwargs
@@ -168,7 +226,9 @@ class StreamableHttpTransport(ClientTransport):
168
226
  self,
169
227
  url: str | AnyUrl,
170
228
  headers: dict[str, str] | None = None,
229
+ auth: httpx.Auth | Literal["oauth"] | str | None = None,
171
230
  sse_read_timeout: datetime.timedelta | float | int | None = None,
231
+ httpx_client_factory: Callable[[], httpx.AsyncClient] | None = None,
172
232
  ):
173
233
  if isinstance(url, AnyUrl):
174
234
  url = str(url)
@@ -176,11 +236,21 @@ class StreamableHttpTransport(ClientTransport):
176
236
  raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
177
237
  self.url = url
178
238
  self.headers = headers or {}
239
+ self._set_auth(auth)
240
+ self.httpx_client_factory = httpx_client_factory
179
241
 
180
242
  if isinstance(sse_read_timeout, int | float):
181
243
  sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
182
244
  self.sse_read_timeout = sse_read_timeout
183
245
 
246
+ def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None):
247
+ if auth == "oauth":
248
+ auth = OAuth(self.url)
249
+ elif isinstance(auth, str):
250
+ self.headers["Authorization"] = auth
251
+ auth = None
252
+ self.auth = auth
253
+
184
254
  @contextlib.asynccontextmanager
185
255
  async def connect_session(
186
256
  self, **session_kwargs: Unpack[SessionKwargs]
@@ -199,7 +269,14 @@ class StreamableHttpTransport(ClientTransport):
199
269
  if session_kwargs.get("read_timeout_seconds", None) is not None:
200
270
  client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
201
271
 
202
- async with streamablehttp_client(self.url, **client_kwargs) as transport:
272
+ if self.httpx_client_factory is not None:
273
+ client_kwargs["httpx_client_factory"] = self.httpx_client_factory
274
+
275
+ async with streamablehttp_client(
276
+ self.url,
277
+ auth=self.auth,
278
+ **client_kwargs,
279
+ ) as transport:
203
280
  read_stream, write_stream, _ = transport
204
281
  async with ClientSession(
205
282
  read_stream, write_stream, **session_kwargs
@@ -224,6 +301,7 @@ class StdioTransport(ClientTransport):
224
301
  args: list[str],
225
302
  env: dict[str, str] | None = None,
226
303
  cwd: str | None = None,
304
+ keep_alive: bool | None = None,
227
305
  ):
228
306
  """
229
307
  Initialize a Stdio transport.
@@ -233,25 +311,90 @@ class StdioTransport(ClientTransport):
233
311
  args: The arguments to pass to the command
234
312
  env: Environment variables to set for the subprocess
235
313
  cwd: Current working directory for the subprocess
314
+ keep_alive: Whether to keep the subprocess alive between connections.
315
+ Defaults to True. When True, the subprocess remains active
316
+ after the connection context exits, allowing reuse in
317
+ subsequent connections.
236
318
  """
237
319
  self.command = command
238
320
  self.args = args
239
321
  self.env = env
240
322
  self.cwd = cwd
323
+ if keep_alive is None:
324
+ keep_alive = True
325
+ self.keep_alive = keep_alive
326
+
327
+ self._session: ClientSession | None = None
328
+ self._connect_task: asyncio.Task | None = None
329
+ self._ready_event = anyio.Event()
330
+ self._stop_event = anyio.Event()
241
331
 
242
332
  @contextlib.asynccontextmanager
243
333
  async def connect_session(
244
334
  self, **session_kwargs: Unpack[SessionKwargs]
245
335
  ) -> AsyncIterator[ClientSession]:
246
- server_params = StdioServerParameters(
247
- command=self.command, args=self.args, env=self.env, cwd=self.cwd
248
- )
249
- async with stdio_client(server_params) as transport:
250
- read_stream, write_stream = transport
251
- async with ClientSession(
252
- read_stream, write_stream, **session_kwargs
253
- ) as session:
254
- yield session
336
+ try:
337
+ await self.connect(**session_kwargs)
338
+ assert self._session is not None
339
+ yield self._session
340
+ finally:
341
+ if not self.keep_alive:
342
+ await self.disconnect()
343
+ else:
344
+ logger.debug("Stdio transport has keep_alive=True, not disconnecting")
345
+
346
+ async def connect(
347
+ self, **session_kwargs: Unpack[SessionKwargs]
348
+ ) -> ClientSession | None:
349
+ if self._connect_task is not None:
350
+ return
351
+
352
+ async def _connect_task():
353
+ async with contextlib.AsyncExitStack() as stack:
354
+ try:
355
+ server_params = StdioServerParameters(
356
+ command=self.command, args=self.args, env=self.env, cwd=self.cwd
357
+ )
358
+ transport = await stack.enter_async_context(
359
+ stdio_client(server_params)
360
+ )
361
+ read_stream, write_stream = transport
362
+ self._session = await stack.enter_async_context(
363
+ ClientSession(read_stream, write_stream, **session_kwargs)
364
+ )
365
+
366
+ logger.debug("Stdio transport connected")
367
+ self._ready_event.set()
368
+
369
+ # Wait until disconnect is requested (stop_event is set)
370
+ await self._stop_event.wait()
371
+ finally:
372
+ # Clean up client on exit
373
+ self._session = None
374
+ logger.debug("Stdio transport disconnected")
375
+
376
+ # start the connection task
377
+ self._connect_task = asyncio.create_task(_connect_task())
378
+ # wait for the client to be ready before returning
379
+ await self._ready_event.wait()
380
+
381
+ async def disconnect(self):
382
+ if self._connect_task is None:
383
+ return
384
+
385
+ # signal the connection task to stop
386
+ self._stop_event.set()
387
+
388
+ # wait for the connection task to finish cleanly
389
+ await self._connect_task
390
+
391
+ # reset variables and events for potential future reconnects
392
+ self._connect_task = None
393
+ self._stop_event = anyio.Event()
394
+ self._ready_event = anyio.Event()
395
+
396
+ async def close(self):
397
+ await self.disconnect()
255
398
 
256
399
  def __repr__(self) -> str:
257
400
  return (
@@ -269,6 +412,7 @@ class PythonStdioTransport(StdioTransport):
269
412
  env: dict[str, str] | None = None,
270
413
  cwd: str | None = None,
271
414
  python_cmd: str = sys.executable,
415
+ keep_alive: bool | None = None,
272
416
  ):
273
417
  """
274
418
  Initialize a Python transport.
@@ -279,6 +423,10 @@ class PythonStdioTransport(StdioTransport):
279
423
  env: Environment variables to set for the subprocess
280
424
  cwd: Current working directory for the subprocess
281
425
  python_cmd: Python command to use (default: "python")
426
+ keep_alive: Whether to keep the subprocess alive between connections.
427
+ Defaults to True. When True, the subprocess remains active
428
+ after the connection context exits, allowing reuse in
429
+ subsequent connections.
282
430
  """
283
431
  script_path = Path(script_path).resolve()
284
432
  if not script_path.is_file():
@@ -290,7 +438,13 @@ class PythonStdioTransport(StdioTransport):
290
438
  if args:
291
439
  full_args.extend(args)
292
440
 
293
- super().__init__(command=python_cmd, args=full_args, env=env, cwd=cwd)
441
+ super().__init__(
442
+ command=python_cmd,
443
+ args=full_args,
444
+ env=env,
445
+ cwd=cwd,
446
+ keep_alive=keep_alive,
447
+ )
294
448
  self.script_path = script_path
295
449
 
296
450
 
@@ -303,6 +457,7 @@ class FastMCPStdioTransport(StdioTransport):
303
457
  args: list[str] | None = None,
304
458
  env: dict[str, str] | None = None,
305
459
  cwd: str | None = None,
460
+ keep_alive: bool | None = None,
306
461
  ):
307
462
  script_path = Path(script_path).resolve()
308
463
  if not script_path.is_file():
@@ -311,7 +466,11 @@ class FastMCPStdioTransport(StdioTransport):
311
466
  raise ValueError(f"Not a Python script: {script_path}")
312
467
 
313
468
  super().__init__(
314
- command="fastmcp", args=["run", str(script_path)], env=env, cwd=cwd
469
+ command="fastmcp",
470
+ args=["run", str(script_path)],
471
+ env=env,
472
+ cwd=cwd,
473
+ keep_alive=keep_alive,
315
474
  )
316
475
  self.script_path = script_path
317
476
 
@@ -326,6 +485,7 @@ class NodeStdioTransport(StdioTransport):
326
485
  env: dict[str, str] | None = None,
327
486
  cwd: str | None = None,
328
487
  node_cmd: str = "node",
488
+ keep_alive: bool | None = None,
329
489
  ):
330
490
  """
331
491
  Initialize a Node transport.
@@ -336,6 +496,10 @@ class NodeStdioTransport(StdioTransport):
336
496
  env: Environment variables to set for the subprocess
337
497
  cwd: Current working directory for the subprocess
338
498
  node_cmd: Node.js command to use (default: "node")
499
+ keep_alive: Whether to keep the subprocess alive between connections.
500
+ Defaults to True. When True, the subprocess remains active
501
+ after the connection context exits, allowing reuse in
502
+ subsequent connections.
339
503
  """
340
504
  script_path = Path(script_path).resolve()
341
505
  if not script_path.is_file():
@@ -347,7 +511,9 @@ class NodeStdioTransport(StdioTransport):
347
511
  if args:
348
512
  full_args.extend(args)
349
513
 
350
- super().__init__(command=node_cmd, args=full_args, env=env, cwd=cwd)
514
+ super().__init__(
515
+ command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive
516
+ )
351
517
  self.script_path = script_path
352
518
 
353
519
 
@@ -363,6 +529,7 @@ class UvxStdioTransport(StdioTransport):
363
529
  with_packages: list[str] | None = None,
364
530
  from_package: str | None = None,
365
531
  env_vars: dict[str, str] | None = None,
532
+ keep_alive: bool | None = None,
366
533
  ):
367
534
  """
368
535
  Initialize a Uvx transport.
@@ -375,6 +542,10 @@ class UvxStdioTransport(StdioTransport):
375
542
  with_packages: Additional packages to include
376
543
  from_package: Package to install the tool from
377
544
  env_vars: Additional environment variables
545
+ keep_alive: Whether to keep the subprocess alive between connections.
546
+ Defaults to True. When True, the subprocess remains active
547
+ after the connection context exits, allowing reuse in
548
+ subsequent connections.
378
549
  """
379
550
  # Basic validation
380
551
  if project_directory and not Path(project_directory).exists():
@@ -402,7 +573,13 @@ class UvxStdioTransport(StdioTransport):
402
573
  env = os.environ.copy()
403
574
  env.update(env_vars)
404
575
 
405
- super().__init__(command="uvx", args=uvx_args, env=env, cwd=project_directory)
576
+ super().__init__(
577
+ command="uvx",
578
+ args=uvx_args,
579
+ env=env,
580
+ cwd=project_directory,
581
+ keep_alive=keep_alive,
582
+ )
406
583
  self.tool_name = tool_name
407
584
 
408
585
 
@@ -416,6 +593,7 @@ class NpxStdioTransport(StdioTransport):
416
593
  project_directory: str | None = None,
417
594
  env_vars: dict[str, str] | None = None,
418
595
  use_package_lock: bool = True,
596
+ keep_alive: bool | None = None,
419
597
  ):
420
598
  """
421
599
  Initialize an Npx transport.
@@ -426,6 +604,10 @@ class NpxStdioTransport(StdioTransport):
426
604
  project_directory: Project directory with package.json
427
605
  env_vars: Additional environment variables
428
606
  use_package_lock: Whether to use package-lock.json (--prefer-offline)
607
+ keep_alive: Whether to keep the subprocess alive between connections.
608
+ Defaults to True. When True, the subprocess remains active
609
+ after the connection context exits, allowing reuse in
610
+ subsequent connections.
429
611
  """
430
612
  # verify npx is installed
431
613
  if shutil.which("npx") is None:
@@ -453,7 +635,13 @@ class NpxStdioTransport(StdioTransport):
453
635
  env = os.environ.copy()
454
636
  env.update(env_vars)
455
637
 
456
- super().__init__(command="npx", args=npx_args, env=env, cwd=project_directory)
638
+ super().__init__(
639
+ command="npx",
640
+ args=npx_args,
641
+ env=env,
642
+ cwd=project_directory,
643
+ keep_alive=keep_alive,
644
+ )
457
645
  self.package = package
458
646
 
459
647
 
@@ -466,7 +654,7 @@ class FastMCPTransport(ClientTransport):
466
654
  tests or scenarios where client and server run in the same runtime.
467
655
  """
468
656
 
469
- def __init__(self, mcp: FastMCPServer | FastMCP1Server):
657
+ def __init__(self, mcp: FastMCP | FastMCP1Server):
470
658
  """Initialize a FastMCPTransport from a FastMCP server instance."""
471
659
 
472
660
  # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
@@ -575,9 +763,47 @@ class MCPConfigTransport(ClientTransport):
575
763
  return f"<MCPConfig(config='{self.config}')>"
576
764
 
577
765
 
766
+ @overload
767
+ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ...
768
+
769
+
770
+ @overload
771
+ def infer_transport(transport: FastMCP) -> FastMCPTransport: ...
772
+
773
+
774
+ @overload
775
+ def infer_transport(transport: FastMCP1Server) -> FastMCPTransport: ...
776
+
777
+
778
+ @overload
779
+ def infer_transport(transport: MCPConfig) -> MCPConfigTransport: ...
780
+
781
+
782
+ @overload
783
+ def infer_transport(transport: dict[str, Any]) -> MCPConfigTransport: ...
784
+
785
+
786
+ @overload
787
+ def infer_transport(
788
+ transport: AnyUrl,
789
+ ) -> SSETransport | StreamableHttpTransport: ...
790
+
791
+
792
+ @overload
793
+ def infer_transport(
794
+ transport: str,
795
+ ) -> (
796
+ PythonStdioTransport | NodeStdioTransport | SSETransport | StreamableHttpTransport
797
+ ): ...
798
+
799
+
800
+ @overload
801
+ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTransport: ...
802
+
803
+
578
804
  def infer_transport(
579
805
  transport: ClientTransport
580
- | FastMCPServer
806
+ | FastMCP
581
807
  | FastMCP1Server
582
808
  | AnyUrl
583
809
  | Path
@@ -594,7 +820,7 @@ def infer_transport(
594
820
 
595
821
  The function supports these input types:
596
822
  - ClientTransport: Used directly without modification
597
- - FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
823
+ - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
598
824
  - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
599
825
  - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
600
826
  - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
@@ -632,7 +858,7 @@ def infer_transport(
632
858
  return transport
633
859
 
634
860
  # the transport is a FastMCP server (2.x or 1.0)
635
- elif isinstance(transport, FastMCPServer | FastMCP1Server):
861
+ elif isinstance(transport, FastMCP | FastMCP1Server):
636
862
  inferred_transport = FastMCPTransport(mcp=transport)
637
863
 
638
864
  # the transport is a path to a script
@@ -148,7 +148,7 @@ class ResourceTemplate(BaseModel):
148
148
  f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
149
149
  )
150
150
 
151
- description = description or fn.__doc__ or ""
151
+ description = description or fn.__doc__
152
152
 
153
153
  if not inspect.isroutine(fn):
154
154
  fn = fn.__call__
@@ -0,0 +1,4 @@
1
+ from .providers.bearer import BearerAuthProvider
2
+
3
+
4
+ __all__ = ["BearerAuthProvider"]
@@ -0,0 +1,45 @@
1
+ from mcp.server.auth.provider import (
2
+ AccessToken,
3
+ AuthorizationCode,
4
+ OAuthAuthorizationServerProvider,
5
+ RefreshToken,
6
+ )
7
+ from mcp.server.auth.settings import (
8
+ ClientRegistrationOptions,
9
+ RevocationOptions,
10
+ )
11
+ from pydantic import AnyHttpUrl
12
+
13
+
14
+ class OAuthProvider(
15
+ OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
16
+ ):
17
+ def __init__(
18
+ self,
19
+ issuer_url: AnyHttpUrl | str,
20
+ service_documentation_url: AnyHttpUrl | str | None = None,
21
+ client_registration_options: ClientRegistrationOptions | None = None,
22
+ revocation_options: RevocationOptions | None = None,
23
+ required_scopes: list[str] | None = None,
24
+ ):
25
+ """
26
+ Initialize the OAuth provider.
27
+
28
+ Args:
29
+ issuer_url: The URL of the OAuth issuer.
30
+ service_documentation_url: The URL of the service documentation.
31
+ client_registration_options: The client registration options.
32
+ revocation_options: The revocation options.
33
+ required_scopes: Scopes that are required for all requests.
34
+ """
35
+ super().__init__()
36
+ if isinstance(issuer_url, str):
37
+ issuer_url = AnyHttpUrl(issuer_url)
38
+ if isinstance(service_documentation_url, str):
39
+ service_documentation_url = AnyHttpUrl(service_documentation_url)
40
+
41
+ self.issuer_url = issuer_url
42
+ self.service_documentation_url = service_documentation_url
43
+ self.client_registration_options = client_registration_options
44
+ self.revocation_options = revocation_options
45
+ self.required_scopes = required_scopes