mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Merge branch 'main' into transform-tools-2
This commit is contained in:
commit
47b2c58134
25 changed files with 302 additions and 83 deletions
20
.github/dependabot.yml
vendored
Normal file
20
.github/dependabot.yml
vendored
Normal file
|
|
@ -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"
|
||||
4
.github/release.yml
vendored
4
.github/release.yml
vendored
|
|
@ -27,6 +27,10 @@ changelog:
|
|||
labels:
|
||||
- documentation
|
||||
|
||||
- title: Dependencies 📦
|
||||
labels:
|
||||
- dependencies
|
||||
|
||||
- title: Other Changes 🦾
|
||||
labels:
|
||||
- "*"
|
||||
|
|
|
|||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/run-static.yml
vendored
2
.github/workflows/run-static.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
BIN
docs/assets/updates/release-2-7.png
Normal file
BIN
docs/assets/updates/release-2-7.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
|
|
@ -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).
|
||||
</Tip>
|
||||
|
||||
### 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.
|
||||
</Warning>
|
||||
|
||||
### 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
|
||||
|
||||
<VersionBadge version="2.6.0" />
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: Claude Desktop
|
||||
title: Claude Desktop + FastMCP
|
||||
sidebarTitle: Claude Desktop
|
||||
description: Call FastMCP servers from Claude Desktop
|
||||
icon: desktop
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,36 @@ title: "FastMCP Updates"
|
|||
sidebarTitle: "Updates"
|
||||
icon: "sparkles"
|
||||
tag: "New!"
|
||||
mode: "wide"
|
||||
---
|
||||
<Update label="FastMCP 2.7" description="June 6, 2025">
|
||||
<Card
|
||||
title="FastMCP 2.7: Pare Programming" href="https://github.com/jlowin/fastmcp/releases/tag/v2.7.0"
|
||||
img="assets/updates/release-2-7.png"
|
||||
cta="Read the release notes"
|
||||
arrow="false"
|
||||
>
|
||||
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.
|
||||
</Card>
|
||||
</Update>
|
||||
|
||||
|
||||
|
||||
<Update label="FastMCP 2.6" description="June 6, 2025">
|
||||
|
||||
|
||||
|
||||
<Card
|
||||
title="Blast Auth with FastMCP 2.6" href="https://www.jlowin.dev/blog/fastmcp-2-6"
|
||||
img="https://www.jlowin.dev/_image?href=%2F_astro%2Fhero.Bsu8afiw.png&w=1000&h=500&f=webp"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""FastmMCP CLI tools."""
|
||||
"""FastMCP CLI tools."""
|
||||
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ class Client(Generic[ClientTransportT]):
|
|||
progress_handler: ProgressHandler | None = None,
|
||||
timeout: datetime.timedelta | float | int | None = None,
|
||||
init_timeout: datetime.timedelta | float | int | None = None,
|
||||
client_info: mcp.types.Implementation | None = None,
|
||||
auth: httpx.Auth | Literal["oauth"] | str | None = None,
|
||||
):
|
||||
self.transport = cast(ClientTransportT, infer_transport(transport))
|
||||
|
|
@ -180,6 +181,7 @@ class Client(Generic[ClientTransportT]):
|
|||
"logging_callback": create_log_callback(log_handler),
|
||||
"message_handler": message_handler,
|
||||
"read_timeout_seconds": timeout,
|
||||
"client_info": client_info,
|
||||
}
|
||||
|
||||
if roots is not None:
|
||||
|
|
|
|||
|
|
@ -8,39 +8,25 @@ import sys
|
|||
import warnings
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Literal,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
from typing import Any, Literal, TypedDict, TypeVar, cast, overload
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import mcp.types
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.session import (
|
||||
ListRootsFnT,
|
||||
LoggingFnT,
|
||||
MessageHandlerFnT,
|
||||
SamplingFnT,
|
||||
)
|
||||
from mcp.client.session import ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
|
||||
from mcp.server.fastmcp import FastMCP as FastMCP1Server
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
from mcp.shared.memory import create_client_server_memory_streams
|
||||
from pydantic import AnyUrl
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from fastmcp.client.auth.bearer import BearerAuth
|
||||
from fastmcp.client.auth.oauth import OAuth
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
from fastmcp.server.server import FastMCP
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastmcp.utilities.mcp_config import MCPConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# TypeVar for preserving specific ClientTransport subclass types
|
||||
|
|
@ -64,11 +50,12 @@ __all__ = [
|
|||
class SessionKwargs(TypedDict, total=False):
|
||||
"""Keyword arguments for the MCP ClientSession constructor."""
|
||||
|
||||
read_timeout_seconds: datetime.timedelta | None
|
||||
sampling_callback: SamplingFnT | None
|
||||
list_roots_callback: ListRootsFnT | None
|
||||
logging_callback: LoggingFnT | None
|
||||
message_handler: MessageHandlerFnT | None
|
||||
read_timeout_seconds: datetime.timedelta | None
|
||||
client_info: mcp.types.Implementation | None
|
||||
|
||||
|
||||
class ClientTransport(abc.ABC):
|
||||
|
|
@ -152,7 +139,7 @@ class WSTransport(ClientTransport):
|
|||
yield session
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WebSocket(url='{self.url}')>"
|
||||
return f"<WebSocketTransport(url='{self.url}')>"
|
||||
|
||||
|
||||
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"<SSE(url='{self.url}')>"
|
||||
return f"<SSETransport(url='{self.url}')>"
|
||||
|
||||
|
||||
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"<StreamableHttp(url='{self.url}')>"
|
||||
return f"<StreamableHttpTransport(url='{self.url}')>"
|
||||
|
||||
|
||||
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"<FastMCP(server='{self.server.name}')>"
|
||||
return f"<FastMCPTransport(server='{self.server.name}')>"
|
||||
|
||||
|
||||
class MCPConfigTransport(ClientTransport):
|
||||
|
|
@ -769,7 +776,7 @@ class MCPConfigTransport(ClientTransport):
|
|||
yield session
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<MCPConfig(config='{self.config}')>"
|
||||
return f"<MCPConfigTransport(config='{self.config}')>"
|
||||
|
||||
|
||||
@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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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("<StreamableHttp(")
|
||||
assert isinstance(proxy.client.transport, StreamableHttpTransport)
|
||||
assert proxy.client.transport.url == "http://example.com/mcp"
|
||||
|
||||
|
||||
class TestTools:
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ class TestToolContextInjection:
|
|||
result = await client.call_tool("tool_with_context", {"x": 42})
|
||||
assert len(result) == 1
|
||||
content = result[0]
|
||||
assert content.text == "2" # type: ignore[attr-defined]
|
||||
assert content.text == "1" # type: ignore[attr-defined]
|
||||
|
||||
async def test_async_context(self):
|
||||
"""Test that context works in async functions."""
|
||||
|
|
@ -632,7 +632,7 @@ class TestToolContextInjection:
|
|||
result = await client.call_tool("async_tool", {"x": 42})
|
||||
assert len(result) == 1
|
||||
content = result[0]
|
||||
assert content.text == "Async request 2: 42" # type: ignore[attr-defined]
|
||||
assert content.text == "Async request 1: 42" # type: ignore[attr-defined]
|
||||
|
||||
async def test_optional_context(self):
|
||||
"""Test that context is optional."""
|
||||
|
|
@ -696,7 +696,7 @@ class TestToolContextInjection:
|
|||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("MyTool", {"x": 2})
|
||||
assert result[0].text == "4" # type: ignore[attr-defined]
|
||||
assert result[0].text == "3" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestResource:
|
||||
|
|
@ -780,7 +780,7 @@ class TestResourceContext:
|
|||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource(AnyUrl("resource://test"))
|
||||
assert result[0].text == "2" # type: ignore[attr-defined]
|
||||
assert result[0].text == "1" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestResourceTemplates:
|
||||
|
|
@ -1015,7 +1015,7 @@ class TestResourceTemplateContext:
|
|||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource(AnyUrl("resource://test"))
|
||||
assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined]
|
||||
assert result[0].text.startswith("Resource template: test 1") # type: ignore[attr-defined]
|
||||
|
||||
async def test_resource_template_context_with_callable_object(self):
|
||||
mcp = FastMCP()
|
||||
|
|
@ -1031,7 +1031,7 @@ class TestResourceTemplateContext:
|
|||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource(AnyUrl("resource://test"))
|
||||
assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined]
|
||||
assert result[0].text.startswith("Resource template: test 1") # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class TestPrompts:
|
||||
|
|
@ -1249,4 +1249,4 @@ class TestPromptContext:
|
|||
assert len(result.messages) == 1
|
||||
message = result.messages[0]
|
||||
assert message.role == "user"
|
||||
assert message.content.text == "Hello, World! 2" # type: ignore[attr-defined]
|
||||
assert message.content.text == "Hello, World! 1" # type: ignore[attr-defined]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp.client.auth.bearer import BearerAuth
|
||||
from fastmcp.client.auth.oauth import OAuthClientProvider
|
||||
from fastmcp.client.client import Client
|
||||
from fastmcp.client.transports import (
|
||||
SSETransport,
|
||||
|
|
@ -136,3 +138,60 @@ async def test_multi_client(tmp_path: Path):
|
|||
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
|
||||
assert result_1[0].text == "3" # type: ignore[attr-dict]
|
||||
assert result_2[0].text == "3" # type: ignore[attr-dict]
|
||||
|
||||
|
||||
async def test_remote_config_default_no_auth():
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"test_server": {
|
||||
"url": "http://localhost:8000",
|
||||
}
|
||||
}
|
||||
}
|
||||
client = Client(config)
|
||||
assert isinstance(client.transport.transport, StreamableHttpTransport)
|
||||
assert client.transport.transport.auth is None
|
||||
|
||||
|
||||
async def test_remote_config_with_auth_token():
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"test_server": {
|
||||
"url": "http://localhost:8000",
|
||||
"auth": "test_token",
|
||||
}
|
||||
}
|
||||
}
|
||||
client = Client(config)
|
||||
assert isinstance(client.transport.transport, StreamableHttpTransport)
|
||||
assert isinstance(client.transport.transport.auth, BearerAuth)
|
||||
assert client.transport.transport.auth.token.get_secret_value() == "test_token"
|
||||
|
||||
|
||||
async def test_remote_config_sse_with_auth_token():
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"test_server": {
|
||||
"url": "http://localhost:8000/sse",
|
||||
"auth": "test_token",
|
||||
}
|
||||
}
|
||||
}
|
||||
client = Client(config)
|
||||
assert isinstance(client.transport.transport, SSETransport)
|
||||
assert isinstance(client.transport.transport.auth, BearerAuth)
|
||||
assert client.transport.transport.auth.token.get_secret_value() == "test_token"
|
||||
|
||||
|
||||
async def test_remote_config_with_oauth_literal():
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"test_server": {
|
||||
"url": "http://localhost:8000",
|
||||
"auth": "oauth",
|
||||
}
|
||||
}
|
||||
}
|
||||
client = Client(config)
|
||||
assert isinstance(client.transport.transport, StreamableHttpTransport)
|
||||
assert isinstance(client.transport.transport.auth, OAuthClientProvider)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue