Jeremiah Lowin commited on
Commit
ba8f800
·
unverified ·
2 Parent(s): a1b3f72ba8f065

Merge branch 'main' into fix-typing

Browse files
src/fastmcp/client/auth.py CHANGED
@@ -25,9 +25,9 @@ from pydantic import AnyHttpUrl, ValidationError
25
 
26
  from fastmcp.client.oauth_callback import (
27
  create_oauth_callback_server,
28
- find_available_port,
29
  )
30
  from fastmcp.settings import settings as fastmcp_global_settings
 
31
  from fastmcp.utilities.logging import get_logger
32
 
33
  __all__ = ["OAuth"]
 
25
 
26
  from fastmcp.client.oauth_callback import (
27
  create_oauth_callback_server,
 
28
  )
29
  from fastmcp.settings import settings as fastmcp_global_settings
30
+ from fastmcp.utilities.http import find_available_port
31
  from fastmcp.utilities.logging import get_logger
32
 
33
  __all__ = ["OAuth"]
src/fastmcp/client/oauth_callback.py CHANGED
@@ -8,7 +8,6 @@ and display styled responses to users.
8
  from __future__ import annotations
9
 
10
  import asyncio
11
- import socket
12
  from dataclasses import dataclass
13
 
14
  from starlette.applications import Starlette
@@ -17,6 +16,7 @@ from starlette.responses import HTMLResponse
17
  from starlette.routing import Route
18
  from uvicorn import Config, Server
19
 
 
20
  from fastmcp.utilities.logging import get_logger
21
 
22
  logger = get_logger(__name__)
