Merge pull request #111 from jlowin/client

Reorganize all client / transports
This commit is contained in:
Jeremiah Lowin 2025-04-10 21:11:31 -04:00 committed by GitHub
commit 8ee3bc02cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 851 additions and 662 deletions

View file

@ -1,150 +0,0 @@
"""
Modular FastMCP Application Example
This example demonstrates building a modular application with FastMCP
by separating functionality into domain-specific modules.
"""
import asyncio
from pathlib import Path
from typing import Any, Dict, List
from fastmcp import Context, FastMCP
# ----- DATA MODULE -----
data_app = FastMCP("Data Module")
# Simulated database
users_db = [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"},
{"id": 3, "name": "Charlie", "email": "charlie@example.com"},
]
@data_app.resource("users://all")
def get_all_users() -> List[Dict[str, Any]]:
"""Get all users in the database"""
return users_db
@data_app.resource("users://{user_id}")
def get_user_by_id(user_id: str) -> dict[str, Any] | None:
"""Get a specific user by ID"""
user_id_int = int(user_id)
for user in users_db:
if user["id"] == user_id_int:
return user
return None
@data_app.tool()
async def create_user(name: str, email: str, ctx: Context) -> Dict[str, Any]:
"""Add a new user to the database"""
# Simulate a slow operation
await ctx.info(f"Creating user {name}...")
await asyncio.sleep(1)
# Create user
new_id = max(user["id"] for user in users_db) + 1
new_user = {"id": new_id, "name": name, "email": email}
users_db.append(new_user)
await ctx.info(f"User created with ID {new_id}")
return new_user
# ----- ANALYTICS MODULE -----
analytics_app = FastMCP("Analytics Module")
@analytics_app.tool()
async def analyze_users(ctx: Context) -> Dict[str, Any]:
"""Run analytics on user data"""
# Get user data from the data module
users = await ctx.read_resource("data:users://all")
# Perform analytics
await ctx.info("Analyzing user data...")
await asyncio.sleep(1)
# Return analytics results
return {
"total_users": len(users),
"domains": {user["email"].split("@")[1] for user in users},
}
@analytics_app.resource("analytics://summary")
def get_analytics_summary() -> Dict[str, Any]:
"""Get a summary of analytics data"""
return {"active_users": len(users_db), "last_updated": "2023-06-01"}
# ----- FILESYSTEM MODULE -----
files_app = FastMCP("Filesystem Module")
@files_app.resource("files://desktop")
def list_desktop_files() -> List[str]:
"""List files on the user's desktop"""
desktop = Path.home() / "Desktop"
return [f.name for f in desktop.iterdir() if f.is_file()]
@files_app.tool()
async def search_files(query: str, ctx: Context) -> List[str]:
"""Search for files matching a query"""
await ctx.info(f"Searching for files matching '{query}'...")
# Simulate a file search
desktop = Path.home() / "Desktop"
files = [
f.name
for f in desktop.iterdir()
if f.is_file() and query.lower() in f.name.lower()
]
await ctx.info(f"Found {len(files)} matching files")
return files
# ----- MAIN APPLICATION -----
# Create the main application that combines all modules
main_app = FastMCP("Modular FastMCP Demo")
@main_app.tool()
async def get_system_info(ctx: Context) -> Dict[str, Any]:
"""Get comprehensive system information"""
await ctx.info("Gathering system information...")
# Use the mounted modules to gather info
users = await ctx.read_resource("data:users://all")
analytics = await ctx.read_resource("analytics:analytics://summary")
desktop_files = await ctx.read_resource("files:files://desktop")
return {
"users": {"count": len(users), "names": [user["name"] for user in users]},
"analytics": analytics,
"files": {"desktop_count": len(desktop_files)},
}
# Mount all modules to the main app
main_app.mount("data", data_app)
main_app.mount("analytics", analytics_app)
main_app.mount("files", files_app)
if __name__ == "__main__":
# Now register resources (which requires async)
async def initialize_resources():
await main_app.register_all_mounted_resources()
print("Resources registered successfully!")
# Initialize resources
asyncio.run(initialize_resources())
# Start the server
print("Starting modular FastMCP application...")
main_app.run()

View file

@ -1,11 +1,12 @@
"""FastMCP - An ergonomic MCP interface."""
from importlib.metadata import version
import fastmcp.settings
from fastmcp.server.server import FastMCP
from fastmcp.server.context import Context
from . import clients
from fastmcp.client import Client
from . import client, settings
__version__ = version("fastmcp")
__all__ = ["FastMCP", "Context", "clients"]
__all__ = ["FastMCP", "Context", "client", "settings"]

View file

