diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d7be3b7fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "daily" + labels: + - "dependencies" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + labels: + - "dependencies" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" diff --git a/.github/release.yml b/.github/release.yml index 32eb2588e..e637ece59 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -27,6 +27,10 @@ changelog: labels: - documentation + - title: Dependencies 📦 + labels: + - dependencies + - title: Other Changes 🦾 labels: - "*" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 66ab30968..49a55b17d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - name: "Install uv" - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v6 - name: Build run: uv build diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml index 526844f6c..bb4ade8f4 100644 --- a/.github/workflows/run-static.yml +++ b/.github/workflows/run-static.yml @@ -32,7 +32,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 04f35da3e..cf4805e87 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -37,7 +37,7 @@ jobs: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/docs/assets/updates/release-2-7.png b/docs/assets/updates/release-2-7.png new file mode 100644 index 000000000..5716e1f73 Binary files /dev/null and b/docs/assets/updates/release-2-7.png differ diff --git a/docs/integrations/anthropic.mdx b/docs/integrations/anthropic.mdx index 041040b00..c5cdaea69 100644 --- a/docs/integrations/anthropic.mdx +++ b/docs/integrations/anthropic.mdx @@ -1,6 +1,6 @@ --- -title: Anthropic -sidebarTitle: Anthropic +title: Anthropic API + FastMCP +sidebarTitle: Anthropic API description: Call FastMCP servers from the Anthropic API icon: message-smile tag: "New!" @@ -8,9 +8,6 @@ tag: "New!" import { VersionBadge } from "/snippets/version-badge.mdx" -Anthropic supports MCP servers through the [MCP connector](https://docs.anthropic.com/en/docs/agents-and-tools/mcp-connector) feature in the Messages API, allowing you to extend AI capabilities with custom tools from remote MCP servers. - -## Messages API Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) supports MCP servers as remote tool sources. This tutorial will show you how to create a FastMCP server and deploy it to a public URL, then how to call it from the Messages API. @@ -18,7 +15,7 @@ Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) supports Currently, the MCP connector only accesses **tools** from MCP servers—it queries the `list_tools` endpoint and exposes those functions to Claude. Other MCP features like resources and prompts are not currently supported. You can read more about the MCP connector in the [Anthropic documentation](https://docs.anthropic.com/en/docs/agents-and-tools/mcp-connector). -### Create a Server +## Create a Server First, create a FastMCP server with the tools you want to expose. For this example, we'll create a server with a single tool that rolls dice. @@ -37,7 +34,7 @@ if __name__ == "__main__": mcp.run(transport="sse", port=8000) ``` -### Deploy the Server +## Deploy the Server Your server must be deployed to a public URL in order for Anthropic to access it. The MCP connector supports both SSE and Streamable HTTP transports. @@ -59,7 +56,7 @@ ngrok http 8000 This exposes your unauthenticated server to the internet. Only run this command in a safe environment if you understand the risks. -### Call the Server +## Call the Server To use the Messages API with MCP servers, you'll need to install the Anthropic Python SDK (not included with FastMCP): @@ -114,13 +111,13 @@ The results were 4, 2, and 6. Would you like me to roll again or roll a differen ``` -### Authentication +## Authentication The MCP connector supports OAuth authentication through authorization tokens, which means you can secure your server while still allowing Anthropic to access it. -#### Server Authentication +### Server Authentication The simplest way to add authentication to the server is to use a bearer token scheme. @@ -181,7 +178,7 @@ if __name__ == "__main__": mcp.run(transport="sse", port=8000) ``` -#### Client Authentication +### Client Authentication If you try to call the authenticated server with the same Anthropic code we wrote earlier, you'll get an error indicating that the server rejected the request because it's not authenticated. diff --git a/docs/integrations/claude-desktop.mdx b/docs/integrations/claude-desktop.mdx index 9b8dc5404..9c4729dbe 100644 --- a/docs/integrations/claude-desktop.mdx +++ b/docs/integrations/claude-desktop.mdx @@ -1,5 +1,5 @@ --- -title: Claude Desktop +title: Claude Desktop + FastMCP sidebarTitle: Claude Desktop description: Call FastMCP servers from Claude Desktop icon: desktop diff --git a/docs/integrations/contrib.mdx b/docs/integrations/contrib.mdx index aead6affd..2f71c0b61 100644 --- a/docs/integrations/contrib.mdx +++ b/docs/integrations/contrib.mdx @@ -12,7 +12,7 @@ FastMCP includes a `contrib` package that holds community-contributed modules. T Contrib modules provide additional features, integrations, or patterns that complement the core FastMCP library. They offer a way for the community to share useful extensions while keeping the core library focused and maintainable. -The available modules can be viewed in the [contrib directory](https://github.com/jlowin/fastmcp/tree/main/src/contrib). +The available modules can be viewed in the [contrib directory](https://github.com/jlowin/fastmcp/tree/main/src/fastmcp/contrib). ## Usage diff --git a/docs/integrations/gemini.mdx b/docs/integrations/gemini.mdx index 2f5cc5e3d..fee4a9d8e 100644 --- a/docs/integrations/gemini.mdx +++ b/docs/integrations/gemini.mdx @@ -1,5 +1,5 @@ --- -title: Gemini SDK +title: Gemini SDK + FastMCP sidebarTitle: Gemini SDK description: Call FastMCP servers from the Google Gemini SDK icon: message-smile diff --git a/docs/integrations/openai.mdx b/docs/integrations/openai.mdx index a5b1e933f..b68ebcc18 100644 --- a/docs/integrations/openai.mdx +++ b/docs/integrations/openai.mdx @@ -1,6 +1,6 @@ --- -title: OpenAI -sidebarTitle: OpenAI +title: OpenAI API + FastMCP +sidebarTitle: OpenAI API description: Call FastMCP servers from the OpenAI API icon: message-smile tag: "New!" @@ -8,14 +8,13 @@ tag: "New!" import { VersionBadge } from "/snippets/version-badge.mdx" -OpenAI recently announced support for MCP servers in the Responses API. Note that at this time, MCP is not supported in ChatGPT. ## Responses API OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) supports [MCP servers](https://platform.openai.com/docs/guides/tools-remote-mcp) as remote tool sources, allowing you to extend AI capabilities with custom functions. -The Responses API is a distinct API from OpenAI's Completions API, Assistants API, or ChatGPT. At this time, only the Responses API supports MCP. +The Responses API is a distinct API from OpenAI's Completions API or Assistants API. At this time, only the Responses API supports MCP. diff --git a/docs/updates.mdx b/docs/updates.mdx index 1faf9dc91..afaac40e7 100644 --- a/docs/updates.mdx +++ b/docs/updates.mdx @@ -3,11 +3,36 @@ title: "FastMCP Updates" sidebarTitle: "Updates" icon: "sparkles" tag: "New!" +mode: "wide" --- + + +FastMCP 2.7 has been released! + +Most notably, it introduces the highly requested (and Pythonic) "naked" decorator usage: + +```python {3} +mcp = FastMCP() + +@mcp.tool +def add(a: int, b: int) -> int: + return a + b +``` + +In addition, decorators now return the objects they create, instead of the decorated function. This is an important usability enhancement. + +The bulk of the update is focused on improving the FastMCP internals, including a few breaking internal changes to private APIs. A number of functions that have clung on since 1.0 are now deprecated. + + + + + - - - str: - return f"" + return f"" class SSETransport(ClientTransport): @@ -183,8 +170,7 @@ class SSETransport(ClientTransport): if auth == "oauth": auth = OAuth(self.url) elif isinstance(auth, str): - self.headers["Authorization"] = auth - auth = None + auth = BearerAuth(auth) self.auth = auth @contextlib.asynccontextmanager @@ -221,7 +207,7 @@ class SSETransport(ClientTransport): yield session def __repr__(self) -> str: - return f"" + return f"" class StreamableHttpTransport(ClientTransport): @@ -252,8 +238,7 @@ class StreamableHttpTransport(ClientTransport): if auth == "oauth": auth = OAuth(self.url) elif isinstance(auth, str): - self.headers["Authorization"] = auth - auth = None + auth = BearerAuth(auth) self.auth = auth @contextlib.asynccontextmanager @@ -291,7 +276,7 @@ class StreamableHttpTransport(ClientTransport): yield session def __repr__(self) -> str: - return f"" + return f"" class StdioTransport(ClientTransport): @@ -663,27 +648,49 @@ class FastMCPTransport(ClientTransport): tests or scenarios where client and server run in the same runtime. """ - def __init__(self, mcp: FastMCP | FastMCP1Server): + def __init__(self, mcp: FastMCP | FastMCP1Server, raise_exceptions: bool = False): """Initialize a FastMCPTransport from a FastMCP server instance.""" # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a # ``_mcp_server`` attribute pointing to the underlying MCP server # implementation, so we can treat them identically. self.server = mcp + self.raise_exceptions = raise_exceptions @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - # create_connected_server_and_client_session manages the session lifecycle itself - async with create_connected_server_and_client_session( - server=self.server._mcp_server, - **session_kwargs, - ) as session: - yield session + async with create_client_server_memory_streams() as ( + client_streams, + server_streams, + ): + client_read, client_write = client_streams + server_read, server_write = server_streams + + # Create a cancel scope for the server task + async with anyio.create_task_group() as tg: + tg.start_soon( + lambda: self.server._mcp_server.run( + server_read, + server_write, + self.server._mcp_server.create_initialization_options(), + raise_exceptions=self.raise_exceptions, + ) + ) + + try: + async with ClientSession( + read_stream=client_read, + write_stream=client_write, + **session_kwargs, + ) as client_session: + yield client_session + finally: + tg.cancel_scope.cancel() def __repr__(self) -> str: - return f"" + return f"" class MCPConfigTransport(ClientTransport): @@ -769,7 +776,7 @@ class MCPConfigTransport(ClientTransport): yield session def __repr__(self) -> str: - return f"" + return f"" @overload @@ -860,7 +867,6 @@ def infer_transport( transport = infer_transport(config) ``` """ - from fastmcp.utilities.mcp_config import MCPConfig # the transport is already a ClientTransport if isinstance(transport, ClientTransport): diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 572af5282..08775225e 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -67,6 +67,7 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: "te", "keep-alive", "expect", + "accept", # Proxy-related headers "proxy-authenticate", "proxy-authorization", diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 2b5381437..b9121dfe8 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -13,6 +13,7 @@ from mcp.server.auth.middleware.bearer_auth import ( from mcp.server.auth.routes import create_auth_routes from mcp.server.lowlevel.server import LifespanResultT from mcp.server.sse import SseServerTransport +from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette from starlette.middleware import Middleware @@ -241,7 +242,7 @@ def create_sse_app( def create_streamable_http_app( server: FastMCP[LifespanResultT], streamable_http_path: str, - event_store: None = None, + event_store: EventStore | None = None, auth: OAuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 12fbf976d..5cda3cbb4 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -41,6 +41,7 @@ from starlette.requests import Request from starlette.responses import Response from starlette.routing import BaseRoute, Route +import fastmcp import fastmcp.server import fastmcp.settings from fastmcp.exceptions import NotFoundError @@ -131,6 +132,8 @@ class FastMCP(Generic[LifespanResultT]): tools: list[Tool | Callable[..., Any]] | None = None, **settings: Any, ): + if cache_expiration_seconds is not None: + settings["cache_expiration_seconds"] = cache_expiration_seconds self.settings = fastmcp.settings.ServerSettings(**settings) # If mask_error_details is provided, override the settings value @@ -148,7 +151,9 @@ class FastMCP(Generic[LifespanResultT]): self.tags: set[str] = tags or set() self.dependencies = dependencies self._cache = TimedCache( - expiration=datetime.timedelta(seconds=cache_expiration_seconds or 0) + expiration=datetime.timedelta( + seconds=self.settings.cache_expiration_seconds + ) ) self._mounted_servers: dict[str, MountedServer] = {} self._additional_http_routes: list[BaseRoute] = [] @@ -496,11 +501,7 @@ class FastMCP(Generic[LifespanResultT]): with the Context type annotation. See the @tool decorator for examples. Args: - fn: The function to register as a tool - name: Optional name for the tool (defaults to function name) - description: Optional description of what the tool does - tags: Optional set of tags for categorizing the tool - annotations: Optional annotations about the tool's behavior + tool: The Tool instance to register """ self._tool_manager.add_tool(tool) self._cache.clear() @@ -870,7 +871,7 @@ class FastMCP(Generic[LifespanResultT]): This decorator supports multiple calling patterns: - @server.prompt (without parentheses) - - @server.prompt (with empty parentheses) + - @server.prompt() (with empty parentheses) - @server.prompt("custom_name") (with name as first argument) - @server.prompt(name="custom_name") (with name as keyword argument) - server.prompt(function, name="custom_name") (direct function call) @@ -892,7 +893,7 @@ class FastMCP(Generic[LifespanResultT]): } ] - @server.prompt + @server.prompt() def analyze_with_context(table_name: str, ctx: Context) -> list[Message]: ctx.info(f"Analyzing table {table_name}") schema = read_table_schema(table_name) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 96939d11c..f39c82ff1 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -170,7 +170,7 @@ class ServerSettings(BaseSettings): ), ] = [] - # cache settings (for checking mounted servers) + # cache settings (for getting attributes from servers, used to avoid repeated calls) cache_expiration_seconds: float = 0 # StreamableHTTP settings diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py index 5a2990980..176cb097d 100644 --- a/src/fastmcp/utilities/mcp_config.py +++ b/src/fastmcp/utilities/mcp_config.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal from urllib.parse import urlparse from pydantic import AnyUrl, Field @@ -55,7 +55,13 @@ class StdioMCPServer(FastMCPBaseModel): class RemoteMCPServer(FastMCPBaseModel): url: str headers: dict[str, str] = Field(default_factory=dict) - transport: Literal["streamable-http", "sse", "http"] | None = None + transport: Literal["streamable-http", "sse"] | None = None + auth: Annotated[ + str | Literal["oauth"] | None, + Field( + description='Either a string representing a Bearer token or the literal "oauth" to use OAuth authentication.' + ), + ] = None def to_transport(self) -> StreamableHttpTransport | SSETransport: from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -66,9 +72,11 @@ class RemoteMCPServer(FastMCPBaseModel): transport = self.transport if transport == "sse": - return SSETransport(self.url, headers=self.headers) + return SSETransport(self.url, headers=self.headers, auth=self.auth) else: - return StreamableHttpTransport(self.url, headers=self.headers) + return StreamableHttpTransport( + self.url, headers=self.headers, auth=self.auth + ) class MCPConfig(FastMCPBaseModel): diff --git a/tests/client/test_client.py b/tests/client/test_client.py index e012d8336..1c246337d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,12 +1,16 @@ import asyncio import sys from typing import cast +from unittest.mock import AsyncMock +import mcp import pytest from mcp import McpError +from mcp.client.auth import OAuthClientProvider from pydantic import AnyUrl from fastmcp.client import Client +from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.transports import ( FastMCPTransport, MCPConfigTransport, @@ -273,6 +277,14 @@ async def test_client_connection(fastmcp_server): assert not client.is_connected() +async def test_initialize_called_once(fastmcp_server, monkeypatch): + mock_initialize = AsyncMock() + monkeypatch.setattr(mcp.ClientSession, "initialize", mock_initialize) + client = Client(transport=FastMCPTransport(fastmcp_server)) + async with client: + assert mock_initialize.call_count == 1 + + async def test_initialize_result_connected(fastmcp_server): """Test that initialize_result returns the correct result when connected.""" client = Client(transport=FastMCPTransport(fastmcp_server)) @@ -810,3 +822,73 @@ class TestInferTransport: server = FastMCP1() transport = infer_transport(server) assert isinstance(transport, FastMCPTransport) + + +class TestAuth: + def test_default_auth_is_none(self): + client = Client(transport=StreamableHttpTransport("http://localhost:8000")) + assert client.transport.auth is None + + def test_stdio_doesnt_support_auth(self): + with pytest.raises(ValueError, match="This transport does not support auth"): + Client(transport=StdioTransport("echo", ["hello"]), auth="oauth") + + def test_oauth_literal_sets_up_oauth_shttp(self): + client = Client( + transport=StreamableHttpTransport("http://localhost:8000"), auth="oauth" + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, OAuthClientProvider) + + def test_oauth_literal_pass_direct_to_transport(self): + client = Client( + transport=StreamableHttpTransport("http://localhost:8000", auth="oauth"), + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, OAuthClientProvider) + + def test_oauth_literal_sets_up_oauth_sse(self): + client = Client(transport=SSETransport("http://localhost:8000"), auth="oauth") + assert isinstance(client.transport, SSETransport) + assert isinstance(client.transport.auth, OAuthClientProvider) + + def test_oauth_literal_pass_direct_to_transport_sse(self): + client = Client(transport=SSETransport("http://localhost:8000", auth="oauth")) + assert isinstance(client.transport, SSETransport) + assert isinstance(client.transport.auth, OAuthClientProvider) + + def test_auth_string_sets_up_bearer_auth_shttp(self): + client = Client( + transport=StreamableHttpTransport("http://localhost:8000"), + auth="test_token", + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, BearerAuth) + assert client.transport.auth.token.get_secret_value() == "test_token" + + def test_auth_string_pass_direct_to_transport_shttp(self): + client = Client( + transport=StreamableHttpTransport( + "http://localhost:8000", auth="test_token" + ), + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, BearerAuth) + assert client.transport.auth.token.get_secret_value() == "test_token" + + def test_auth_string_sets_up_bearer_auth_sse(self): + client = Client( + transport=SSETransport("http://localhost:8000"), + auth="test_token", + ) + assert isinstance(client.transport, SSETransport) + assert isinstance(client.transport.auth, BearerAuth) + assert client.transport.auth.token.get_secret_value() == "test_token" + + def test_auth_string_pass_direct_to_transport_sse(self): + client = Client( + transport=SSETransport("http://localhost:8000", auth="test_token"), + ) + assert isinstance(client.transport, SSETransport) + assert isinstance(client.transport.auth, BearerAuth) + assert client.transport.auth.token.get_secret_value() == "test_token" diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 090666ccc..97c2efd3e 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -304,6 +304,19 @@ class TestDynamicChanges: tools = await main_app.get_tools() assert "sub_temp_tool" not in tools + async def test_cache_expiration(self): + main_app = FastMCP("MainApp", cache_expiration_seconds=2) + sub_app = FastMCP("SubApp") + tools = await main_app.get_tools() + assert len(tools) == 0 + + @sub_app.tool + def sub_tool(): + return "sub_tool" + + tools = await main_app.get_tools() + assert len(tools) == 0 + class TestResourcesAndTemplates: """Test mounting with resources and resource templates.""" diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 9ca60f0d8..39e87c8c9 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -9,7 +9,7 @@ from pydantic import AnyUrl from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport +from fastmcp.client.transports import FastMCPTransport, StreamableHttpTransport from fastmcp.exceptions import ToolError from fastmcp.server.proxy import FastMCPProxy @@ -104,7 +104,8 @@ def test_as_proxy_with_url(): """FastMCP.as_proxy should accept a URL without connecting.""" proxy = FastMCP.as_proxy("http://example.com/mcp") assert isinstance(proxy, FastMCPProxy) - assert repr(proxy.client.transport).startswith("