diff --git a/examples/modular_app.py b/examples/modular_app.py deleted file mode 100644 index c920bb2a3..000000000 --- a/examples/modular_app.py +++ /dev/null @@ -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() diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py index 083fc21d9..287058577 100644 --- a/src/fastmcp/__init__.py +++ b/src/fastmcp/__init__.py @@ -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"] diff --git a/src/fastmcp/client/__init__.py b/src/fastmcp/client/__init__.py new file mode 100644 index 000000000..30a9a29a3 --- /dev/null +++ b/src/fastmcp/client/__init__.py @@ -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", +] diff --git a/src/fastmcp/client/base.py b/src/fastmcp/client/base.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/src/fastmcp/client/base.py @@ -0,0 +1 @@ + diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py new file mode 100644 index 000000000..37c924b5c --- /dev/null +++ b/src/fastmcp/client/client.py @@ -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() diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py new file mode 100644 index 000000000..3a81f2ff3 --- /dev/null +++ b/src/fastmcp/client/transports.py @@ -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"" + + +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"" + + +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"" + + +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}") diff --git a/src/fastmcp/clients/__init__.py b/src/fastmcp/clients/__init__.py deleted file mode 100644 index c9d74c25f..000000000 --- a/src/fastmcp/clients/__init__.py +++ /dev/null @@ -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", -] diff --git a/src/fastmcp/clients/base.py b/src/fastmcp/clients/base.py deleted file mode 100644 index 0cc179a04..000000000 --- a/src/fastmcp/clients/base.py +++ /dev/null @@ -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() diff --git a/src/fastmcp/clients/fastmcp_client.py b/src/fastmcp/clients/fastmcp_client.py deleted file mode 100644 index 64f211ea3..000000000 --- a/src/fastmcp/clients/fastmcp_client.py +++ /dev/null @@ -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 diff --git a/src/fastmcp/clients/sse.py b/src/fastmcp/clients/sse.py deleted file mode 100644 index 2cf0570a9..000000000 --- a/src/fastmcp/clients/sse.py +++ /dev/null @@ -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 diff --git a/src/fastmcp/clients/stdio.py b/src/fastmcp/clients/stdio.py deleted file mode 100644 index 1855c3883..000000000 --- a/src/fastmcp/clients/stdio.py +++ /dev/null @@ -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 diff --git a/src/fastmcp/clients/websocket.py b/src/fastmcp/clients/websocket.py deleted file mode 100644 index 8f1edc02c..000000000 --- a/src/fastmcp/clients/websocket.py +++ /dev/null @@ -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 diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 2764aa943..0b3311251 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -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) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 2e45e7d69..80c6b9dd7 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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( diff --git a/tests/clients/__init__.py b/tests/client/__init__.py similarity index 100% rename from tests/clients/__init__.py rename to tests/client/__init__.py diff --git a/tests/clients/test_fastmcp_client.py b/tests/client/test_fastmcp_transport.py similarity index 87% rename from tests/clients/test_fastmcp_client.py rename to tests/client/test_fastmcp_transport.py index 813f0df2e..bf097cc2b 100644 --- a/tests/clients/test_fastmcp_client.py +++ b/tests/client/test_fastmcp_transport.py @@ -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 diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 895a774b8..bd9ce2a9a 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -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) diff --git a/tests/server/test_run_server.py b/tests/server/test_run_server.py new file mode 100644 index 000000000..fb936b578 --- /dev/null +++ b/tests/server/test_run_server.py @@ -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 diff --git a/tests/server/test_servers/fastmcp_server.py b/tests/server/test_servers/fastmcp_server.py new file mode 100644 index 000000000..f24bbfeef --- /dev/null +++ b/tests/server/test_servers/fastmcp_server.py @@ -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}!" diff --git a/tests/server/test_servers/sse.py b/tests/server/test_servers/sse.py new file mode 100644 index 000000000..6ada18c33 --- /dev/null +++ b/tests/server/test_servers/sse.py @@ -0,0 +1,6 @@ +import asyncio + +import fastmcp_server + +if __name__ == "__main__": + asyncio.run(fastmcp_server.server.run_sse_async()) diff --git a/tests/server/test_servers/stdio.py b/tests/server/test_servers/stdio.py new file mode 100644 index 000000000..00f3796cf --- /dev/null +++ b/tests/server/test_servers/stdio.py @@ -0,0 +1,6 @@ +import asyncio + +import fastmcp_server + +if __name__ == "__main__": + asyncio.run(fastmcp_server.server.run_stdio_async())