@ -0,0 +1,25 @@
from .client import Client
from .transports import (
ClientTransport,
WSTransport,
SSETransport,
StdioTransport,
PythonStdioTransport,
NodeStdioTransport,
UvxStdioTransport,
NpxStdioTransport,
FastMCPTransport,
)
__all__ = [
"Client",
"ClientTransport",
"WSTransport",
"SSETransport",
"StdioTransport",
"PythonStdioTransport",
"NodeStdioTransport",
"UvxStdioTransport",
"NpxStdioTransport",
"FastMCPTransport",
]

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,176 @@
import datetime
from pathlib import Path
from typing import Any, AsyncContextManager
import mcp.types
from mcp import ClientSession
from mcp.client.session import (
ListRootsFnT,
LoggingFnT,
MessageHandlerFnT,
SamplingFnT,
)
from mcp.shared.context import LifespanContextT, RequestContext
from pydantic import AnyUrl
from fastmcp.server import FastMCP
from .transports import ClientTransport, SessionKwargs, infer_transport
def _get_roots_callback(roots: list[mcp.types.Root]) -> ListRootsFnT | None:
async def _roots_callback(
context: RequestContext[ClientSession, LifespanContextT],
) -> mcp.types.ListRootsResult:
return mcp.types.ListRootsResult(roots=roots)
return _roots_callback
class Client:
"""
MCP client that delegates connection management to a Transport instance.
The Client class is primarily concerned with MCP protocol logic,
while the Transport handles connection establishment and management.
"""
def __init__(
self,
transport: ClientTransport | FastMCP | AnyUrl | Path | str,
# Common args
roots: list[mcp.types.Root] | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
read_timeout_seconds: datetime.timedelta | None = None,
):
self.transport = infer_transport(transport)
self._session: ClientSession | None = None
self._session_cm: AsyncContextManager[ClientSession] | None = None
# Store common kwargs to pass to transport.connect_session
if roots is not None and list_roots_callback is not None:
raise ValueError("Cannot provide both `roots` and `list_roots_callback`.")
resolved_list_roots_callback = list_roots_callback or (
_get_roots_callback(roots) if roots else None
)
self._session_kwargs: SessionKwargs = {
"sampling_callback": sampling_callback,
"list_roots_callback": resolved_list_roots_callback,
"logging_callback": logging_callback,
"message_handler": message_handler,
"read_timeout_seconds": read_timeout_seconds,
}
@property
def session(self) -> ClientSession:
"""Get the current active session. Raises RuntimeError if not connected."""
if self._session is None:
raise RuntimeError(
"Client is not connected. Use 'async with client:' context manager first."
)
return self._session
def is_connected(self) -> bool:
"""Check if the client is currently connected."""
return self._session is not None
async def __aenter__(self):
if self.is_connected():
raise RuntimeError("Client is already connected in an async context.")
try:
self._session_cm = self.transport.connect_session(**self._session_kwargs)
self._session = await self._session_cm.__aenter__()
return self
except Exception as e:
# Ensure cleanup if __aenter__ fails partially
self._session = None
self._session_cm = None
raise ConnectionError(
f"Failed to connect using {self.transport}: {e}"
) from e
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._session_cm:
await self._session_cm.__aexit__(exc_type, exc_val, exc_tb)
self._session = None
self._session_cm = None
# --- MCP Client Methods ---
async def ping(self) -> None:
"""Send a ping request."""
await self.session.send_ping()
async def progress(
self,
progress_token: str | int,
progress: float,
total: float | None = None,
) -> None:
"""Send a progress notification."""
await self.session.send_progress_notification(progress_token, progress, total)
async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
"""Send a logging/setLevel request."""
await self.session.set_logging_level(level)
async def list_resources(self) -> mcp.types.ListResourcesResult:
"""Send a resources/list request."""
return await self.session.list_resources()
async def list_resource_templates(self) -> mcp.types.ListResourceTemplatesResult:
"""Send a resources/listResourceTemplates request."""
return await self.session.list_resource_templates()
async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
"""Send a resources/read request."""
if isinstance(uri, str):
uri = AnyUrl(uri) # Ensure AnyUrl
return await self.session.read_resource(uri)
async def subscribe_resource(self, uri: AnyUrl | str) -> None:
"""Send a resources/subscribe request."""
if isinstance(uri, str):
uri = AnyUrl(uri)
await self.session.subscribe_resource(uri)
async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
"""Send a resources/unsubscribe request."""
if isinstance(uri, str):
uri = AnyUrl(uri)
await self.session.unsubscribe_resource(uri)
async def list_prompts(self) -> mcp.types.ListPromptsResult:
"""Send a prompts/list request."""
return await self.session.list_prompts()
async def get_prompt(
self, name: str, arguments: dict[str, str] | None = None
) -> mcp.types.GetPromptResult:
"""Send a prompts/get request."""
return await self.session.get_prompt(name, arguments)
async def complete(
self,
ref: mcp.types.ResourceReference | mcp.types.PromptReference,
argument: dict[str, str],
) -> mcp.types.CompleteResult:
"""Send a completion/complete request."""
return await self.session.complete(ref, argument)
async def list_tools(self) -> mcp.types.ListToolsResult:
"""Send a tools/list request."""
return await self.session.list_tools()
async def call_tool(
self, name: str, arguments: dict[str, Any] | None = None
) -> mcp.types.CallToolResult:
"""Send a tools/call request."""
return await self.session.call_tool(name, arguments)
async def send_roots_list_changed(self) -> None:
"""Send a roots/list_changed notification."""
await self.session.send_roots_list_changed()

View file

