zzstoatzz commited on
Commit
9a5babe
·
1 Parent(s): f084918

integration tests and better error

Browse files
.github/workflows/run-integration-tests.yml ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Run integration tests
2
+
3
+ env:
4
+ # enable colored output
5
+ PY_COLORS: 1
6
+
7
+ on:
8
+ push:
9
+ branches: ["main"]
10
+ paths:
11
+ - "src/**"
12
+ - "integration_tests/**"
13
+ - "uv.lock"
14
+ - "pyproject.toml"
15
+ - ".github/workflows/**"
16
+
17
+ # run on all pull requests because these checks are required and will block merges otherwise
18
+ pull_request:
19
+
20
+ workflow_dispatch:
21
+
22
+ permissions:
23
+ contents: read
24
+
25
+ jobs:
26
+ run_tests:
27
+ name: "Run tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}"
28
+ runs-on: ${{ matrix.os }}
29
+ strategy:
30
+ matrix:
31
+ os: [ubuntu-latest, windows-latest]
32
+ python-version: ["3.10"]
33
+ fail-fast: false
34
+ timeout-minutes: 5
35
+
36
+ steps:
37
+ - uses: actions/checkout@v4
38
+
39
+ - name: Install uv
40
+ uses: astral-sh/setup-uv@v5
41
+ with:
42
+ enable-cache: true
43
+ cache-dependency-glob: "uv.lock"
44
+ python-version: ${{ matrix.python-version }}
45
+
46
+ - name: Install FastMCP
47
+ run: uv sync --locked
48
+
49
+ - name: Run tests
50
+ run: uv run pytest integration_tests
integration_tests/test_repro_518_output.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import tempfile
5
+ import time
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ PYTHON_EXE = sys.executable
11
+
12
+ SERVER_CODE = """
13
+ import uvicorn
14
+ from fastapi import FastAPI
15
+ from fastmcp import FastMCP
16
+
17
+ mcp = FastMCP()
18
+
19
+ @mcp.tool("dummy_tool", "A simple dummy tool for the test server")
20
+ def add(a: int, b: int) -> int:
21
+ return a + b
22
+
23
+ app = FastAPI() # Intentionally no lifespan=mcp.lifespan
24
+ app.mount("/", mcp.http_app(transport="streamable-http"))
25
+
26
+ if __name__ == "__main__":
27
+ uvicorn.run(app, host="0.0.0.0", port=8080, log_config=None)
28
+ """
29
+
30
+
31
+ @pytest.mark.timeout(20)
32
+ def test_server_shows_informative_error_on_stderr():
33
+ """
34
+ Runs a minimal FastMCP+FastAPI server (that omits lifespan wiring)
35
+ as a subprocess, triggers the error via an HTTP request, and then checks
36
+ if the server's stderr contains the specific informative error message for issue #518.
37
+ """
38
+ process = None
39
+ captured_stderr = ""
40
+
41
+ with tempfile.NamedTemporaryFile(
42
+ mode="w", suffix=".py", delete=False
43
+ ) as tmp_script:
44
+ tmp_script.write(SERVER_CODE)
45
+ tmp_script_path = tmp_script.name
46
+
47
+ try:
48
+ process = subprocess.Popen(
49
+ [PYTHON_EXE, "-u", tmp_script_path],
50
+ stderr=subprocess.PIPE,
51
+ stdout=subprocess.PIPE,
52
+ text=True,
53
+ universal_newlines=True,
54
+ )
55
+
56
+ time.sleep(3)
57
+
58
+ if process.poll() is None:
59
+ try:
60
+ with httpx.Client(timeout=5.0) as client:
61
+ # The mounted FastMCP app is at root, its internal default path is /mcp
62
+ client.get("http://localhost:8080/mcp/")
63
+ except httpx.RequestError:
64
+ pass
65
+ time.sleep(1)
66
+
67
+ finally:
68
+ if process:
69
+ if process.poll() is None:
70
+ process.terminate()
71
+ try:
72
+ _, captured_stderr = process.communicate(timeout=10)
73
+ except subprocess.TimeoutExpired:
74
+ process.kill()
75
+ _, captured_stderr = process.communicate()
76
+
77
+ if os.path.exists(tmp_script_path):
78
+ os.unlink(tmp_script_path)
79
+
80
+ assert captured_stderr is not None, "stderr should have been captured"
81
+ normalized_stderr = captured_stderr.replace("\r\n", "\n").replace("\r", "\n")
82
+
83
+ assert (
84
+ "FastMCP's StreamableHTTPSessionManager task group was not initialized"
85
+ in normalized_stderr
86
+ )
87
+ assert "lifespan=mcp_app.lifespan" in normalized_stderr
88
+ assert "gofastmcp.com/deployment/asgi" in normalized_stderr
89
+ assert "Original error: Task group is not initialized" in normalized_stderr
src/fastmcp/server/http.py CHANGED
@@ -306,7 +306,26 @@ def create_streamable_http_app(
306
  async def handle_streamable_http(
307
  scope: Scope, receive: Receive, send: Send
308
  ) -> None:
309
- await session_manager.handle_request(scope, receive, send)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
 
311
  # Get auth middleware and routes
312
  auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
 
306
  async def handle_streamable_http(
307
  scope: Scope, receive: Receive, send: Send
308
  ) -> None:
309
+ try:
310
+ await session_manager.handle_request(scope, receive, send)
311
+ except RuntimeError as e:
312
+ if "Task group is not initialized" in str(e):
313
+ new_error_message = (
314
+ "FastMCP's StreamableHTTPSessionManager task group was not initialized. "
315
+ "This commonly occurs when the FastMCP application's lifespan is not "
316
+ "passed to the parent ASGI application (e.g., FastAPI or Starlette). "
317
+ "Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
318
+ "parent app's constructor, where `mcp_app` is the application instance "
319
+ "returned by `fastmcp_instance.http_app()`. \\n"
320
+ "For more details, see the FastMCP ASGI integration documentation: "
321
+ "https://gofastmcp.com/deployment/asgi"
322
+ )
323
+ # Raise a new RuntimeError that includes the original error's message
324
+ # for full context, but leads with the more helpful guidance.
325
+ raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
326
+ else:
327
+ # Re-raise other RuntimeErrors if they don't match the specific message
328
+ raise
329
 
330
  # Get auth middleware and routes
331
  auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(