@@ -179,13 +179,6 @@ def create_callback_html(
179
  """
180
 
181
 
182
- def find_available_port() -> int:
183
- """Find an available port by letting the OS assign one."""
184
- with socket.socket() as s:
185
- s.bind(("127.0.0.1", 0))
186
- return s.getsockname()[1]
187
-
188
-
189
  @dataclass
190
  class CallbackResponse:
191
  code: str | None = None
 
8
  from __future__ import annotations
9
 
10
  import asyncio
 
11
  from dataclasses import dataclass
12
 
13
  from starlette.applications import Starlette
 
16
  from starlette.routing import Route
17
  from uvicorn import Config, Server
18
 
19
+ from fastmcp.utilities.http import find_available_port
20
  from fastmcp.utilities.logging import get_logger
21
 
22
  logger = get_logger(__name__)
 
179
  """
180
 
181
 
 
 
 
 
 
 
 
182
  @dataclass
183
  class CallbackResponse:
184
  code: str | None = None
src/fastmcp/client/transports.py CHANGED
@@ -37,7 +37,6 @@ from pydantic import AnyUrl
37
  from typing_extensions import Unpack
38
 
39
  from fastmcp.client.auth import OAuth
40
- from fastmcp.server import FastMCP as FastMCPServer
41
  from fastmcp.server.dependencies import get_http_headers
42
  from fastmcp.server.server import FastMCP
43
  from fastmcp.utilities.logging import get_logger
@@ -55,7 +54,6 @@ __all__ = [
55
  "ClientTransport",
56
  "SSETransport",
57
  "StreamableHttpTransport",
58
- "FastMCPServer",
59
  "StdioTransport",
60
  "PythonStdioTransport",
61
  "FastMCPStdioTransport",
@@ -656,7 +654,7 @@ class FastMCPTransport(ClientTransport):
656
  tests or scenarios where client and server run in the same runtime.
657
  """
658
 
659
- def __init__(self, mcp: FastMCPServer | FastMCP1Server):
660
  """Initialize a FastMCPTransport from a FastMCP server instance."""
661
 
662
  # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
@@ -770,7 +768,7 @@ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ...
770
 
771
 
772
  @overload
773
- def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ...
774
 
775
 
776
  @overload
@@ -805,7 +803,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor
805
 
806
  def infer_transport(
807
  transport: ClientTransport
808
- | FastMCPServer
809
  | FastMCP1Server
810
  | AnyUrl
811
  | Path
@@ -822,7 +820,7 @@ def infer_transport(
822
 
823
  The function supports these input types:
824
  - ClientTransport: Used directly without modification
825
- - FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
826
  - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
827
  - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
828
  - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
@@ -860,7 +858,7 @@ def infer_transport(
860
  return transport
861
 
862
  # the transport is a FastMCP server (2.x or 1.0)
863
- elif isinstance(transport, FastMCPServer | FastMCP1Server):
864
  inferred_transport = FastMCPTransport(mcp=transport)
865
 
866
  # the transport is a path to a script
 
37
  from typing_extensions import Unpack
38
 
39
  from fastmcp.client.auth import OAuth
 
40
  from fastmcp.server.dependencies import get_http_headers
41
  from fastmcp.server.server import FastMCP
42
  from fastmcp.utilities.logging import get_logger
 
54
  "ClientTransport",
55
  "SSETransport",
56
  "StreamableHttpTransport",
 
57
  "StdioTransport",
58
  "PythonStdioTransport",
59
  "FastMCPStdioTransport",
 
654
  tests or scenarios where client and server run in the same runtime.
655
  """
656
 
657
+ def __init__(self, mcp: FastMCP | FastMCP1Server):
658
  """Initialize a FastMCPTransport from a FastMCP server instance."""
659
 
660
  # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
 
768
 
769
 
770
  @overload
771
+ def infer_transport(transport: FastMCP) -> FastMCPTransport: ...
772
 
773
 
774
  @overload
 
803
 
804
  def infer_transport(
805
  transport: ClientTransport
806
+ | FastMCP
807
  | FastMCP1Server
808
  | AnyUrl
809
  | Path
 
820
 
821
  The function supports these input types:
822
  - ClientTransport: Used directly without modification
823
+ - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport
824
  - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
825
  - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
826
  - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
 
858
  return transport
859
 
860
  # the transport is a FastMCP server (2.x or 1.0)
861
+ elif isinstance(transport, FastMCP | FastMCP1Server):
862
  inferred_transport = FastMCPTransport(mcp=transport)
863
 
864
  # the transport is a path to a script
src/fastmcp/server/auth/auth.py CHANGED
@@ -23,6 +23,16 @@ class OAuthProvider(
23
  revocation_options: RevocationOptions | None = None,
24
  required_scopes: list[str] | None = None,
25
  ):
 
 
 
 
 
 
 
 
 
 
26
  super().__init__()
27
  if isinstance(issuer_url, str):
28
  issuer_url = AnyHttpUrl(issuer_url)
 
23
  revocation_options: RevocationOptions | None = None,
24
  required_scopes: list[str] | None = None,
25
  ):
26
+ """
27
+ Initialize the OAuth provider.
28
+
29
+ Args:
30
+ issuer_url: The URL of the OAuth issuer.
31
+ service_documentation_url: The URL of the service documentation.
32
+ client_registration_options: The client registration options.
33
+ revocation_options: The revocation options.
34
+ required_scopes: Scopes that are required for all requests.
35
+ """
36
  super().__init__()
37
  if isinstance(issuer_url, str):
38
  issuer_url = AnyHttpUrl(issuer_url)
src/fastmcp/utilities/http.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import socket
2
+
3
+
4
+ def find_available_port() -> int:
5
+ """Find an available port by letting the OS assign one."""
6
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
7
+ s.bind(("127.0.0.1", 0))
8
+ return s.getsockname()[1]
src/fastmcp/utilities/tests.py CHANGED
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal
11
  import uvicorn
12
 
13
  from fastmcp.settings import settings
 
14
 
15
  if TYPE_CHECKING:
16
  from fastmcp.server.server import FastMCP
@@ -84,9 +85,7 @@ def run_server_in_process(
84
  The server URL.
85
  """
86
  host = "127.0.0.1"
87
- with socket.socket() as s:
88
- s.bind((host, 0))
89
- port = s.getsockname()[1]
90
 
91
  proc = multiprocessing.Process(
92
  target=server_fn, args=(host, port, *args), daemon=True
 
11
  import uvicorn
12
 
13
  from fastmcp.settings import settings
14
+ from fastmcp.utilities.http import find_available_port
15
 
16
  if TYPE_CHECKING:
17
  from fastmcp.server.server import FastMCP
 
85
  The server URL.
86
  """
87
  host = "127.0.0.1"
88
+ port = find_available_port()
 
 
89
 
90
  proc = multiprocessing.Process(
91
  target=server_fn, args=(host, port, *args), daemon=True
tests/auth/__init__.py ADDED
File without changes
tests/{client/test_oauth.py → auth/test_oauth_client.py} RENAMED
@@ -11,7 +11,7 @@ import fastmcp.client.auth # Import module, not the function directly
11
  from fastmcp.client import Client
12
  from fastmcp.client.transports import StreamableHttpTransport
13
  from fastmcp.server.auth.auth import ClientRegistrationOptions
14
- from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider
15
  from fastmcp.server.server import FastMCP
16
  from fastmcp.utilities.tests import run_server_in_process
17
 
@@ -20,7 +20,7 @@ def fastmcp_server(issuer_url: str):
20
  """Create a FastMCP server with OAuth authentication."""
21
  server = FastMCP(
22
  "TestServer",
23
- auth=InMemoryOAuthProvider(
24
  issuer_url=issuer_url,
25
  client_registration_options=ClientRegistrationOptions(enabled=True),
26
  ),
 
11
  from fastmcp.client import Client
12
  from fastmcp.client.transports import StreamableHttpTransport
13
  from fastmcp.server.auth.auth import ClientRegistrationOptions
14
+ from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider as InMemory
15
  from fastmcp.server.server import FastMCP
16
  from fastmcp.utilities.tests import run_server_in_process
17
 
 
20
  """Create a FastMCP server with OAuth authentication."""
21
  server = FastMCP(
22
  "TestServer",
23
+ auth=InMemory(
24
  issuer_url=issuer_url,
25
  client_registration_options=ClientRegistrationOptions(enabled=True),
26
  ),
tests/cli/__init__.py ADDED
File without changes
tests/client/__init__.py CHANGED
@@ -1 +0,0 @@
1
- """Client tests package."""
 
 
tests/server/http/__init__.py ADDED
File without changes
tests/server/openapi/__init__.py ADDED
File without changes
tests/server/test_lifespan.py DELETED
@@ -1,396 +0,0 @@
1
- """Tests for lifespan functionality in both low-level and FastMCP servers."""
2
-
3
- import os
4
- import sys
5
- import traceback
6
- from collections.abc import AsyncIterator
7
- from contextlib import asynccontextmanager
8
- from pathlib import Path
9
-
10
- import anyio
11
- import httpx
12
- import uvicorn
13
- from mcp.server.lowlevel.server import NotificationOptions, Server
14
- from mcp.server.models import InitializationOptions
15
- from mcp.shared.message import SessionMessage
16
- from mcp.types import (
17
- ClientCapabilities,
18
- Implementation,
19
- InitializeRequestParams,
20
- JSONRPCMessage,
21
- JSONRPCNotification,
22
- JSONRPCRequest,
23
- )
24
- from pydantic import TypeAdapter
25
- from starlette.applications import Starlette
26
- from starlette.routing import Mount
27
-
28
- from fastmcp import Context, FastMCP
29
- from fastmcp.utilities.tests import run_server_in_process
30
-
31
-
32
- async def test_lowlevel_server_lifespan():
33
- """Test that lifespan works in low-level server."""
34
-
35
- @asynccontextmanager
36
- async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]:
37
- """Test lifespan context that tracks startup/shutdown."""
38
- context = {"started": False, "shutdown": False}
39
- try:
40
- context["started"] = True
41
- yield context
42
- finally:
43
- context["shutdown"] = True
44
-
45
- server = Server("test", lifespan=test_lifespan)
46
-
47
- # Create memory streams for testing
48
- send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
49
- send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
50
-
51
- # Create a tool that accesses lifespan context
52
- @server.call_tool()
53
- async def check_lifespan(name: str, arguments: dict) -> list:
54
- ctx = server.request_context
55
- assert isinstance(ctx.lifespan_context, dict)
56
- assert ctx.lifespan_context["started"]
57
- assert not ctx.lifespan_context["shutdown"]
58
- return [{"type": "text", "text": "true"}]
59
-
60
- # Run server in background task
61
- async with (
62
- anyio.create_task_group() as tg,
63
- send_stream1,
64
- receive_stream1,
65
- send_stream2,
66
- receive_stream2,
67
- ):
68
-
69
- async def run_server():
70
- await server.run(
71
- receive_stream1,
72
- send_stream2,
73
- InitializationOptions(
74
- server_name="test",
75
- server_version="0.1.0",
76
- capabilities=server.get_capabilities(
77
- notification_options=NotificationOptions(),
78
- experimental_capabilities={},
79
- ),
80
- ),
81
- raise_exceptions=True,
82
- )
83
-
84
- tg.start_soon(run_server)
85
-
86
- # Initialize the server
87
- params = InitializeRequestParams(
88
- protocolVersion="2024-11-05",
89
- capabilities=ClientCapabilities(),
90
- clientInfo=Implementation(name="test-client", version="0.1.0"),
91
- )
92
- await send_stream1.send(
93
- SessionMessage(
94
- JSONRPCMessage(
95
- root=JSONRPCRequest(
96
- jsonrpc="2.0",
97
- id=1,
98
- method="initialize",
99
- params=TypeAdapter(InitializeRequestParams).dump_python(params),
100
- )
101
- )
102
- )
103
- )
104
- response = await receive_stream2.receive()
105
- response = response.message
106
-
107
- # Send initialized notification
108
- await send_stream1.send(
109
- SessionMessage(
110
- JSONRPCMessage(
111
- root=JSONRPCNotification(
112
- jsonrpc="2.0",
113
- method="notifications/initialized",
114
- )
115
- )
116
- )
117
- )
118
-
119
- # Call the tool to verify lifespan context
120
- await send_stream1.send(
121
- SessionMessage(
122
- JSONRPCMessage(
123
- root=JSONRPCRequest(
124
- jsonrpc="2.0",
125
- id=2,
126
- method="tools/call",
127
- params={"name": "check_lifespan", "arguments": {}},
128
- )
129
- )
130
- )
131
- )
132
-
133
- # Get response and verify
134
- response = await receive_stream2.receive()
135
- response = response.message
136
- assert response.root.result["content"][0]["text"] == "true"
137
-
138
- # Cancel server task
139
- tg.cancel_scope.cancel()
140
-
141
-
142
- async def test_fastmcp_server_lifespan():
143
- """Test that lifespan works in FastMCP server."""
144
-
145
- @asynccontextmanager
146
- async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]:
147
- """Test lifespan context that tracks startup/shutdown."""
148
- context = {"started": False, "shutdown": False}
149
- try:
150
- context["started"] = True
151
- yield context
152
- finally:
153
- context["shutdown"] = True
154
-
155
- server = FastMCP("test", lifespan=test_lifespan)
156
-
157
- # Create memory streams for testing
158
- send_stream1, receive_stream1 = anyio.create_memory_object_stream(100)
159
- send_stream2, receive_stream2 = anyio.create_memory_object_stream(100)
160
-
161
- # Add a tool that checks lifespan context
162
- @server.tool()
163
- def check_lifespan(ctx: Context) -> bool:
164
- """Tool that checks lifespan context."""
165
- assert isinstance(ctx.request_context.lifespan_context, dict)
166
- assert ctx.request_context.lifespan_context["started"]
167
- assert not ctx.request_context.lifespan_context["shutdown"]
168
- return True
169
-
170
- # Run server in background task
171
- async with (
172
- anyio.create_task_group() as tg,
173
- send_stream1,
174
- receive_stream1,
175
- send_stream2,
176
- receive_stream2,
177
- ):
178
-
179
- async def run_server():
180
- await server._mcp_server.run(
181
- receive_stream1,
182
- send_stream2,
183
- server._mcp_server.create_initialization_options(),
184
- raise_exceptions=True,
185
- )
186
-
187
- tg.start_soon(run_server)
188
-
189
- # Initialize the server
190
- params = InitializeRequestParams(
191
- protocolVersion="2024-11-05",
192
- capabilities=ClientCapabilities(),
193
- clientInfo=Implementation(name="test-client", version="0.1.0"),
194
- )
195
- await send_stream1.send(
196
- SessionMessage(
197
- JSONRPCMessage(
198
- root=JSONRPCRequest(
199
- jsonrpc="2.0",
200
- id=1,
201
- method="initialize",
202
- params=TypeAdapter(InitializeRequestParams).dump_python(params),
203
- )
204
- )
205
- )
206
- )
207
- response = await receive_stream2.receive()
208
- response = response.message
209
-
210
- # Send initialized notification
211
- await send_stream1.send(
212
- SessionMessage(
213
- JSONRPCMessage(
214
- root=JSONRPCNotification(
215
- jsonrpc="2.0",
216
- method="notifications/initialized",
217
- )
218
- )
219
- )
220
- )
221
-
222
- # Call the tool to verify lifespan context
223
- await send_stream1.send(
224
- SessionMessage(
225
- JSONRPCMessage(
226
- root=JSONRPCRequest(
227
- jsonrpc="2.0",
228
- id=2,
229
- method="tools/call",
230
- params={"name": "check_lifespan", "arguments": {}},
231
- )
232
- )
233
- )
234
- )
235
-
236
- # Get response and verify
237
- response = await receive_stream2.receive()
238
- response = response.message
239
- assert response.root.result["content"][0]["text"] == "true"
240
-
241
- # Cancel server task
242
- tg.cancel_scope.cancel()
243
-
244
-
245
- def run_server_with_incorrect_lifespan_setup(
246
- host: str, port: int, server_log_file_path: str
247
- ) -> None:
248
- os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True)
249
-
250
- CUSTOM_LOGGING_CONFIG = {
251
- "version": 1,
252
- "disable_existing_loggers": False,
253
- "formatters": {
254
- "default": {
255
- "()": "uvicorn.logging.DefaultFormatter",
256
- "fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s",
257
- "datefmt": "%Y-%m-%d %H:%M:%S",
258
- "use_colors": False,
259
- },
260
- "access": {
261
- "()": "uvicorn.logging.AccessFormatter",
262
- "fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s',
263
- "datefmt": "%Y-%m-%d %H:%M:%S",
264
- "use_colors": False,
265
- },
266
- },
267
- "handlers": {
268
- "file_default": {
269
- "formatter": "default",
270
- "class": "logging.FileHandler",
271
- "filename": server_log_file_path,
272
- "mode": "w",
273
- },
274
- "file_access": {
275
- "formatter": "access",
276
- "class": "logging.FileHandler",
277
- "filename": server_log_file_path,
278
- "mode": "a",
279
- },
280
- },
281
- "loggers": {
282
- "uvicorn": { # Catches uvicorn root logs
283
- "handlers": ["file_default"],
284
- "level": "DEBUG",
285
- "propagate": False,
286
- },
287
- "uvicorn.error": {
288
- "handlers": ["file_default"],
289
- "level": "DEBUG",
290
- "propagate": False,
291
- },
292
- "uvicorn.access": {
293
- "handlers": ["file_access"],
294
- "level": "INFO",
295
- "propagate": False,
296
- },
297
- },
298
- "root": {
299
- "handlers": ["file_default"],
300
- "level": "DEBUG",
301
- },
302
- }
303
-
304
- try:
305
- mcp = FastMCP()
306
-
307
- @mcp.tool("ping_tool", "A simple ping tool for the test server")
308
- def ping_tool() -> str:
309
- return "pong"
310
-
311
- mcp_asgi_app = mcp.http_app(transport="streamable-http")
312
-
313
- parent_app = Starlette(
314
- routes=[Mount("/mounted_mcp", app=mcp_asgi_app)],
315
- )
316
-
317
- uvicorn.run(
318
- parent_app,
319
- host=host,
320
- port=port,
321
- log_config=CUSTOM_LOGGING_CONFIG,
322
- log_level=None,
323
- )
324
- sys.exit(0)
325
- except Exception as e_outer:
326
- with open(server_log_file_path, "a") as f_fallback:
327
- f_fallback.write(
328
- "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n"
329
- )
330
- f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n")
331
- f_fallback.write(traceback.format_exc())
332
- sys.exit(1)
333
-
334
-
335
- async def test_missing_lifespan_logs_informative_error(tmp_path: Path):
336
- server_log_file = tmp_path / "server.log"
337
-
338
- with run_server_in_process(
339
- run_server_with_incorrect_lifespan_setup, str(server_log_file)
340
- ) as server_url:
341
- full_mcp_path = server_url + "/mounted_mcp/mcp/"
342
-
343
- client_triggered_error = False
344
- response_status = -1
345
- response_body = ""
346
- try:
347
- async with httpx.AsyncClient(timeout=10) as client:
348
- response = await client.post(
349
- full_mcp_path,
350
- json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"},
351
- )
352
- response_status = response.status_code
353
- response_body = response.text
354
- if response.status_code == 500:
355
- client_triggered_error = True
356
- else:
357
- print(
358
- f"Client received unexpected status code: {response.status_code} "
359
- f"Response: {response_body[:500]}"
360
- )
361
- except httpx.RequestError as e:
362
- print(f"Client request failed with RequestError: {e}")
363
- client_triggered_error = True
364
-
365
- assert client_triggered_error, (
366
- f"Client request did not result in a 500 error or a request error. "
367
- f"Status: {response_status}, Body: {response_body[:500]}"
368
- )
369
-
370
- assert server_log_file.exists(), (
371
- f"Server log file was not created at {server_log_file}"
372
- )
373
- log_content = server_log_file.read_text()
374
-
375
- print(f"--- Captured Server Log Content ({server_log_file}) ---")
376
- print(log_content)
377
- print("--- End Server Log Content ---")
378
-
379
- # Core assertions for the enhanced error message
380
- assert (
381
- "FastMCP's StreamableHTTPSessionManager task group was not initialized"
382
- in log_content
383
- )
384
- assert "lifespan=mcp_app.lifespan" in log_content
385
- assert "gofastmcp.com/deployment/asgi" in log_content
386
- assert "Original error: Task group is not initialized" in log_content
387
-
388
- # Check for Uvicorn's own error logging wrapper for the request
389
- assert "ERROR" in log_content # General check for ERROR level logs
390
- assert "Exception in ASGI application" in log_content
391
-
392
- # Sanity checks for server operation and logging setup
393
- assert "Uvicorn running on" in log_content
394
- assert (
395
- "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content
396
- )