@ -0,0 +1,415 @@
import abc
import contextlib
import datetime
import os
from pathlib import Path
from typing import (
AsyncIterator,
Dict,
List,
Optional,
TypedDict,
Union,
)
from mcp import ClientSession, StdioServerParameters
from mcp.client.session import (
ListRootsFnT,
LoggingFnT,
MessageHandlerFnT,
SamplingFnT,
)
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.websocket import websocket_client
from mcp.shared.memory import create_connected_server_and_client_session
from pydantic import AnyUrl
from typing_extensions import Unpack
from fastmcp.server import FastMCP as FastMCPServer
class SessionKwargs(TypedDict, total=False):
"""Keyword arguments for the MCP ClientSession constructor."""
sampling_callback: SamplingFnT | None
list_roots_callback: ListRootsFnT | None
logging_callback: LoggingFnT | None
message_handler: MessageHandlerFnT | None
read_timeout_seconds: datetime.timedelta | None
class ClientTransport(abc.ABC):
"""
Abstract base class for different MCP client transport mechanisms.
A Transport is responsible for establishing and managing connections
to an MCP server, and providing a ClientSession within an async context.
"""
@abc.abstractmethod
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
"""
Establishes a connection and yields an active, initialized ClientSession.
The session is guaranteed to be valid only within the scope of the
async context manager. Connection setup and teardown are handled
within this context.
Args:
**session_kwargs: Keyword arguments to pass to the ClientSession
constructor (e.g., callbacks, timeouts).
Yields:
An initialized mcp.ClientSession instance.
"""
raise NotImplementedError
yield None # type: ignore
def __repr__(self) -> str:
# Basic representation for subclasses
return f"<{self.__class__.__name__}>"
class WSTransport(ClientTransport):
"""Transport implementation that connects to an MCP server via WebSockets."""
def __init__(self, url: str | AnyUrl):
if isinstance(url, AnyUrl):
url = str(url)
if not isinstance(url, str) or not url.startswith("ws"):
raise ValueError("Invalid WebSocket URL provided.")
self.url = url
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
async with websocket_client(self.url) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
await session.initialize() # Initialize after session creation
yield session
def __repr__(self) -> str:
return f"<WebSocket(url='{self.url}')>"
class SSETransport(ClientTransport):
"""Transport implementation that connects to an MCP server via Server-Sent Events."""
def __init__(self, url: str | AnyUrl, headers: Optional[Dict[str, str]] = None):
if isinstance(url, AnyUrl):
url = str(url)
if not isinstance(url, str) or not url.startswith("http"):
raise ValueError("Invalid HTTP/S URL provided for SSE.")
self.url = url
self.headers = headers or {}
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
async with sse_client(self.url, headers=self.headers) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
await session.initialize()
yield session
def __repr__(self) -> str:
return f"<SSE(url='{self.url}')>"
class StdioTransport(ClientTransport):
"""
Base transport for connecting to an MCP server via subprocess with stdio.
This is a base class that can be subclassed for specific command-based
transports like Python, Node, Uvx, etc.
"""
def __init__(
self,
command: str,
args: List[str],
env: Optional[Dict[str, str]] = None,
cwd: Optional[str] = None,
):
"""
Initialize a Stdio transport.
Args:
command: The command to run (e.g., "python", "node", "uvx")
args: The arguments to pass to the command
env: Environment variables to set for the subprocess
cwd: Current working directory for the subprocess
"""
self.command = command
self.args = args
self.env = env
self.cwd = cwd
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
server_params = StdioServerParameters(
command=self.command, args=self.args, env=self.env, cwd=self.cwd
)
async with stdio_client(server_params) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
await session.initialize()
yield session
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__}(command='{self.command}', args={self.args})>"
)
class PythonStdioTransport(StdioTransport):
"""Transport for running Python scripts."""
def __init__(
self,
script_path: Union[str, Path],
args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None,
cwd: Optional[str] = None,
python_cmd: str = "python",
):
"""
Initialize a Python transport.
Args:
script_path: Path to the Python script to run
args: Additional arguments to pass to the script
env: Environment variables to set for the subprocess
cwd: Current working directory for the subprocess
python_cmd: Python command to use (default: "python")
"""
script_path = Path(script_path).resolve()
if not script_path.is_file():
raise FileNotFoundError(f"Script not found: {script_path}")
if not str(script_path).endswith(".py"):
raise ValueError(f"Not a Python script: {script_path}")
full_args = [str(script_path)]
if args:
full_args.extend(args)
super().__init__(command=python_cmd, args=full_args, env=env, cwd=cwd)
self.script_path = script_path
class NodeStdioTransport(StdioTransport):
"""Transport for running Node.js scripts."""
def __init__(
self,
script_path: Union[str, Path],
args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None,
cwd: Optional[str] = None,
node_cmd: str = "node",
):
"""
Initialize a Node transport.
Args:
script_path: Path to the Node.js script to run
args: Additional arguments to pass to the script
env: Environment variables to set for the subprocess
cwd: Current working directory for the subprocess
node_cmd: Node.js command to use (default: "node")
"""
script_path = Path(script_path).resolve()
if not script_path.is_file():
raise FileNotFoundError(f"Script not found: {script_path}")
if not str(script_path).endswith(".js"):
raise ValueError(f"Not a JavaScript script: {script_path}")
full_args = [str(script_path)]
if args:
full_args.extend(args)
super().__init__(command=node_cmd, args=full_args, env=env, cwd=cwd)
self.script_path = script_path
class UvxStdioTransport(StdioTransport):
"""Transport for running commands via the uvx tool."""
def __init__(
self,
tool_name: str,
tool_args: Optional[List[str]] = None,
project_directory: Optional[str] = None,
python_version: Optional[str] = None,
with_packages: Optional[List[str]] = None,
from_package: Optional[str] = None,
env_vars: Optional[Dict[str, str]] = None,
):
"""
Initialize a Uvx transport.
Args:
tool_name: Name of the tool to run via uvx
tool_args: Arguments to pass to the tool
project_directory: Project directory (for package resolution)
python_version: Python version to use
with_packages: Additional packages to include
from_package: Package to install the tool from
env_vars: Additional environment variables
"""
# Basic validation
if project_directory and not Path(project_directory).exists():
raise NotADirectoryError(
f"Project directory not found: {project_directory}"
)
# Build uvx arguments
uvx_args = []
if python_version:
uvx_args.extend(["--python", python_version])
if from_package:
uvx_args.extend(["--from", from_package])
for pkg in with_packages or []:
uvx_args.extend(["--with", pkg])
# Add the tool name and tool args
uvx_args.append(tool_name)
if tool_args:
uvx_args.extend(tool_args)
# Get environment with any additional variables
env = None
if env_vars:
env = os.environ.copy()
env.update(env_vars)
super().__init__(command="uvx", args=uvx_args, env=env, cwd=project_directory)
self.tool_name = tool_name
class NpxStdioTransport(StdioTransport):
"""Transport for running commands via the npx tool."""
def __init__(
self,
package: str,
args: Optional[List[str]] = None,
project_directory: Optional[str] = None,
env_vars: Optional[Dict[str, str]] = None,
use_package_lock: bool = True,
):
"""
Initialize an Npx transport.
Args:
package: Name of the npm package to run
args: Arguments to pass to the package command
project_directory: Project directory with package.json
env_vars: Additional environment variables
use_package_lock: Whether to use package-lock.json (--prefer-offline)
"""
# Basic validation
if project_directory and not Path(project_directory).exists():
raise NotADirectoryError(
f"Project directory not found: {project_directory}"
)
# Build npx arguments
npx_args = []
if use_package_lock:
npx_args.append("--prefer-offline")
# Add the package name and args
npx_args.append(package)
if args:
npx_args.extend(args)
# Get environment with any additional variables
env = None
if env_vars:
env = os.environ.copy()
env.update(env_vars)
super().__init__(command="npx", args=npx_args, env=env, cwd=project_directory)
self.package = package
class FastMCPTransport(ClientTransport):
"""
Special transport for in-memory connections to an MCP server.
This is particularly useful for testing or when client and server
are in the same process.
"""
def __init__(self, mcp: FastMCPServer):
self._fastmcp = mcp # Can be FastMCP or MCPServer
@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._fastmcp._mcp_server,
**session_kwargs,
) as session:
yield session
def __repr__(self) -> str:
return f"<FastMCP(server='{self._fastmcp.name}')>"
def infer_transport(
transport: ClientTransport | FastMCPServer | AnyUrl | Path | str,
) -> ClientTransport:
"""
Infer the appropriate transport type from the given transport argument.
This function attempts to infer the correct transport type from the provided
argument, handling various input types and converting them to the appropriate
ClientTransport subclass.
"""
# the transport is already a ClientTransport
if isinstance(transport, ClientTransport):
return transport
# the transport is a FastMCP server
elif isinstance(transport, FastMCPServer):
return FastMCPTransport(mcp=transport)
# the transport is a path to a script
elif isinstance(transport, (Path, str)) and Path(transport).exists():
if str(transport).endswith(".py"):
return PythonStdioTransport(script_path=transport)
elif str(transport).endswith(".js"):
return NodeStdioTransport(script_path=transport)
else:
raise ValueError(f"Unsupported script type: {transport}")
# the transport is an http(s) URL
elif isinstance(transport, (AnyUrl, str)) and str(transport).startswith("http"):
return SSETransport(url=transport)
# the transport is a websocket URL
elif isinstance(transport, (AnyUrl, str)) and str(transport).startswith("ws"):
return WSTransport(url=transport)
# the transport is an unknown type
else:
raise ValueError(f"Could not infer a valid transport from: {transport}")

