Jeremiah Lowin commited on
Commit
45bedd0
·
unverified ·
2 Parent(s): 1bee8bed37ad3f

Merge pull request #512 from jlowin/sse-inference

Browse files
docs/clients/transports.mdx CHANGED
@@ -40,7 +40,7 @@ Streamable HTTP is the recommended transport for web-based deployments, providin
40
  #### Overview
41
 
42
  - **Class:** `fastmcp.client.transports.StreamableHttpTransport`
43
- - **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0)
44
  - **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode
45
 
46
  #### Basic Usage
@@ -62,6 +62,15 @@ async def main():
62
  asyncio.run(main())
63
  ```
64
 
 
 
 
 
 
 
 
 
 
65
  #### Authentication with Headers
66
 
67
  For servers requiring authentication:
@@ -88,23 +97,19 @@ Server-Sent Events (SSE) is a transport that allows servers to push data to clie
88
  #### Overview
89
 
90
  - **Class:** `fastmcp.client.transports.SSETransport`
91
- - **Inferred From:** Not automatically inferred for HTTP URLs since v2.3.0 (must be explicitly specified)
92
  - **Server Compatibility:** Works with FastMCP servers running in `sse` mode
93
 
94
  #### Basic Usage
95
 
96
- Since v2.3.0, you must explicitly create an `SSETransport` for SSE connections:
97
 
98
  ```python
99
  from fastmcp import Client
100
- from fastmcp.client.transports import SSETransport
101
  import asyncio
102
 
103
- # Create an SSE transport
104
- transport = SSETransport(url="https://example.com/sse")
105
-
106
- # Pass the transport to the client
107
- client = Client(transport)
108
 
109
  async def main():
110
  async with client:
@@ -114,6 +119,15 @@ async def main():
114
  asyncio.run(main())
115
  ```
116
 
 
 
 
 
 
 
 
 
 
117
  #### Authentication with Headers
118
 
119
  SSE transport also supports custom headers for authentication:
 
40
  #### Overview
41
 
42
  - **Class:** `fastmcp.client.transports.StreamableHttpTransport`
43
+ - **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) that do not contain `/sse/` in the path
44
  - **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode
45
 
46
  #### Basic Usage
 
62
  asyncio.run(main())
63
  ```
64
 
65
+ You can also explicitly instantiate the transport:
66
+
67
+ ```python
68
+ from fastmcp.client.transports import StreamableHttpTransport
69
+
70
+ transport = StreamableHttpTransport(url="https://example.com/mcp")
71
+ client = Client(transport)
72
+ ```
73
+
74
  #### Authentication with Headers
75
 
76
  For servers requiring authentication:
 
97
  #### Overview
98
 
99
  - **Class:** `fastmcp.client.transports.SSETransport`
100
+ - **Inferred From:** HTTP URLs containing `/sse/` in the path
101
  - **Server Compatibility:** Works with FastMCP servers running in `sse` mode
102
 
103
  #### Basic Usage
104
 
105
+ The simplest way to use SSE is to let the transport be inferred from a URL with `/sse/` in the path:
106
 
107
  ```python
108
  from fastmcp import Client
 
109
  import asyncio
110
 
111
+ # The Client automatically uses SSETransport for URLs containing /sse/ in the path
112
+ client = Client("https://example.com/sse")
 
 
 
113
 
114
  async def main():
115
  async with client:
 
119
  asyncio.run(main())
120
  ```
121
 
122
+ You can also explicitly instantiate the transport for URLs that do not contain `/sse/` in the path or for more control:
123
+
124
+ ```python
125
+ from fastmcp.client.transports import SSETransport
126
+
127
+ transport = SSETransport(url="https://example.com/sse")
128
+ client = Client(transport)
129
+ ```
130
+
131
  #### Authentication with Headers
132
 
133
  SSE transport also supports custom headers for authentication:
src/fastmcp/client/transports.py CHANGED
@@ -1,14 +1,13 @@
1
  import abc
2
  import contextlib
3
  import datetime
4
- import inspect
5
  import os
6
  import shutil
7
  import sys
8
- import warnings
9
  from collections.abc import AsyncIterator
10
  from pathlib import Path
11
  from typing import Any, TypedDict, cast
 
12
 
13
  from mcp import ClientSession, StdioServerParameters
