Compare commits

..

9 commits

Author SHA1 Message Date
nate nowack
e333fa52df
docs: publish beta 4 changelog fix (#4917)
🤖 Generated with OpenAI Codex
2026-08-27 00:49:35 -05:00
nate nowack
9f3ba555c2
docs: publish FastMCP v4.0.0b4 (#4915)
🤖 Generated with OpenAI Codex
2026-08-26 18:01:45 -05:00
Jeremiah Lowin
0a619de571
Publish FastMCP v4.0.0b3 docs (#4842) 2026-08-14 13:51:05 -04:00
Jeremiah Lowin
0e941cf051
Publish refreshed FastMCP 4 docs (#4712) 2026-07-29 14:36:08 -04:00
Jeremiah Lowin
98bbec1f14
sync: bring published-docs to main @ v4.0.0b1 (+ whats-new/extensions) (#4700) 2026-07-28 22:45:47 -04:00
Jeremiah Lowin
0747ce0bc1
Publish FastMCP 4.0.0b1 docs to gofastmcp.com (#4695) 2026-07-28 17:26:15 -04:00
Jeremiah Lowin
8cf4506aa9
Publish FastMCP 4 (alpha) docs to gofastmcp.com (#4624) 2026-07-23 21:21:01 -04:00
Jeremiah Lowin
3098f8086b
Restore Mintlify's fixed banner positioning (#4543) 2026-07-18 21:25:33 -04:00
Jeremiah Lowin
6bc5dc065a
Fix docs banner contrast (production hotfix) (#4536) 2026-07-18 15:40:37 -04:00
16 changed files with 176 additions and 75 deletions

View file

@ -618,7 +618,7 @@ def search(query: str) -> str:
When a response exceeds the limit, the middleware extracts all text content, joins it together, truncates to fit within the limit, and returns a single `TextContent` block. For non-text responses, the serialized JSON is used as the text source.
<Note>
Because truncation can replace structured output with plain text, the middleware omits `outputSchema` from `tools/list` for every tool it limits. Responses below the limit still include their original structured content, but clients cannot validate or hydrate it against an advertised output schema. Tools excluded with the `tools` parameter keep their output schemas.
If a tool defines an `output_schema`, truncated responses will no longer conform to that schema — the client will receive a plain `TextContent` block instead of the expected structured output. Keep this in mind when setting size limits for tools with structured responses.
</Note>
```python

View file

@ -3,14 +3,13 @@
from __future__ import annotations
import logging
from collections.abc import Sequence
from typing import Any
import mcp_types as mt
import pydantic_core
from mcp_types import TextContent
from fastmcp.tools.base import InputRequiredToolResult, Tool, ToolResult
from fastmcp.tools.base import InputRequiredToolResult, ToolResult
from .middleware import CallNext, Middleware, MiddlewareContext
@ -69,9 +68,6 @@ class ResponseLimitingMiddleware(Middleware):
self.truncation_suffix = truncation_suffix
self.tools = set(tools) if tools is not None else None
def _limits_tool(self, name: str) -> bool:
return self.tools is None or name in self.tools
def _truncate_to_result(
self,
text: str,
@ -97,25 +93,15 @@ class ResponseLimitingMiddleware(Middleware):
+ self.truncation_suffix
)
# Preserve original meta, falling back to {} when absent. Having
# meta set ensures to_mcp_result() returns a CallToolResult, which
# bypasses MCP SDK outputSchema validation — a truncated response
# is no longer valid structured output.
return ToolResult(
content=[TextContent(type="text", text=truncated)],
meta=meta,
meta=meta if meta is not None else {},
)
async def on_list_tools(
self,
context: MiddlewareContext[mt.ListToolsRequest],
call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]],
) -> Sequence[Tool]:
"""Hide schemas for tools whose response shape may be truncated to text."""
tools = await call_next(context)
return [
tool.model_copy(update={"output_schema": None})
if self._limits_tool(tool.name) and tool.output_schema is not None
else tool
for tool in tools
]
async def on_call_tool(
self,
context: MiddlewareContext[mt.CallToolRequestParams],
@ -139,7 +125,7 @@ class ResponseLimitingMiddleware(Middleware):
return result
# Check if we should limit this tool
if not self._limits_tool(context.message.name):
if self.tools is not None and context.message.name not in self.tools:
return result
# Measure serialized size

View file

@ -307,7 +307,6 @@ async def run_asgi_lifespan(app: ASGIApp) -> AsyncIterator[None]:
raise
finally:
await receive_queue.put({"type": "lifespan.shutdown"})
results: tuple[object, ...] = ()
with anyio.CancelScope(shield=True):
results = await asyncio.gather(
shutdown_complete, task, return_exceptions=True

View file

@ -253,15 +253,15 @@ class TestProjectPrepareCommand:
mock_find.return_value = None
# Run command without output_dir - should exit with error for missing output_dir
with patch("fastmcp.cli.cli.logger.error") as mock_error:
with pytest.raises(SystemExit) as exc_info:
with pytest.raises(SystemExit) as exc_info:
with patch("fastmcp.cli.cli.logger.error") as mock_error:
await prepare(config_path=None, output_dir=None)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
mock_error.assert_called()
error_msg = mock_error.call_args[0][0]
assert "--output-dir parameter is required" in error_msg
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
mock_error.assert_called()
error_msg = mock_error.call_args[0][0]
assert "--output-dir parameter is required" in error_msg
@patch("pathlib.Path.exists")
async def test_project_prepare_config_not_exists(self, mock_exists):
@ -272,15 +272,15 @@ class TestProjectPrepareCommand:
mock_exists.return_value = False
# Run command without output_dir - should exit with error for missing output_dir
with patch("fastmcp.cli.cli.logger.error") as mock_error:
with pytest.raises(SystemExit) as exc_info:
with pytest.raises(SystemExit) as exc_info:
with patch("fastmcp.cli.cli.logger.error") as mock_error:
await prepare(config_path="missing.json", output_dir=None)
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
mock_error.assert_called()
error_msg = mock_error.call_args[0][0]
assert "--output-dir parameter is required" in error_msg
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
mock_error.assert_called()
error_msg = mock_error.call_args[0][0]
assert "--output-dir parameter is required" in error_msg
@patch("pathlib.Path.exists")
@patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file")
@ -295,12 +295,12 @@ class TestProjectPrepareCommand:
mock_from_file.return_value = mock_config
# Run command - should exit with error
with patch("fastmcp.cli.cli.console.print") as mock_print:
with pytest.raises(SystemExit) as exc_info:
with pytest.raises(SystemExit) as exc_info:
with patch("fastmcp.cli.cli.console.print") as mock_print:
await prepare(config_path="config.json", output_dir="./test-env")
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
# Should print error message
error_call = mock_print.call_args_list[-1][0][0]
assert "Failed to prepare project" in error_call
assert isinstance(exc_info.value, SystemExit)
assert exc_info.value.code == 1
# Should print error message
error_call = mock_print.call_args_list[-1][0][0]
assert "Failed to prepare project" in error_call

View file

@ -225,9 +225,9 @@ async def test_oauth_callback_handler_propagates_iss_to_authorization_code_resul
tg.start_soon(send_callback)
result = await oauth.callback_handler()
assert result.code == "auth-code-123"
assert result.state == "state-xyz"
assert result.iss == "https://issuer.example.com"
assert result.code == "auth-code-123"
assert result.state == "state-xyz"
assert result.iss == "https://issuer.example.com"
class TestOAuthClientUrlHandling:

View file

@ -7,6 +7,7 @@ Client(server) with an in-process FastMCP server.
import time
import pytest
from docket import Docket
from fastmcp import Client, FastMCP
from fastmcp.client.transports import FastMCPTransport
@ -20,8 +21,18 @@ def test_transport_repr_includes_server_name():
assert repr(transport) == "<FastMCPTransport(server='repr-test')>"
@pytest.fixture
def reset_docket_memory_server():
"""Force a fresh memory:// Docket server bound to this test's loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
@pytest.mark.timeout(10)
async def test_task_teardown_does_not_hang():
async def test_task_teardown_does_not_hang(reset_docket_memory_server):
"""In-memory transport must tear down in under 2 seconds after a task call.
This is a regression test for a teardown ordering bug where the Docket

View file

@ -1,6 +1,7 @@
import json
import pytest
from docket import Docket
from fastmcp_tasks.context import _recall_snapshot, get_task_context
from mcp_types import TextContent, TextResourceContents
from starlette.requests import Request
@ -13,6 +14,16 @@ from fastmcp_tasks import TasksExtension
from tests.tasks.task_helpers import running_task_server, submit_task, wait_for_task
@pytest.fixture
def reset_docket_memory_server():
"""Force a fresh memory:// Docket server bound to this test's event loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
def _http_request_with_headers(headers: dict[str, str]) -> Request:
"""Build a minimal Starlette HTTP request carrying the given headers."""
raw_headers = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
@ -235,7 +246,9 @@ def _worker_snapshot_headers() -> dict[str, str]:
return dict(snapshot.http_headers)
async def test_background_task_can_read_snapshotted_request_headers():
async def test_background_task_can_read_snapshotted_request_headers(
reset_docket_memory_server,
):
"""A background task worker reads the HTTP headers snapshotted at submission.
There is no client task-submission API yet (Phase 4), so the task is driven
@ -265,7 +278,9 @@ async def test_background_task_can_read_snapshotted_request_headers():
assert final.result["structuredContent"] == {"result": "tenant-123"}
async def test_background_task_snapshot_preserves_all_request_headers():
async def test_background_task_snapshot_preserves_all_request_headers(
reset_docket_memory_server,
):
"""The task snapshot preserves every request header, including authorization."""
server = FastMCP()
server.add_extension(TasksExtension())

View file

@ -57,18 +57,14 @@ class TestResponseLimitingMiddleware:
)
@mcp_server.tool()
def limited_tool() -> str:
return "x" * 10_000
def limited_tool() -> ToolResult:
return ToolResult(content=[TextContent(type="text", text="x" * 10_000)])
@mcp_server.tool()
def unlimited_tool() -> str:
return "y" * 10_000
def unlimited_tool() -> ToolResult:
return ToolResult(content=[TextContent(type="text", text="y" * 10_000)])
async with Client(mcp_server) as client:
tools = {tool.name: tool for tool in await client.list_tools()}
assert tools["limited_tool"].output_schema is None
assert tools["unlimited_tool"].output_schema is not None
# Limited tool should be truncated
result = await client.call_tool("limited_tool", {})
assert "[Response truncated" in result.content[0].text
@ -82,13 +78,10 @@ class TestResponseLimitingMiddleware:
mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=100, tools=[]))
@mcp_server.tool()
def any_tool() -> str:
return "x" * 10_000
def any_tool() -> ToolResult:
return ToolResult(content=[TextContent(type="text", text="x" * 10_000)])
async with Client(mcp_server) as client:
tools = await client.list_tools()
assert tools[0].output_schema is not None
result = await client.call_tool("any_tool", {})
# Should NOT be truncated
assert "[Response truncated" not in result.content[0].text
@ -159,10 +152,11 @@ class TestResponseLimitingMiddleware:
async def test_truncation_does_not_break_output_schema_tools(
self, mcp_server: FastMCP
):
"""Truncating a tool with outputSchema must not cause client validation errors.
"""Truncating a tool with outputSchema must not cause validation errors.
Regression test for #4926: an end-to-end client rejects truncated results
when tools/list still advertises the original outputSchema.
Regression test for #3717: the MCP SDK rejects truncated results
from tools with outputSchema because structured_content is dropped.
We verify the server returns a successful (non-error) truncated result.
"""
mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=1_000))
@ -170,12 +164,7 @@ class TestResponseLimitingMiddleware:
def big_answer() -> Answer:
return Answer(text="x" * 2_000)
async with Client(mcp_server) as client:
tools = await client.list_tools()
assert tools[0].output_schema is None
result = await client.call_tool("big_answer", {})
assert result.is_error is False
result = await mcp_server.call_tool("big_answer", {})
first = result.content[0]
assert isinstance(first, TextContent)
assert "[Response truncated" in first.text

View file

@ -1,6 +1,7 @@
"""Advanced mounting scenarios."""
import pytest
from docket import Docket
from mcp_types import TextContent
from starlette.routing import Route
@ -12,6 +13,16 @@ from fastmcp_tasks import TasksExtension
from tests.tasks.task_helpers import running_task_server
@pytest.fixture
def reset_docket_memory_server():
"""Force a fresh memory:// Docket server bound to this test's event loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
class TestDynamicChanges:
"""Test that changes to mounted servers are reflected dynamically."""
@ -600,7 +611,9 @@ class TestMountedServerDocketBehavior:
includes Docket creation.
"""
async def test_mounted_server_does_not_have_docket(self):
async def test_mounted_server_does_not_have_docket(
self, reset_docket_memory_server
):
"""Test that a mounted server doesn't create its own Docket.
MountedProvider.lifespan() should call only the server's _lifespan

View file

@ -3,6 +3,7 @@
from contextlib import asynccontextmanager, contextmanager
import pytest
from docket import Docket
from mcp_types import TextContent, TextResourceContents
from fastmcp import FastMCP
@ -15,6 +16,16 @@ from tests.conftest import make_server_request_context
HUZZAH = "huzzah!"
@pytest.fixture
def reset_docket_memory_server():
"""Force a fresh memory:// Docket server bound to this test's event loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
class Connection:
"""Test connection that tracks whether it's currently open."""
@ -1194,7 +1205,9 @@ class TestSharedDependencies:
)
assert call_count == 1
async def test_shared_resolves_on_task_capable_server(self):
async def test_shared_resolves_on_task_capable_server(
self, reset_docket_memory_server
):
"""Shared() dependencies resolve on a normal request even when the server
has task-enabled components.

View file

@ -22,6 +22,7 @@ from typing import Annotated
import mcp_types
import pytest
from docket import Docket
from mcp.client._input_required import InputRequiredRoundsExceededError
from mcp.server.request_state import RequestStateSecurity
from mcp.shared.exceptions import MCPError
@ -1176,7 +1177,18 @@ class TestTaskExecution:
background task has no such request, so returning a guard result from a task
is rejected with a clear error rather than silently yielding empty content."""
async def test_guard_result_from_task_parks_for_input(self):
@pytest.fixture
def reset_docket_memory_server(self):
"""Force a fresh memory:// Docket server bound to this test's loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
async def test_guard_result_from_task_parks_for_input(
self, reset_docket_memory_server
):
mcp = FastMCP("guard-task")
mcp.add_extension(TasksExtension())

View file

@ -3,6 +3,7 @@
import asyncio
from contextlib import asynccontextmanager
import pytest
from docket import Docket
from docket.worker import Worker
from fastmcp_tasks.dependencies import CurrentDocket, CurrentWorker
@ -15,6 +16,16 @@ from fastmcp_tasks import TasksExtension
HUZZAH = "huzzah!"
@pytest.fixture(autouse=True)
def reset_docket_memory_server():
"""Force a fresh memory:// Docket server bound to each test's event loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
async def test_docket_not_initialized_without_task_components():
"""Docket is only initialized when task-enabled components exist."""
mcp = FastMCP("test-server")

View file

@ -8,6 +8,26 @@ import pytest
from fastmcp.utilities.tests import temporary_settings
@pytest.fixture(autouse=True)
def reset_docket_memory_server():
"""Reset the shared memory:// Docket server between tests.
Docket keeps a process-wide ``Docket._memory_server`` singleton for
``memory://`` backends. It persists across tests and across event loops, so a
test that inherits a stale server from a previous loop can fail (e.g.
``tasks/get`` raising ``TypeError`` from the dead client). Clearing it before
and after each test keeps the task suite isolation-safe rather than
order-dependent.
"""
from docket import Docket
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
@pytest.fixture(autouse=True)
def isolate_settings_home(_settings_home_root: Path):
"""Task-local override of the repo-wide ``isolate_settings_home`` fixture.

View file

@ -44,6 +44,16 @@ from tests.tasks.task_helpers import (
)
@pytest.fixture(autouse=True)
def reset_docket_memory_server():
"""Reset the shared memory:// Docket server between tests for isolation."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
@pytest.fixture
def child_server() -> FastMCP:
mcp = FastMCP("child-server")

View file

@ -8,6 +8,7 @@ client opts the tasks extension in for the request.
"""
import pytest
from docket import Docket
from fastmcp_tasks.models import CreateTaskResult
from fastmcp import FastMCP
@ -23,6 +24,16 @@ from tests.tasks.task_helpers import (
)
@pytest.fixture(autouse=True)
def reset_docket_memory_server():
"""Force a fresh memory:// Docket server bound to each test's event loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
async def call_tool_with_optin(server: FastMCP, name: str, arguments: dict):
"""Run a `tools/call` with the tasks opt-in bound into the request context."""
with auth_scope(None), _opted_in_request(name, arguments, None):

View file

@ -16,6 +16,7 @@ from uuid import UUID
import mcp_types
import pytest
from docket import Docket
from pydantic import BaseModel
from typing_extensions import TypedDict
@ -30,6 +31,16 @@ from tests.tasks.task_helpers import (
)
@pytest.fixture(autouse=True)
def reset_docket_memory_server():
"""Force a fresh memory:// Docket server bound to each test's event loop."""
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
yield
if hasattr(Docket, "_memory_server"):
delattr(Docket, "_memory_server")
def _sync_result_to_wire(result: ToolResult) -> dict[str, Any]:
"""Serialize a synchronous ToolResult into the inlined task wire shape."""
mcp_result = result.to_mcp_result()