View file

@ -1,12 +0,0 @@
from .websocket import WebSocketClient
from .sse import SSEClient
from .stdio import StdioClient, UvxClient
from .fastmcp_client import FastMCPClient
__all__ = [
"StdioClient",
"SSEClient",
"WebSocketClient",
"UvxClient",
"FastMCPClient",
]

View file

@ -1,216 +0,0 @@
import abc
import contextlib
import datetime
from typing import Any, AsyncContextManager, TypedDict
import mcp.types
from mcp import ClientSession
from mcp.client.session import ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.shared.context import LifespanContextT, RequestContext
from pydantic import AnyUrl
def _get_roots_callback(roots: list[mcp.types.Root]) -> ListRootsFnT | None:
async def _roots_callback(
context: RequestContext[ClientSession, LifespanContextT],
) -> mcp.types.ListRootsResult:
return mcp.types.ListRootsResult(roots=roots)
return _roots_callback
class ClientKwargs(TypedDict, total=False):
roots: list[mcp.types.Root] | None
sampling_callback: SamplingFnT | None
list_roots_callback: ListRootsFnT | None
logging_callback: LoggingFnT | None
message_handler: MessageHandlerFnT | None
read_timeout_seconds: datetime.timedelta | None
class SessionKwargs(TypedDict, total=False):
sampling_callback: SamplingFnT | None
list_roots_callback: ListRootsFnT | None
logging_callback: LoggingFnT | None
message_handler: MessageHandlerFnT | None
read_timeout_seconds: datetime.timedelta | None
class BaseClient(abc.ABC):
def __init__(
self,
roots: list[mcp.types.Root] | None = None,
sampling_callback: SamplingFnT | None = None,
list_roots_callback: ListRootsFnT | None = None,
logging_callback: LoggingFnT | None = None,
message_handler: MessageHandlerFnT | None = None,
read_timeout_seconds: datetime.timedelta | None = None,
):
self._transport: Any | None = None
self._session: ClientSession | None = None
self._cm: AsyncContextManager | None = None
if roots is not None:
if list_roots_callback is not None:
raise ValueError(
"Cannot provide both `roots` and `list_roots_callback`. "
"Either provide a list of roots or a callback to list roots."
)
else:
list_roots_callback = _get_roots_callback(roots)
self._sampling_callback = sampling_callback
self._list_roots_callback = list_roots_callback
self._logging_callback = logging_callback
self._message_handler = message_handler
self._read_timeout_seconds = read_timeout_seconds
def _session_kwargs(self) -> SessionKwargs:
return SessionKwargs(
sampling_callback=self._sampling_callback,
list_roots_callback=self._list_roots_callback,
logging_callback=self._logging_callback,
message_handler=self._message_handler,
read_timeout_seconds=self._read_timeout_seconds,
)
@property
def transport(self):
"""Get the current transport connection"""
if self._transport is None:
raise RuntimeError(
"Client is not connected. Use 'async with client:' context manager first."
)
return self._transport
@property
def session(self):
"""Get the current session"""
if self._session is None:
raise RuntimeError(
"Client is not connected. Use 'async with client:' context manager first."
)
return self._session
def is_connected(self):
"""Check if the client is currently connected"""
return self._session is not None
@abc.abstractmethod
def _connect(self) -> AsyncContextManager:
"""Return an async context manager that handles connection lifecycle.
This will be called by __aenter__ to establish the connection."""
raise NotImplementedError("Subclasses must implement this method")
@contextlib.asynccontextmanager
async def _create_connection_context(self):
"""Create and manage the connection context if not already connected.
This handles both creating a new connection or reusing an existing one."""
created_connection = False
try:
if not self.is_connected():
# Only create a new connection if not already connected
self._cm = self._connect()
await self._cm.__aenter__()
created_connection = True
yield
finally:
if created_connection and self._cm is not None:
# Only close if we created the connection in this context
await self._cm.__aexit__(None, None, None)
self._transport = None
self._session = None
self._cm = None
@contextlib.asynccontextmanager
async def _set_session(self, transport: Any, session: ClientSession):
self._transport = transport
self._session = session
try:
await self._session.initialize()
yield
finally:
self._transport = None
self._session = None
async def __aenter__(self):
self._connection_ctx = self._create_connection_context()
await self._connection_ctx.__aenter__()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._connection_ctx.__aexit__(exc_type, exc_val, exc_tb)
# --- MCP Client Methods ---
async def ping(self) -> None:
"""Send a ping request."""
await self.session.send_ping()
async def progress(
self, progress_token: str | int, progress: float, total: float | None = None
) -> None:
"""Send a progress notification."""
await self.session.send_progress_notification(progress_token, progress, total)
async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
"""Send a logging/setLevel request."""
await self.session.set_logging_level(level)
async def list_resources(self) -> mcp.types.ListResourcesResult:
"""Send a resources/list request."""
return await self.session.list_resources()
async def list_resource_templates(self) -> mcp.types.ListResourceTemplatesResult:
"""Send a resources/listResourceTemplates request."""
return await self.session.list_resource_templates()
async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
"""Send a resources/read request."""
if isinstance(uri, str):
uri = AnyUrl(uri)
return await self.session.read_resource(uri)
async def subscribe_resource(self, uri: AnyUrl | str) -> None:
"""Send a resources/subscribe request."""
if isinstance(uri, str):
uri = AnyUrl(uri)
await self.session.subscribe_resource(uri)
async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
"""Send a resources/unsubscribe request."""
if isinstance(uri, str):
uri = AnyUrl(uri)
await self.session.unsubscribe_resource(uri)
async def list_prompts(self) -> mcp.types.ListPromptsResult:
"""Send a prompts/list request."""
return await self.session.list_prompts()
async def get_prompt(
self, name: str, arguments: dict[str, str] | None = None
) -> mcp.types.GetPromptResult:
"""Send a prompts/get request."""
return await self.session.get_prompt(name, arguments)
async def complete(
self,
ref: mcp.types.ResourceReference | mcp.types.PromptReference,
argument: dict[str, str],
) -> mcp.types.CompleteResult:
"""Send a completion/complete request."""
return await self.session.complete(ref, argument)
async def list_tools(self) -> mcp.types.ListToolsResult:
"""Send a tools/list request."""
return await self.session.list_tools()
async def call_tool(
self, name: str, arguments: dict[str, Any] | None = None
) -> mcp.types.CallToolResult:
"""Send a tools/call request."""
return await self.session.call_tool(name, arguments)
async def send_roots_list_changed(self) -> None:
"""Send a roots/list_changed notification."""
await self.session.send_roots_list_changed()

