Spaces:
Running
Running
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|