14
  from mcp.client.session import (
@@ -26,6 +25,9 @@ from pydantic import AnyUrl
26
  from typing_extensions import Unpack
27
 
28
  from fastmcp.server import FastMCP as FastMCPServer
 
 
 
29
 
30
 
31
  class SessionKwargs(TypedDict, total=False):
@@ -486,36 +488,29 @@ def infer_transport(
486
 
487
  # the transport is a FastMCP server
488
  elif isinstance(transport, FastMCPServer):
489
- return FastMCPTransport(mcp=transport)
490
 
491
  # the transport is a path to a script
492
  elif isinstance(transport, Path | str) and Path(transport).exists():
493
  if str(transport).endswith(".py"):
494
- return PythonStdioTransport(script_path=transport)
495
  elif str(transport).endswith(".js"):
496
- return NodeStdioTransport(script_path=transport)
497
  else:
498
  raise ValueError(f"Unsupported script type: {transport}")
499
 
500
  # the transport is an http(s) URL
501
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
502
- if str(transport).rstrip("/").endswith("/sse"):
503
- warnings.warn(
504
- inspect.cleandoc(
505
- """
506
- As of FastMCP 2.3.0, HTTP URLs are inferred to use Streamable HTTP.
507
- The provided URL ends in `/sse`, so you may encounter unexpected behavior.
508
- If you intended to use SSE, please use the `SSETransport` class directly.
509
- """
510
- ),
511
- category=UserWarning,
512
- stacklevel=2,
513
- )
514
- return StreamableHttpTransport(url=transport)
515
-
516
- # the transport is a websocket URL
517
- elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"):
518
- return WSTransport(url=transport)
519
 
520
  ## if the transport is a config dict
521
  elif isinstance(transport, dict):
@@ -530,7 +525,7 @@ def infer_transport(
530
  server_name = list(server.keys())[0]
531
  # Stdio transport
532
  if "command" in server[server_name] and "args" in server[server_name]:
533
- return StdioTransport(
534
  command=server[server_name]["command"],
535
  args=server[server_name]["args"],
536
  env=server[server_name].get("env", None),
@@ -539,7 +534,7 @@ def infer_transport(
539
 
540
  # HTTP transport
541
  elif "url" in server:
542
- return SSETransport(
543
  url=server["url"],
544
  headers=server.get("headers", None),
545
  )
@@ -549,3 +544,6 @@ def infer_transport(
549
  # the transport is an unknown type
550
  else:
551
  raise ValueError(f"Could not infer a valid transport from: {transport}")
 
 
 
 
1
  import abc
2
  import contextlib
3
  import datetime
 
4
  import os
5
  import shutil
6
  import sys
 
7
  from collections.abc import AsyncIterator
8
  from pathlib import Path
9
  from typing import Any, TypedDict, cast
10
+ from urllib.parse import urlparse
11
 
12
  from mcp import ClientSession, StdioServerParameters
13
  from mcp.client.session import (
 
25
  from typing_extensions import Unpack
26
 
27
  from fastmcp.server import FastMCP as FastMCPServer
28
+ from fastmcp.utilities.logging import get_logger
29
+
30
+ logger = get_logger(__name__)
31
 
32
 
33
  class SessionKwargs(TypedDict, total=False):
 
488
 
489
  # the transport is a FastMCP server
490
  elif isinstance(transport, FastMCPServer):
491
+ inferred_transport = FastMCPTransport(mcp=transport)
492
 
493
  # the transport is a path to a script
494
  elif isinstance(transport, Path | str) and Path(transport).exists():
495
  if str(transport).endswith(".py"):
496
+ inferred_transport = PythonStdioTransport(script_path=transport)
497
  elif str(transport).endswith(".js"):
498
+ inferred_transport = NodeStdioTransport(script_path=transport)
499
  else:
500
  raise ValueError(f"Unsupported script type: {transport}")
501
 
502
  # the transport is an http(s) URL
503
  elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
504
+ transport_str = str(transport)
505
+ # Parse out just the path portion to check for /sse
506
+ parsed_url = urlparse(transport_str)
507
+ path = parsed_url.path
508
+
509
+ # Check if path contains /sse/ or ends with /sse
510
+ if "/sse/" in path or path.rstrip("/").endswith("/sse"):
511
+ inferred_transport = SSETransport(url=transport)
512
+ else:
513
+ inferred_transport = StreamableHttpTransport(url=transport)
 
 
 
 
 
 
 
514
 
515
  ## if the transport is a config dict
516
  elif isinstance(transport, dict):
 
525
  server_name = list(server.keys())[0]
526
  # Stdio transport
527
  if "command" in server[server_name] and "args" in server[server_name]:
528
+ inferred_transport = StdioTransport(
529
  command=server[server_name]["command"],
530
  args=server[server_name]["args"],
531
  env=server[server_name].get("env", None),
 
534
 
535
  # HTTP transport
536
  elif "url" in server:
537
+ inferred_transport = SSETransport(
538
  url=server["url"],
539
  headers=server.get("headers", None),
540
  )
 
544
  # the transport is an unknown type
545
  else:
546
  raise ValueError(f"Could not infer a valid transport from: {transport}")
547
+
548
+ logger.debug(f"Inferred transport: {inferred_transport}")
549
+ return inferred_transport
tests/client/test_client.py CHANGED
@@ -7,7 +7,12 @@ from mcp import McpError
7
  from pydantic import AnyUrl
8
 
9
  from fastmcp.client import Client
10
- from fastmcp.client.transports import FastMCPTransport
 
 
 
 
 
11
  from fastmcp.exceptions import ResourceError, ToolError
12
  from fastmcp.prompts.prompt import TextContent
13
  from fastmcp.server.server import FastMCP
@@ -540,6 +545,10 @@ class TestTimeout:
540
  with pytest.raises(McpError):
541
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
542
 
 
 
 
 
543
  async def test_timeout_tool_call_overrides_client_timeout_even_if_lower(
544
  self, fastmcp_server: FastMCP
545
  ):
@@ -548,3 +557,53 @@ class TestTimeout:
548
  timeout=0.01,
549
  ) as client:
550
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from pydantic import AnyUrl
8
 
9
  from fastmcp.client import Client
10
+ from fastmcp.client.transports import (
11
+ FastMCPTransport,
12
+ SSETransport,
13
+ StreamableHttpTransport,
14
+ infer_transport,
15
+ )
16
  from fastmcp.exceptions import ResourceError, ToolError
17
  from fastmcp.prompts.prompt import TextContent
18
  from fastmcp.server.server import FastMCP
 
545
  with pytest.raises(McpError):
546
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
547
 
548
+ @pytest.mark.skipif(
549
+ sys.platform == "win32",
550
+ reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
551
+ )
552
  async def test_timeout_tool_call_overrides_client_timeout_even_if_lower(
553
  self, fastmcp_server: FastMCP
554
  ):
 
557
  timeout=0.01,
558
  ) as client:
559
  await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
560
+
561
+
562
+ class TestInferTransport:
563
+ """Tests for the infer_transport function."""
564
+
565
+ @pytest.mark.parametrize(
566
+ "url",
567
+ [
568
+ "http://example.com/api/sse/stream",
569
+ "https://localhost:8080/mcp/sse/endpoint",
570
+ "http://example.com/api/sse",
571
+ "https://localhost:8080/mcp/sse",
572
+ "http://example.com/api/sse?param=value",
573
+ "https://localhost:8080/mcp/sse/?param=value",
574
+ "https://localhost:8000/mcp/sse?x=1&y=2",
575
+ ],
576
+ ids=[
577
+ "path_with_sse_directory",
578
+ "path_with_sse_subdirectory",
579
+ "path_ending_with_sse",
580
+ "path_ending_with_sse_https",
581
+ "path_with_sse_and_query_params",
582
+ "path_with_sse_slash_and_query_params",
583
+ "path_with_sse_and_ampersand_param",
584
+ ],
585
+ )
586
+ def test_url_returns_sse_transport(self, url):
587
+ """Test that URLs with /sse/ pattern return SSETransport."""
588
+ assert isinstance(infer_transport(url), SSETransport)
589
+
590
+ @pytest.mark.parametrize(
591
+ "url",
592
+ [
593
+ "http://example.com/api",
594
+ "https://localhost:8080/mcp",
595
+ "http://example.com/asset/image.jpg",
596
+ "https://localhost:8080/sservice/endpoint",
597
+ "https://example.com/assets/file",
598
+ ],
599
+ ids=[
600
+ "regular_http_url",
601
+ "regular_https_url",
602
+ "url_with_unrelated_path",
603
+ "url_with_sservice_in_path",
604
+ "url_with_assets_in_path",
605
+ ],
606
+ )
607
+ def test_url_returns_streamable_http_transport(self, url):
608
+ """Test that URLs without /sse/ pattern return StreamableHttpTransport."""
609
+ assert isinstance(infer_transport(url), StreamableHttpTransport)