View file

@ -1,50 +0,0 @@
import contextlib
from typing import TypeVar
from mcp.shared.memory import create_connected_server_and_client_session
from typing_extensions import Unpack
from fastmcp.clients.base import BaseClient, ClientKwargs
from fastmcp.server.server import FastMCP
T = TypeVar("T")
class FastMCPClient(BaseClient):
"""Client that connects directly to an in-memory FastMCP server.
This client creates and manages an in-memory connection to a server,
without using any external processes or network connections.
"""
def __init__(
self,
server: FastMCP,
**kwargs: Unpack[ClientKwargs],
):
"""Initialize an InMemoryClient that connects to an in-memory MCP server.
Args:
server: The FastMCP instance to connect to
**kwargs: Additional arguments for BaseClient
"""
super().__init__(**kwargs)
self.server = server
self._cm_session = None
@contextlib.asynccontextmanager
async def _connect(self):
"""Set up in-memory connection and session"""
self._cm_session = create_connected_server_and_client_session(
server=self.server._mcp_server,
read_timeout_seconds=self._read_timeout_seconds,
sampling_callback=self._sampling_callback,
list_roots_callback=self._list_roots_callback,
logging_callback=self._logging_callback,
message_handler=self._message_handler,
)
async with self._cm_session as session:
# No need to call initialize as create_connected_server_and_client_session already does
async with self._set_session((None, None), session):
yield self

View file

@ -1,32 +0,0 @@
import contextlib
from mcp import ClientSession
from mcp.client.sse import sse_client
from typing_extensions import Unpack
from fastmcp.clients.base import BaseClient, ClientKwargs
class SSEClient(BaseClient):
def __init__(
self,
url: str,
headers: dict[str, str] | None = None,
**kwargs: Unpack[ClientKwargs],
):
super().__init__(**kwargs)
self.url = url
self.headers = headers or {}
@contextlib.asynccontextmanager
async def _connect(self):
"""Set up SSE connection and session"""
async with sse_client(self.url, headers=self.headers) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream=read_stream,
write_stream=write_stream,
**self._session_kwargs(),
) as session:
async with self._set_session(transport, session):
yield self

View file

@ -1,134 +0,0 @@
import contextlib
import os
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from typing_extensions import Unpack
from fastmcp.clients.base import BaseClient, ClientKwargs
class StdioClient(BaseClient):
def __init__(
self,
server_script_path: str,
**kwargs: Unpack[ClientKwargs],
):
super().__init__(**kwargs)
self.server_script_path = server_script_path
@contextlib.asynccontextmanager
async def _connect(self):
"""Set up stdio connection and session"""
is_python = self.server_script_path.endswith(".py")
is_js = self.server_script_path.endswith(".js")
if not (is_python or is_js):
raise ValueError("Server script must be a .py or .js file")
command = "python" if is_python else "node"
server_params = StdioServerParameters(
command=command, args=[self.server_script_path], env=None
)
async with stdio_client(server_params) as transport:
stdio, write = transport
async with ClientSession(
read_stream=stdio,
write_stream=write,
**self._session_kwargs(),
) as session:
async with self._set_session(transport, session):
yield self
class UvxClient(BaseClient):
"""Client that uses uvx to run Python tools in isolated environments.
uvx automatically installs and manages dependencies from pyproject.toml.
"""
def __init__(
self,
tool_name: str,
tool_args: list[str] | None = None,
project_directory: str | None = None,
python_version: str | None = None,
with_packages: list[str] | None = None,
from_package: str | None = None,
env_vars: dict[str, str] | None = None,
**kwargs: Unpack[ClientKwargs],
):
"""Initialize a UvxClient that uses uvx to run Python tools in isolated environments.
Args:
tool_name: Name of the tool/command to run
tool_args: Arguments to pass to the tool
project_directory: Path to the project directory (optional)
python_version: Specific Python version to use (e.g., "3.10")
with_packages: Additional packages to include
from_package: Package that provides the tool if different from tool_name
env_vars: Environment variables to set for the process
**kwargs: Additional arguments for BaseClient
"""
super().__init__(**kwargs)
self.tool_name = tool_name
self.tool_args = tool_args or []
self.project_directory = project_directory
self.python_version = python_version
self.with_packages = with_packages or []
self.from_package = from_package
self.env_vars = env_vars or {}
@contextlib.asynccontextmanager
async def _connect(self):
"""Set up uvx connection and session"""
# Check if project directory exists if provided
if self.project_directory and not os.path.isdir(self.project_directory):
raise ValueError(
f"Project directory does not exist: {self.project_directory}"
)
# Build the uvx command arguments
args = []
# Add Python version if specified
if self.python_version:
args.extend(["--python", self.python_version])
# Add from package if specified
if self.from_package:
args.extend(["--from", self.from_package])
# Add with packages if specified
for pkg in self.with_packages:
args.extend(["--with", pkg])
# Add the tool name
args.append(self.tool_name)
# Add the tool arguments
args.extend(self.tool_args)
# Create environment variables dictionary
env = os.environ.copy()
env.update(self.env_vars)
# Configure the server parameters
server_params = StdioServerParameters(
command="uvx",
args=args,
env=env,
cwd=self.project_directory,
)
async with stdio_client(server_params) as transport:
stdio, write = transport
async with ClientSession(
read_stream=stdio,
write_stream=write,
**self._session_kwargs(),
) as session:
async with self._set_session(transport, session):
yield self

View file

@ -1,31 +0,0 @@
import contextlib
from mcp import ClientSession
from mcp.client.websocket import websocket_client
from typing_extensions import Unpack
from fastmcp.clients.base import BaseClient, ClientKwargs
class WebSocketClient(BaseClient):
def __init__(
self,
url: str,
**kwargs: Unpack[ClientKwargs],
):
super().__init__(**kwargs)
self.url = url
@contextlib.asynccontextmanager
async def _connect(self):
"""Set up WebSocket connection and session"""
async with websocket_client(self.url) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream=read_stream,
write_stream=write_stream,
**self._session_kwargs(),
) as session:
async with self._set_session(transport, session):
yield self

View file

@ -3,7 +3,8 @@ from typing import Any, cast
import mcp.types
from mcp.types import BlobResourceContents, PromptMessage, TextResourceContents
from fastmcp.clients.base import BaseClient
import fastmcp
from fastmcp.client import Client
from fastmcp.prompts import Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.context import Context
@ -20,14 +21,12 @@ def _proxy_passthrough():
class ProxyTool(Tool):
def __init__(self, client: "BaseClient", **kwargs):
def __init__(self, client: "Client", **kwargs):
super().__init__(**kwargs)
self._client = client
@classmethod
async def from_client(
cls, client: "BaseClient", tool: mcp.types.Tool
) -> "ProxyTool":
async def from_client(cls, client: "Client", tool: mcp.types.Tool) -> "ProxyTool":
return cls(
client=client,
name=tool.name,
@ -50,7 +49,7 @@ class ProxyTool(Tool):
class ProxyResource(Resource):
def __init__(
self, client: "BaseClient", *, _value: str | bytes | None = None, **kwargs
self, client: "Client", *, _value: str | bytes | None = None, **kwargs
):
super().__init__(**kwargs)
self._client = client
@ -58,7 +57,7 @@ class ProxyResource(Resource):
@classmethod
async def from_client(
cls, client: "BaseClient", resource: mcp.types.Resource
cls, client: "Client", resource: mcp.types.Resource
) -> "ProxyResource":
return cls(
client=client,
@ -83,13 +82,13 @@ class ProxyResource(Resource):
class ProxyTemplate(ResourceTemplate):
def __init__(self, client: "BaseClient", **kwargs):
def __init__(self, client: "Client", **kwargs):
super().__init__(**kwargs)
self._client = client
@classmethod
async def from_client(
cls, client: "BaseClient", template: mcp.types.ResourceTemplate
cls, client: "Client", template: mcp.types.ResourceTemplate
) -> "ProxyTemplate":
return cls(
client=client,
@ -123,13 +122,13 @@ class ProxyTemplate(ResourceTemplate):
class ProxyPrompt(Prompt):
def __init__(self, client: "BaseClient", **kwargs):
def __init__(self, client: "Client", **kwargs):
super().__init__(**kwargs)
self._client = client
@classmethod
async def from_client(
cls, client: "BaseClient", prompt: mcp.types.Prompt
cls, client: "Client", prompt: mcp.types.Prompt
) -> "ProxyPrompt":
return cls(
client=client,
@ -155,7 +154,10 @@ class FastMCPProxy(FastMCP):
@classmethod
async def from_client(
cls, client: "BaseClient", name: str | None = None, **settings: Any
cls,
client: "Client",
name: str | None = None,
**settings: fastmcp.settings.ServerSettings,
) -> "FastMCPProxy":
"""Create a FastMCP proxy server from a client.
@ -210,3 +212,8 @@ class FastMCPProxy(FastMCP):
logger.info(f"Created server '{server.name}' proxying to client: {client}")
return server
@classmethod
async def from_server(cls, server: FastMCP, **settings: Any) -> "FastMCPProxy":
client = Client(transport=fastmcp.client.transports.FastMCPTransport(server))
return await cls.from_client(client, **settings)

View file

@ -50,11 +50,10 @@ from fastmcp.utilities.logging import configure_logging, get_logger
from fastmcp.utilities.types import Image
if TYPE_CHECKING:
from fastmcp.clients.base import BaseClient
from fastmcp.client import Client
from fastmcp.server.context import Context
from fastmcp.server.openapi import FastMCPOpenAPI
from fastmcp.server.proxy import FastMCPProxy
logger = get_logger(__name__)
@ -117,20 +116,29 @@ class FastMCP(Generic[LifespanResultT]):
def instructions(self) -> str | None:
return self._mcp_server.instructions
def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
async def run_async(self, transport: Literal["stdio", "sse"] | None = None) -> None:
"""Run the FastMCP server asynchronously.
Args:
transport: Transport protocol to use ("stdio" or "sse")
"""
if transport is None:
transport = "stdio"
if transport not in ["stdio", "sse"]:
raise ValueError(f"Unknown transport: {transport}")
if transport == "stdio":
await self.run_stdio_async()
else: # transport == "sse"
await self.run_sse_async()
def run(self, transport: Literal["stdio", "sse"] | None = None) -> None:
"""Run the FastMCP server. Note this is a synchronous function.
Args:
transport: Transport protocol to use ("stdio" or "sse")
"""
TRANSPORTS = Literal["stdio", "sse"]
if transport not in TRANSPORTS.__args__: # type: ignore
raise ValueError(f"Unknown transport: {transport}")
if transport == "stdio":
anyio.run(self.run_stdio_async)
else: # transport == "sse"
anyio.run(self.run_sse_async)
anyio.run(self.run_async, transport)
def _setup_handlers(self) -> None:
"""Set up core MCP protocol handlers."""
@ -547,7 +555,9 @@ class FastMCP(Generic[LifespanResultT]):
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
@classmethod
async def as_proxy(cls, client: "BaseClient", **settings: Any) -> "FastMCPProxy":
async def as_proxy(
cls, client: "Client | FastMCP", **settings: Any
) -> "FastMCPProxy":
"""
Create a FastMCP proxy server from a client.
@ -562,9 +572,18 @@ class FastMCP(Generic[LifespanResultT]):
Returns:
A FastMCP server that proxies requests to the client
"""
from fastmcp.client import Client
from .proxy import FastMCPProxy
return await FastMCPProxy.from_client(client=client, **settings)
if isinstance(client, Client):
return await FastMCPProxy.from_client(client=client, **settings)
elif isinstance(client, FastMCP):
return await FastMCPProxy.from_server(server=client, **settings)
else:
raise ValueError(f"Unknown client type: {type(client)}")
@classmethod
def from_openapi(

View file

@ -3,7 +3,8 @@ from typing import cast
import pytest
from pydantic import AnyUrl
from fastmcp.clients import FastMCPClient
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.server.server import FastMCP
@ -44,7 +45,7 @@ def fastmcp_server():
async def test_list_tools(fastmcp_server):
"""Test listing tools with InMemoryClient."""
client = FastMCPClient(server=fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
async with client:
result = await client.list_tools()
@ -56,7 +57,7 @@ async def test_list_tools(fastmcp_server):
async def test_call_tool(fastmcp_server):
"""Test calling a tool with InMemoryClient."""
client = FastMCPClient(server=fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
async with client:
result = await client.call_tool("greet", {"name": "World"})
@ -68,7 +69,7 @@ async def test_call_tool(fastmcp_server):
async def test_list_resources(fastmcp_server):
"""Test listing resources with InMemoryClient."""
client = FastMCPClient(server=fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
async with client:
result = await client.list_resources()
@ -80,7 +81,7 @@ async def test_list_resources(fastmcp_server):
async def test_list_prompts(fastmcp_server):
"""Test listing prompts with InMemoryClient."""
client = FastMCPClient(server=fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
async with client:
result = await client.list_prompts()
@ -92,7 +93,7 @@ async def test_list_prompts(fastmcp_server):
async def test_get_prompt(fastmcp_server):
"""Test getting a prompt with InMemoryClient."""
client = FastMCPClient(server=fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
async with client:
result = await client.get_prompt("welcome", {"name": "Developer"})
@ -104,7 +105,7 @@ async def test_get_prompt(fastmcp_server):
async def test_read_resource(fastmcp_server):
"""Test reading a resource with InMemoryClient."""
client = FastMCPClient(server=fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
async with client:
# Use the URI from the resource we know exists in our server
@ -122,7 +123,7 @@ async def test_read_resource(fastmcp_server):
async def test_client_connection(fastmcp_server):
"""Test that the client connects and disconnects properly."""
client = FastMCPClient(server=fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
# Before connection
assert not client.is_connected()
@ -137,7 +138,7 @@ async def test_client_connection(fastmcp_server):
async def test_resource_template(fastmcp_server):
"""Test using a resource template with InMemoryClient."""
client = FastMCPClient(server=fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
async with client:
# First, list templates

View file

@ -5,7 +5,8 @@ import pytest
from dirty_equals import Contains
from fastmcp import FastMCP
from fastmcp.clients.fastmcp_client import FastMCPClient
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.server.proxy import FastMCPProxy
USERS = [
@ -62,13 +63,13 @@ def fastmcp_server():
@pytest.fixture
async def proxy_server(fastmcp_server):
"""Fixture that creates a FastMCP proxy server."""
return await FastMCP.as_proxy(FastMCPClient(fastmcp_server))
return await FastMCP.as_proxy(Client(transport=FastMCPTransport(fastmcp_server)))
async def test_create_proxy(fastmcp_server):
"""Test that the proxy server properly forwards requests to the original server."""
# Create a client
client = FastMCPClient(fastmcp_server)
client = Client(transport=FastMCPTransport(fastmcp_server))
server = await FastMCPProxy.from_client(client)

View file

@ -0,0 +1,98 @@
# from pathlib import Path
# from typing import TYPE_CHECKING, Any
# import pytest
# import fastmcp
# from fastmcp import FastMCP
# if TYPE_CHECKING:
# pass
# USERS = [
# {"id": "1", "name": "Alice", "active": True},
# {"id": "2", "name": "Bob", "active": True},
# {"id": "3", "name": "Charlie", "active": False},
# ]
# @pytest.fixture
# def fastmcp_server():
# server = FastMCP("TestServer")
# # --- Tools ---
# @server.tool()
# def greet(name: str) -> str:
# """Greet someone by name."""
# return f"Hello, {name}!"
# @server.tool()
# def add(a: int, b: int) -> int:
# """Add two numbers together."""
# return a + b
# @server.tool()
# def error_tool():
# """This tool always raises an error."""
# raise ValueError("This is a test error")
# # --- Resources ---
# @server.resource(uri="resource://wave")
# def wave() -> str:
# return "👋"
# @server.resource(uri="data://users")
# async def get_users() -> list[dict[str, Any]]:
# return USERS
# @server.resource(uri="data://user/{user_id}")
# async def get_user(user_id: str) -> dict[str, Any] | None:
# return next((user for user in USERS if user["id"] == user_id), None)
# # --- Prompts ---
# @server.prompt()
# def welcome(name: str) -> str:
# return f"Welcome to FastMCP, {name}!"
# return server
# @pytest.fixture
# async def stdio_client():
# # Find the stdio.py script path
# base_dir = Path(__file__).parent
# stdio_script = base_dir / "test_servers" / "stdio.py"
# if not stdio_script.exists():
# raise FileNotFoundError(f"Could not find stdio.py script at {stdio_script}")
# client = fastmcp.Client(
# transport=fastmcp.client.transports.StdioTransport(
# command="python",
# args=[str(stdio_script)],
# )
# )
# async with client:
# print("READY")
# yield client
# print("DONE")
# class TestRunServerStdio:
# async def test_run_server_stdio(
# self, fastmcp_server: FastMCP, stdio_client: fastmcp.Client
# ):
# print("TEST")
# tools = await stdio_client.list_tools()
# print("TEST 2")
# assert tools == 1
# class TestRunServerSSE:
# @pytest.mark.anyio
# async def test_run_server_sse(self, fastmcp_server: FastMCP):
# pass

View file

@ -0,0 +1,58 @@
from typing import Any
from fastmcp import FastMCP
USERS = [
{"id": "1", "name": "Alice", "active": True},
{"id": "2", "name": "Bob", "active": True},
{"id": "3", "name": "Charlie", "active": False},
]
server = FastMCP("TestServer")
# --- Tools ---
@server.tool()
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@server.tool()
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@server.tool()
def error_tool():
"""This tool always raises an error."""
raise ValueError("This is a test error")
# --- Resources ---
@server.resource(uri="resource://wave")
def wave() -> str:
return "👋"
@server.resource(uri="data://users")
async def get_users() -> list[dict[str, Any]]:
return USERS
@server.resource(uri="data://user/{user_id}")
async def get_user(user_id: str) -> dict[str, Any] | None:
return next((user for user in USERS if user["id"] == user_id), None)
# --- Prompts ---
@server.prompt()
def welcome(name: str) -> str:
return f"Welcome to FastMCP, {name}!"

View file

@ -0,0 +1,6 @@
import asyncio
import fastmcp_server
if __name__ == "__main__":
asyncio.run(fastmcp_server.server.run_sse_async())

View file

@ -0,0 +1,6 @@
import asyncio
import fastmcp_server
if __name__ == "__main__":
asyncio.run(fastmcp_server.server.run_stdio_async())