Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
3acddf3
1
Parent(s): 59c76e7
Reorganize clients and transports
Browse files- examples/modular_app.py +0 -150
- src/fastmcp/__init__.py +4 -3
- src/fastmcp/client/__init__.py +25 -0
- src/fastmcp/client/base.py +1 -0
- src/fastmcp/{clients/base.py → client/client.py} +60 -100
- src/fastmcp/client/transports.py +415 -0
- src/fastmcp/clients/__init__.py +0 -12
- src/fastmcp/clients/fastmcp_client.py +0 -50
- src/fastmcp/clients/sse.py +0 -32
- src/fastmcp/clients/stdio.py +0 -134
- src/fastmcp/clients/websocket.py +0 -31
- src/fastmcp/server/proxy.py +19 -12
- src/fastmcp/server/server.py +29 -10
- tests/{clients → client}/__init__.py +0 -0
- tests/{clients/test_fastmcp_client.py → client/test_fastmcp_transport.py} +10 -9
- tests/server/test_proxy.py +4 -3
- tests/server/test_run_server.py +98 -0
- tests/server/test_servers/fastmcp_server.py +58 -0
- tests/server/test_servers/sse.py +6 -0
- tests/server/test_servers/stdio.py +6 -0
examples/modular_app.py
DELETED
|
@@ -1,150 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Modular FastMCP Application Example
|
| 3 |
-
|
| 4 |
-
This example demonstrates building a modular application with FastMCP
|
| 5 |
-
by separating functionality into domain-specific modules.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import asyncio
|
| 9 |
-
from pathlib import Path
|
| 10 |
-
from typing import Any, Dict, List
|
| 11 |
-
|
| 12 |
-
from fastmcp import Context, FastMCP
|
| 13 |
-
|
| 14 |
-
# ----- DATA MODULE -----
|
| 15 |
-
data_app = FastMCP("Data Module")
|
| 16 |
-
|
| 17 |
-
# Simulated database
|
| 18 |
-
users_db = [
|
| 19 |
-
{"id": 1, "name": "Alice", "email": "alice@example.com"},
|
| 20 |
-
{"id": 2, "name": "Bob", "email": "bob@example.com"},
|
| 21 |
-
{"id": 3, "name": "Charlie", "email": "charlie@example.com"},
|
| 22 |
-
]
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
@data_app.resource("users://all")
|
| 26 |
-
def get_all_users() -> List[Dict[str, Any]]:
|
| 27 |
-
"""Get all users in the database"""
|
| 28 |
-
return users_db
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
@data_app.resource("users://{user_id}")
|
| 32 |
-
def get_user_by_id(user_id: str) -> dict[str, Any] | None:
|
| 33 |
-
"""Get a specific user by ID"""
|
| 34 |
-
user_id_int = int(user_id)
|
| 35 |
-
for user in users_db:
|
| 36 |
-
if user["id"] == user_id_int:
|
| 37 |
-
return user
|
| 38 |
-
return None
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
@data_app.tool()
|
| 42 |
-
async def create_user(name: str, email: str, ctx: Context) -> Dict[str, Any]:
|
| 43 |
-
"""Add a new user to the database"""
|
| 44 |
-
# Simulate a slow operation
|
| 45 |
-
await ctx.info(f"Creating user {name}...")
|
| 46 |
-
await asyncio.sleep(1)
|
| 47 |
-
|
| 48 |
-
# Create user
|
| 49 |
-
new_id = max(user["id"] for user in users_db) + 1
|
| 50 |
-
new_user = {"id": new_id, "name": name, "email": email}
|
| 51 |
-
users_db.append(new_user)
|
| 52 |
-
|
| 53 |
-
await ctx.info(f"User created with ID {new_id}")
|
| 54 |
-
return new_user
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
# ----- ANALYTICS MODULE -----
|
| 58 |
-
analytics_app = FastMCP("Analytics Module")
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
@analytics_app.tool()
|
| 62 |
-
async def analyze_users(ctx: Context) -> Dict[str, Any]:
|
| 63 |
-
"""Run analytics on user data"""
|
| 64 |
-
# Get user data from the data module
|
| 65 |
-
users = await ctx.read_resource("data:users://all")
|
| 66 |
-
|
| 67 |
-
# Perform analytics
|
| 68 |
-
await ctx.info("Analyzing user data...")
|
| 69 |
-
await asyncio.sleep(1)
|
| 70 |
-
|
| 71 |
-
# Return analytics results
|
| 72 |
-
return {
|
| 73 |
-
"total_users": len(users),
|
| 74 |
-
"domains": {user["email"].split("@")[1] for user in users},
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
@analytics_app.resource("analytics://summary")
|
| 79 |
-
def get_analytics_summary() -> Dict[str, Any]:
|
| 80 |
-
"""Get a summary of analytics data"""
|
| 81 |
-
return {"active_users": len(users_db), "last_updated": "2023-06-01"}
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
# ----- FILESYSTEM MODULE -----
|
| 85 |
-
files_app = FastMCP("Filesystem Module")
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
@files_app.resource("files://desktop")
|
| 89 |
-
def list_desktop_files() -> List[str]:
|
| 90 |
-
"""List files on the user's desktop"""
|
| 91 |
-
desktop = Path.home() / "Desktop"
|
| 92 |
-
return [f.name for f in desktop.iterdir() if f.is_file()]
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
@files_app.tool()
|
| 96 |
-
async def search_files(query: str, ctx: Context) -> List[str]:
|
| 97 |
-
"""Search for files matching a query"""
|
| 98 |
-
await ctx.info(f"Searching for files matching '{query}'...")
|
| 99 |
-
|
| 100 |
-
# Simulate a file search
|
| 101 |
-
desktop = Path.home() / "Desktop"
|
| 102 |
-
files = [
|
| 103 |
-
f.name
|
| 104 |
-
for f in desktop.iterdir()
|
| 105 |
-
if f.is_file() and query.lower() in f.name.lower()
|
| 106 |
-
]
|
| 107 |
-
|
| 108 |
-
await ctx.info(f"Found {len(files)} matching files")
|
| 109 |
-
return files
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
# ----- MAIN APPLICATION -----
|
| 113 |
-
# Create the main application that combines all modules
|
| 114 |
-
main_app = FastMCP("Modular FastMCP Demo")
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
@main_app.tool()
|
| 118 |
-
async def get_system_info(ctx: Context) -> Dict[str, Any]:
|
| 119 |
-
"""Get comprehensive system information"""
|
| 120 |
-
await ctx.info("Gathering system information...")
|
| 121 |
-
|
| 122 |
-
# Use the mounted modules to gather info
|
| 123 |
-
users = await ctx.read_resource("data:users://all")
|
| 124 |
-
analytics = await ctx.read_resource("analytics:analytics://summary")
|
| 125 |
-
desktop_files = await ctx.read_resource("files:files://desktop")
|
| 126 |
-
|
| 127 |
-
return {
|
| 128 |
-
"users": {"count": len(users), "names": [user["name"] for user in users]},
|
| 129 |
-
"analytics": analytics,
|
| 130 |
-
"files": {"desktop_count": len(desktop_files)},
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
# Mount all modules to the main app
|
| 135 |
-
main_app.mount("data", data_app)
|
| 136 |
-
main_app.mount("analytics", analytics_app)
|
| 137 |
-
main_app.mount("files", files_app)
|
| 138 |
-
|
| 139 |
-
if __name__ == "__main__":
|
| 140 |
-
# Now register resources (which requires async)
|
| 141 |
-
async def initialize_resources():
|
| 142 |
-
await main_app.register_all_mounted_resources()
|
| 143 |
-
print("Resources registered successfully!")
|
| 144 |
-
|
| 145 |
-
# Initialize resources
|
| 146 |
-
asyncio.run(initialize_resources())
|
| 147 |
-
|
| 148 |
-
# Start the server
|
| 149 |
-
print("Starting modular FastMCP application...")
|
| 150 |
-
main_app.run()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/__init__.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
"""FastMCP - An ergonomic MCP interface."""
|
| 2 |
|
| 3 |
from importlib.metadata import version
|
| 4 |
-
|
| 5 |
|
| 6 |
from fastmcp.server.server import FastMCP
|
| 7 |
from fastmcp.server.context import Context
|
| 8 |
-
from . import
|
|
|
|
| 9 |
|
| 10 |
__version__ = version("fastmcp")
|
| 11 |
-
__all__ = ["FastMCP", "Context", "
|
|
|
|
| 1 |
"""FastMCP - An ergonomic MCP interface."""
|
| 2 |
|
| 3 |
from importlib.metadata import version
|
| 4 |
+
|
| 5 |
|
| 6 |
from fastmcp.server.server import FastMCP
|
| 7 |
from fastmcp.server.context import Context
|
| 8 |
+
from fastmcp.client import Client
|
| 9 |
+
from . import client, settings
|
| 10 |
|
| 11 |
__version__ = version("fastmcp")
|
| 12 |
+
__all__ = ["FastMCP", "Context", "client", "settings"]
|
src/fastmcp/client/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .client import Client
|
| 2 |
+
from .transports import (
|
| 3 |
+
ClientTransport,
|
| 4 |
+
WSTransport,
|
| 5 |
+
SSETransport,
|
| 6 |
+
StdioTransport,
|
| 7 |
+
PythonStdioTransport,
|
| 8 |
+
NodeStdioTransport,
|
| 9 |
+
UvxStdioTransport,
|
| 10 |
+
NpxStdioTransport,
|
| 11 |
+
FastMCPTransport,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
"Client",
|
| 16 |
+
"ClientTransport",
|
| 17 |
+
"WSTransport",
|
| 18 |
+
"SSETransport",
|
| 19 |
+
"StdioTransport",
|
| 20 |
+
"PythonStdioTransport",
|
| 21 |
+
"NodeStdioTransport",
|
| 22 |
+
"UvxStdioTransport",
|
| 23 |
+
"NpxStdioTransport",
|
| 24 |
+
"FastMCPTransport",
|
| 25 |
+
]
|
src/fastmcp/client/base.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
src/fastmcp/{clients/base.py → client/client.py}
RENAMED
|
@@ -1,14 +1,22 @@
|
|
| 1 |
-
import abc
|
| 2 |
-
import contextlib
|
| 3 |
import datetime
|
| 4 |
-
from
|
|
|
|
| 5 |
|
| 6 |
import mcp.types
|
| 7 |
from mcp import ClientSession
|
| 8 |
-
from mcp.client.session import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from mcp.shared.context import LifespanContextT, RequestContext
|
| 10 |
from pydantic import AnyUrl
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def _get_roots_callback(roots: list[mcp.types.Root]) -> ListRootsFnT | None:
|
| 14 |
async def _roots_callback(
|
|
@@ -19,26 +27,18 @@ def _get_roots_callback(roots: list[mcp.types.Root]) -> ListRootsFnT | None:
|
|
| 19 |
return _roots_callback
|
| 20 |
|
| 21 |
|
| 22 |
-
class
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
list_roots_callback: ListRootsFnT | None
|
| 26 |
-
logging_callback: LoggingFnT | None
|
| 27 |
-
message_handler: MessageHandlerFnT | None
|
| 28 |
-
read_timeout_seconds: datetime.timedelta | None
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
class SessionKwargs(TypedDict, total=False):
|
| 32 |
-
sampling_callback: SamplingFnT | None
|
| 33 |
-
list_roots_callback: ListRootsFnT | None
|
| 34 |
-
logging_callback: LoggingFnT | None
|
| 35 |
-
message_handler: MessageHandlerFnT | None
|
| 36 |
-
read_timeout_seconds: datetime.timedelta | None
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
class BaseClient(abc.ABC):
|
| 40 |
def __init__(
|
| 41 |
self,
|
|
|
|
|
|
|
| 42 |
roots: list[mcp.types.Root] | None = None,
|
| 43 |
sampling_callback: SamplingFnT | None = None,
|
| 44 |
list_roots_callback: ListRootsFnT | None = None,
|
|
@@ -46,109 +46,69 @@ class BaseClient(abc.ABC):
|
|
| 46 |
message_handler: MessageHandlerFnT | None = None,
|
| 47 |
read_timeout_seconds: datetime.timedelta | None = None,
|
| 48 |
):
|
| 49 |
-
self.
|
| 50 |
self._session: ClientSession | None = None
|
| 51 |
-
self.
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
)
|
| 59 |
-
else:
|
| 60 |
-
list_roots_callback = _get_roots_callback(roots)
|
| 61 |
-
|
| 62 |
-
self._sampling_callback = sampling_callback
|
| 63 |
-
self._list_roots_callback = list_roots_callback
|
| 64 |
-
self._logging_callback = logging_callback
|
| 65 |
-
self._message_handler = message_handler
|
| 66 |
-
self._read_timeout_seconds = read_timeout_seconds
|
| 67 |
-
|
| 68 |
-
def _session_kwargs(self) -> SessionKwargs:
|
| 69 |
-
return SessionKwargs(
|
| 70 |
-
sampling_callback=self._sampling_callback,
|
| 71 |
-
list_roots_callback=self._list_roots_callback,
|
| 72 |
-
logging_callback=self._logging_callback,
|
| 73 |
-
message_handler=self._message_handler,
|
| 74 |
-
read_timeout_seconds=self._read_timeout_seconds,
|
| 75 |
)
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
return self._transport
|
| 85 |
|
| 86 |
@property
|
| 87 |
-
def session(self):
|
| 88 |
-
"""Get the current session"""
|
| 89 |
if self._session is None:
|
| 90 |
raise RuntimeError(
|
| 91 |
"Client is not connected. Use 'async with client:' context manager first."
|
| 92 |
)
|
| 93 |
return self._session
|
| 94 |
|
| 95 |
-
def is_connected(self):
|
| 96 |
-
"""Check if the client is currently connected"""
|
| 97 |
return self._session is not None
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
This will be called by __aenter__ to establish the connection."""
|
| 103 |
-
raise NotImplementedError("Subclasses must implement this method")
|
| 104 |
-
|
| 105 |
-
@contextlib.asynccontextmanager
|
| 106 |
-
async def _create_connection_context(self):
|
| 107 |
-
"""Create and manage the connection context if not already connected.
|
| 108 |
-
This handles both creating a new connection or reusing an existing one."""
|
| 109 |
-
created_connection = False
|
| 110 |
-
try:
|
| 111 |
-
if not self.is_connected():
|
| 112 |
-
# Only create a new connection if not already connected
|
| 113 |
-
self._cm = self._connect()
|
| 114 |
-
await self._cm.__aenter__()
|
| 115 |
-
created_connection = True
|
| 116 |
-
yield
|
| 117 |
-
finally:
|
| 118 |
-
if created_connection and self._cm is not None:
|
| 119 |
-
# Only close if we created the connection in this context
|
| 120 |
-
await self._cm.__aexit__(None, None, None)
|
| 121 |
-
self._transport = None
|
| 122 |
-
self._session = None
|
| 123 |
-
self._cm = None
|
| 124 |
-
|
| 125 |
-
@contextlib.asynccontextmanager
|
| 126 |
-
async def _set_session(self, transport: Any, session: ClientSession):
|
| 127 |
-
self._transport = transport
|
| 128 |
-
self._session = session
|
| 129 |
try:
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
| 134 |
self._session = None
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
return self
|
| 140 |
|
| 141 |
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
# --- MCP Client Methods ---
|
| 145 |
-
|
| 146 |
async def ping(self) -> None:
|
| 147 |
"""Send a ping request."""
|
| 148 |
await self.session.send_ping()
|
| 149 |
|
| 150 |
async def progress(
|
| 151 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 152 |
) -> None:
|
| 153 |
"""Send a progress notification."""
|
| 154 |
await self.session.send_progress_notification(progress_token, progress, total)
|
|
@@ -168,7 +128,7 @@ class BaseClient(abc.ABC):
|
|
| 168 |
async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
|
| 169 |
"""Send a resources/read request."""
|
| 170 |
if isinstance(uri, str):
|
| 171 |
-
uri = AnyUrl(uri)
|
| 172 |
return await self.session.read_resource(uri)
|
| 173 |
|
| 174 |
async def subscribe_resource(self, uri: AnyUrl | str) -> None:
|
|
|
|
|
|
|
|
|
|
| 1 |
import datetime
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Any, AsyncContextManager
|
| 4 |
|
| 5 |
import mcp.types
|
| 6 |
from mcp import ClientSession
|
| 7 |
+
from mcp.client.session import (
|
| 8 |
+
ListRootsFnT,
|
| 9 |
+
LoggingFnT,
|
| 10 |
+
MessageHandlerFnT,
|
| 11 |
+
SamplingFnT,
|
| 12 |
+
)
|
| 13 |
from mcp.shared.context import LifespanContextT, RequestContext
|
| 14 |
from pydantic import AnyUrl
|
| 15 |
|
| 16 |
+
from fastmcp.server import FastMCP
|
| 17 |
+
|
| 18 |
+
from .transports import ClientTransport, SessionKwargs, infer_transport
|
| 19 |
+
|
| 20 |
|
| 21 |
def _get_roots_callback(roots: list[mcp.types.Root]) -> ListRootsFnT | None:
|
| 22 |
async def _roots_callback(
|
|
|
|
| 27 |
return _roots_callback
|
| 28 |
|
| 29 |
|
| 30 |
+
class Client:
|
| 31 |
+
"""
|
| 32 |
+
MCP client that delegates connection management to a Transport instance.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
The Client class is primarily concerned with MCP protocol logic,
|
| 35 |
+
while the Transport handles connection establishment and management.
|
| 36 |
+
"""
|
| 37 |
|
|
|
|
| 38 |
def __init__(
|
| 39 |
self,
|
| 40 |
+
transport: ClientTransport | FastMCP | AnyUrl | Path | str,
|
| 41 |
+
# Common args
|
| 42 |
roots: list[mcp.types.Root] | None = None,
|
| 43 |
sampling_callback: SamplingFnT | None = None,
|
| 44 |
list_roots_callback: ListRootsFnT | None = None,
|
|
|
|
| 46 |
message_handler: MessageHandlerFnT | None = None,
|
| 47 |
read_timeout_seconds: datetime.timedelta | None = None,
|
| 48 |
):
|
| 49 |
+
self.transport = infer_transport(transport)
|
| 50 |
self._session: ClientSession | None = None
|
| 51 |
+
self._session_cm: AsyncContextManager[ClientSession] | None = None
|
| 52 |
+
|
| 53 |
+
# Store common kwargs to pass to transport.connect_session
|
| 54 |
+
if roots is not None and list_roots_callback is not None:
|
| 55 |
+
raise ValueError("Cannot provide both `roots` and `list_roots_callback`.")
|
| 56 |
+
resolved_list_roots_callback = list_roots_callback or (
|
| 57 |
+
_get_roots_callback(roots) if roots else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
)
|
| 59 |
|
| 60 |
+
self._session_kwargs: SessionKwargs = {
|
| 61 |
+
"sampling_callback": sampling_callback,
|
| 62 |
+
"list_roots_callback": resolved_list_roots_callback,
|
| 63 |
+
"logging_callback": logging_callback,
|
| 64 |
+
"message_handler": message_handler,
|
| 65 |
+
"read_timeout_seconds": read_timeout_seconds,
|
| 66 |
+
}
|
|
|
|
| 67 |
|
| 68 |
@property
|
| 69 |
+
def session(self) -> ClientSession:
|
| 70 |
+
"""Get the current active session. Raises RuntimeError if not connected."""
|
| 71 |
if self._session is None:
|
| 72 |
raise RuntimeError(
|
| 73 |
"Client is not connected. Use 'async with client:' context manager first."
|
| 74 |
)
|
| 75 |
return self._session
|
| 76 |
|
| 77 |
+
def is_connected(self) -> bool:
|
| 78 |
+
"""Check if the client is currently connected."""
|
| 79 |
return self._session is not None
|
| 80 |
|
| 81 |
+
async def __aenter__(self):
|
| 82 |
+
if self.is_connected():
|
| 83 |
+
raise RuntimeError("Client is already connected in an async context.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
try:
|
| 85 |
+
self._session_cm = self.transport.connect_session(**self._session_kwargs)
|
| 86 |
+
self._session = await self._session_cm.__aenter__()
|
| 87 |
+
return self
|
| 88 |
+
except Exception as e:
|
| 89 |
+
# Ensure cleanup if __aenter__ fails partially
|
| 90 |
self._session = None
|
| 91 |
+
self._session_cm = None
|
| 92 |
+
raise ConnectionError(
|
| 93 |
+
f"Failed to connect using {self.transport}: {e}"
|
| 94 |
+
) from e
|
|
|
|
| 95 |
|
| 96 |
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 97 |
+
if self._session_cm:
|
| 98 |
+
await self._session_cm.__aexit__(exc_type, exc_val, exc_tb)
|
| 99 |
+
self._session = None
|
| 100 |
+
self._session_cm = None
|
| 101 |
|
| 102 |
# --- MCP Client Methods ---
|
|
|
|
| 103 |
async def ping(self) -> None:
|
| 104 |
"""Send a ping request."""
|
| 105 |
await self.session.send_ping()
|
| 106 |
|
| 107 |
async def progress(
|
| 108 |
+
self,
|
| 109 |
+
progress_token: str | int,
|
| 110 |
+
progress: float,
|
| 111 |
+
total: float | None = None,
|
| 112 |
) -> None:
|
| 113 |
"""Send a progress notification."""
|
| 114 |
await self.session.send_progress_notification(progress_token, progress, total)
|
|
|
|
| 128 |
async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
|
| 129 |
"""Send a resources/read request."""
|
| 130 |
if isinstance(uri, str):
|
| 131 |
+
uri = AnyUrl(uri) # Ensure AnyUrl
|
| 132 |
return await self.session.read_resource(uri)
|
| 133 |
|
| 134 |
async def subscribe_resource(self, uri: AnyUrl | str) -> None:
|
src/fastmcp/client/transports.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import abc
|
| 2 |
+
import contextlib
|
| 3 |
+
import datetime
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import (
|
| 7 |
+
AsyncIterator,
|
| 8 |
+
Dict,
|
| 9 |
+
List,
|
| 10 |
+
Optional,
|
| 11 |
+
TypedDict,
|
| 12 |
+
Union,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
from mcp import ClientSession, StdioServerParameters
|
| 16 |
+
from mcp.client.session import (
|
| 17 |
+
ListRootsFnT,
|
| 18 |
+
LoggingFnT,
|
| 19 |
+
MessageHandlerFnT,
|
| 20 |
+
SamplingFnT,
|
| 21 |
+
)
|
| 22 |
+
from mcp.client.sse import sse_client
|
| 23 |
+
from mcp.client.stdio import stdio_client
|
| 24 |
+
from mcp.client.websocket import websocket_client
|
| 25 |
+
from mcp.shared.memory import create_connected_server_and_client_session
|
| 26 |
+
from pydantic import AnyUrl
|
| 27 |
+
from typing_extensions import Unpack
|
| 28 |
+
|
| 29 |
+
from fastmcp.server import FastMCP as FastMCPServer
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class SessionKwargs(TypedDict, total=False):
|
| 33 |
+
"""Keyword arguments for the MCP ClientSession constructor."""
|
| 34 |
+
|
| 35 |
+
sampling_callback: SamplingFnT | None
|
| 36 |
+
list_roots_callback: ListRootsFnT | None
|
| 37 |
+
logging_callback: LoggingFnT | None
|
| 38 |
+
message_handler: MessageHandlerFnT | None
|
| 39 |
+
read_timeout_seconds: datetime.timedelta | None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ClientTransport(abc.ABC):
|
| 43 |
+
"""
|
| 44 |
+
Abstract base class for different MCP client transport mechanisms.
|
| 45 |
+
|
| 46 |
+
A Transport is responsible for establishing and managing connections
|
| 47 |
+
to an MCP server, and providing a ClientSession within an async context.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
@abc.abstractmethod
|
| 51 |
+
@contextlib.asynccontextmanager
|
| 52 |
+
async def connect_session(
|
| 53 |
+
self, **session_kwargs: Unpack[SessionKwargs]
|
| 54 |
+
) -> AsyncIterator[ClientSession]:
|
| 55 |
+
"""
|
| 56 |
+
Establishes a connection and yields an active, initialized ClientSession.
|
| 57 |
+
|
| 58 |
+
The session is guaranteed to be valid only within the scope of the
|
| 59 |
+
async context manager. Connection setup and teardown are handled
|
| 60 |
+
within this context.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
**session_kwargs: Keyword arguments to pass to the ClientSession
|
| 64 |
+
constructor (e.g., callbacks, timeouts).
|
| 65 |
+
|
| 66 |
+
Yields:
|
| 67 |
+
An initialized mcp.ClientSession instance.
|
| 68 |
+
"""
|
| 69 |
+
raise NotImplementedError
|
| 70 |
+
yield None # type: ignore
|
| 71 |
+
|
| 72 |
+
def __repr__(self) -> str:
|
| 73 |
+
# Basic representation for subclasses
|
| 74 |
+
return f"<{self.__class__.__name__}>"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class WSTransport(ClientTransport):
|
| 78 |
+
"""Transport implementation that connects to an MCP server via WebSockets."""
|
| 79 |
+
|
| 80 |
+
def __init__(self, url: str | AnyUrl):
|
| 81 |
+
if isinstance(url, AnyUrl):
|
| 82 |
+
url = str(url)
|
| 83 |
+
if not isinstance(url, str) or not url.startswith("ws"):
|
| 84 |
+
raise ValueError("Invalid WebSocket URL provided.")
|
| 85 |
+
self.url = url
|
| 86 |
+
|
| 87 |
+
@contextlib.asynccontextmanager
|
| 88 |
+
async def connect_session(
|
| 89 |
+
self, **session_kwargs: Unpack[SessionKwargs]
|
| 90 |
+
) -> AsyncIterator[ClientSession]:
|
| 91 |
+
async with websocket_client(self.url) as transport:
|
| 92 |
+
read_stream, write_stream = transport
|
| 93 |
+
async with ClientSession(
|
| 94 |
+
read_stream, write_stream, **session_kwargs
|
| 95 |
+
) as session:
|
| 96 |
+
await session.initialize() # Initialize after session creation
|
| 97 |
+
yield session
|
| 98 |
+
|
| 99 |
+
def __repr__(self) -> str:
|
| 100 |
+
return f"<WebSocket(url='{self.url}')>"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class SSETransport(ClientTransport):
|
| 104 |
+
"""Transport implementation that connects to an MCP server via Server-Sent Events."""
|
| 105 |
+
|
| 106 |
+
def __init__(self, url: str | AnyUrl, headers: Optional[Dict[str, str]] = None):
|
| 107 |
+
if isinstance(url, AnyUrl):
|
| 108 |
+
url = str(url)
|
| 109 |
+
if not isinstance(url, str) or not url.startswith("http"):
|
| 110 |
+
raise ValueError("Invalid HTTP/S URL provided for SSE.")
|
| 111 |
+
self.url = url
|
| 112 |
+
self.headers = headers or {}
|
| 113 |
+
|
| 114 |
+
@contextlib.asynccontextmanager
|
| 115 |
+
async def connect_session(
|
| 116 |
+
self, **session_kwargs: Unpack[SessionKwargs]
|
| 117 |
+
) -> AsyncIterator[ClientSession]:
|
| 118 |
+
async with sse_client(self.url, headers=self.headers) as transport:
|
| 119 |
+
read_stream, write_stream = transport
|
| 120 |
+
async with ClientSession(
|
| 121 |
+
read_stream, write_stream, **session_kwargs
|
| 122 |
+
) as session:
|
| 123 |
+
await session.initialize()
|
| 124 |
+
yield session
|
| 125 |
+
|
| 126 |
+
def __repr__(self) -> str:
|
| 127 |
+
return f"<SSE(url='{self.url}')>"
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class StdioTransport(ClientTransport):
|
| 131 |
+
"""
|
| 132 |
+
Base transport for connecting to an MCP server via subprocess with stdio.
|
| 133 |
+
|
| 134 |
+
This is a base class that can be subclassed for specific command-based
|
| 135 |
+
transports like Python, Node, Uvx, etc.
|
| 136 |
+
"""
|
| 137 |
+
|
| 138 |
+
def __init__(
|
| 139 |
+
self,
|
| 140 |
+
command: str,
|
| 141 |
+
args: List[str],
|
| 142 |
+
env: Optional[Dict[str, str]] = None,
|
| 143 |
+
cwd: Optional[str] = None,
|
| 144 |
+
):
|
| 145 |
+
"""
|
| 146 |
+
Initialize a Stdio transport.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
command: The command to run (e.g., "python", "node", "uvx")
|
| 150 |
+
args: The arguments to pass to the command
|
| 151 |
+
env: Environment variables to set for the subprocess
|
| 152 |
+
cwd: Current working directory for the subprocess
|
| 153 |
+
"""
|
| 154 |
+
self.command = command
|
| 155 |
+
self.args = args
|
| 156 |
+
self.env = env
|
| 157 |
+
self.cwd = cwd
|
| 158 |
+
|
| 159 |
+
@contextlib.asynccontextmanager
|
| 160 |
+
async def connect_session(
|
| 161 |
+
self, **session_kwargs: Unpack[SessionKwargs]
|
| 162 |
+
) -> AsyncIterator[ClientSession]:
|
| 163 |
+
server_params = StdioServerParameters(
|
| 164 |
+
command=self.command, args=self.args, env=self.env, cwd=self.cwd
|
| 165 |
+
)
|
| 166 |
+
async with stdio_client(server_params) as transport:
|
| 167 |
+
read_stream, write_stream = transport
|
| 168 |
+
async with ClientSession(
|
| 169 |
+
read_stream, write_stream, **session_kwargs
|
| 170 |
+
) as session:
|
| 171 |
+
await session.initialize()
|
| 172 |
+
yield session
|
| 173 |
+
|
| 174 |
+
def __repr__(self) -> str:
|
| 175 |
+
return (
|
| 176 |
+
f"<{self.__class__.__name__}(command='{self.command}', args={self.args})>"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
class PythonStdioTransport(StdioTransport):
|
| 181 |
+
"""Transport for running Python scripts."""
|
| 182 |
+
|
| 183 |
+
def __init__(
|
| 184 |
+
self,
|
| 185 |
+
script_path: Union[str, Path],
|
| 186 |
+
args: Optional[List[str]] = None,
|
| 187 |
+
env: Optional[Dict[str, str]] = None,
|
| 188 |
+
cwd: Optional[str] = None,
|
| 189 |
+
python_cmd: str = "python",
|
| 190 |
+
):
|
| 191 |
+
"""
|
| 192 |
+
Initialize a Python transport.
|
| 193 |
+
|
| 194 |
+
Args:
|
| 195 |
+
script_path: Path to the Python script to run
|
| 196 |
+
args: Additional arguments to pass to the script
|
| 197 |
+
env: Environment variables to set for the subprocess
|
| 198 |
+
cwd: Current working directory for the subprocess
|
| 199 |
+
python_cmd: Python command to use (default: "python")
|
| 200 |
+
"""
|
| 201 |
+
script_path = Path(script_path).resolve()
|
| 202 |
+
if not script_path.is_file():
|
| 203 |
+
raise FileNotFoundError(f"Script not found: {script_path}")
|
| 204 |
+
if not str(script_path).endswith(".py"):
|
| 205 |
+
raise ValueError(f"Not a Python script: {script_path}")
|
| 206 |
+
|
| 207 |
+
full_args = [str(script_path)]
|
| 208 |
+
if args:
|
| 209 |
+
full_args.extend(args)
|
| 210 |
+
|
| 211 |
+
super().__init__(command=python_cmd, args=full_args, env=env, cwd=cwd)
|
| 212 |
+
self.script_path = script_path
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class NodeStdioTransport(StdioTransport):
|
| 216 |
+
"""Transport for running Node.js scripts."""
|
| 217 |
+
|
| 218 |
+
def __init__(
|
| 219 |
+
self,
|
| 220 |
+
script_path: Union[str, Path],
|
| 221 |
+
args: Optional[List[str]] = None,
|
| 222 |
+
env: Optional[Dict[str, str]] = None,
|
| 223 |
+
cwd: Optional[str] = None,
|
| 224 |
+
node_cmd: str = "node",
|
| 225 |
+
):
|
| 226 |
+
"""
|
| 227 |
+
Initialize a Node transport.
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
script_path: Path to the Node.js script to run
|
| 231 |
+
args: Additional arguments to pass to the script
|
| 232 |
+
env: Environment variables to set for the subprocess
|
| 233 |
+
cwd: Current working directory for the subprocess
|
| 234 |
+
node_cmd: Node.js command to use (default: "node")
|
| 235 |
+
"""
|
| 236 |
+
script_path = Path(script_path).resolve()
|
| 237 |
+
if not script_path.is_file():
|
| 238 |
+
raise FileNotFoundError(f"Script not found: {script_path}")
|
| 239 |
+
if not str(script_path).endswith(".js"):
|
| 240 |
+
raise ValueError(f"Not a JavaScript script: {script_path}")
|
| 241 |
+
|
| 242 |
+
full_args = [str(script_path)]
|
| 243 |
+
if args:
|
| 244 |
+
full_args.extend(args)
|
| 245 |
+
|
| 246 |
+
super().__init__(command=node_cmd, args=full_args, env=env, cwd=cwd)
|
| 247 |
+
self.script_path = script_path
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
class UvxStdioTransport(StdioTransport):
|
| 251 |
+
"""Transport for running commands via the uvx tool."""
|
| 252 |
+
|
| 253 |
+
def __init__(
|
| 254 |
+
self,
|
| 255 |
+
tool_name: str,
|
| 256 |
+
tool_args: Optional[List[str]] = None,
|
| 257 |
+
project_directory: Optional[str] = None,
|
| 258 |
+
python_version: Optional[str] = None,
|
| 259 |
+
with_packages: Optional[List[str]] = None,
|
| 260 |
+
from_package: Optional[str] = None,
|
| 261 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 262 |
+
):
|
| 263 |
+
"""
|
| 264 |
+
Initialize a Uvx transport.
|
| 265 |
+
|
| 266 |
+
Args:
|
| 267 |
+
tool_name: Name of the tool to run via uvx
|
| 268 |
+
tool_args: Arguments to pass to the tool
|
| 269 |
+
project_directory: Project directory (for package resolution)
|
| 270 |
+
python_version: Python version to use
|
| 271 |
+
with_packages: Additional packages to include
|
| 272 |
+
from_package: Package to install the tool from
|
| 273 |
+
env_vars: Additional environment variables
|
| 274 |
+
"""
|
| 275 |
+
# Basic validation
|
| 276 |
+
if project_directory and not Path(project_directory).exists():
|
| 277 |
+
raise NotADirectoryError(
|
| 278 |
+
f"Project directory not found: {project_directory}"
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
# Build uvx arguments
|
| 282 |
+
uvx_args = []
|
| 283 |
+
if python_version:
|
| 284 |
+
uvx_args.extend(["--python", python_version])
|
| 285 |
+
if from_package:
|
| 286 |
+
uvx_args.extend(["--from", from_package])
|
| 287 |
+
for pkg in with_packages or []:
|
| 288 |
+
uvx_args.extend(["--with", pkg])
|
| 289 |
+
|
| 290 |
+
# Add the tool name and tool args
|
| 291 |
+
uvx_args.append(tool_name)
|
| 292 |
+
if tool_args:
|
| 293 |
+
uvx_args.extend(tool_args)
|
| 294 |
+
|
| 295 |
+
# Get environment with any additional variables
|
| 296 |
+
env = None
|
| 297 |
+
if env_vars:
|
| 298 |
+
env = os.environ.copy()
|
| 299 |
+
env.update(env_vars)
|
| 300 |
+
|
| 301 |
+
super().__init__(command="uvx", args=uvx_args, env=env, cwd=project_directory)
|
| 302 |
+
self.tool_name = tool_name
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
class NpxStdioTransport(StdioTransport):
|
| 306 |
+
"""Transport for running commands via the npx tool."""
|
| 307 |
+
|
| 308 |
+
def __init__(
|
| 309 |
+
self,
|
| 310 |
+
package: str,
|
| 311 |
+
args: Optional[List[str]] = None,
|
| 312 |
+
project_directory: Optional[str] = None,
|
| 313 |
+
env_vars: Optional[Dict[str, str]] = None,
|
| 314 |
+
use_package_lock: bool = True,
|
| 315 |
+
):
|
| 316 |
+
"""
|
| 317 |
+
Initialize an Npx transport.
|
| 318 |
+
|
| 319 |
+
Args:
|
| 320 |
+
package: Name of the npm package to run
|
| 321 |
+
args: Arguments to pass to the package command
|
| 322 |
+
project_directory: Project directory with package.json
|
| 323 |
+
env_vars: Additional environment variables
|
| 324 |
+
use_package_lock: Whether to use package-lock.json (--prefer-offline)
|
| 325 |
+
"""
|
| 326 |
+
# Basic validation
|
| 327 |
+
if project_directory and not Path(project_directory).exists():
|
| 328 |
+
raise NotADirectoryError(
|
| 329 |
+
f"Project directory not found: {project_directory}"
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
# Build npx arguments
|
| 333 |
+
npx_args = []
|
| 334 |
+
if use_package_lock:
|
| 335 |
+
npx_args.append("--prefer-offline")
|
| 336 |
+
|
| 337 |
+
# Add the package name and args
|
| 338 |
+
npx_args.append(package)
|
| 339 |
+
if args:
|
| 340 |
+
npx_args.extend(args)
|
| 341 |
+
|
| 342 |
+
# Get environment with any additional variables
|
| 343 |
+
env = None
|
| 344 |
+
if env_vars:
|
| 345 |
+
env = os.environ.copy()
|
| 346 |
+
env.update(env_vars)
|
| 347 |
+
|
| 348 |
+
super().__init__(command="npx", args=npx_args, env=env, cwd=project_directory)
|
| 349 |
+
self.package = package
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
class FastMCPTransport(ClientTransport):
|
| 353 |
+
"""
|
| 354 |
+
Special transport for in-memory connections to an MCP server.
|
| 355 |
+
|
| 356 |
+
This is particularly useful for testing or when client and server
|
| 357 |
+
are in the same process.
|
| 358 |
+
"""
|
| 359 |
+
|
| 360 |
+
def __init__(self, mcp: FastMCPServer):
|
| 361 |
+
self._fastmcp = mcp # Can be FastMCP or MCPServer
|
| 362 |
+
|
| 363 |
+
@contextlib.asynccontextmanager
|
| 364 |
+
async def connect_session(
|
| 365 |
+
self, **session_kwargs: Unpack[SessionKwargs]
|
| 366 |
+
) -> AsyncIterator[ClientSession]:
|
| 367 |
+
# create_connected_server_and_client_session manages the session lifecycle itself
|
| 368 |
+
async with create_connected_server_and_client_session(
|
| 369 |
+
server=self._fastmcp._mcp_server,
|
| 370 |
+
**session_kwargs,
|
| 371 |
+
) as session:
|
| 372 |
+
yield session
|
| 373 |
+
|
| 374 |
+
def __repr__(self) -> str:
|
| 375 |
+
return f"<FastMCP(server='{self._fastmcp.name}')>"
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def infer_transport(
|
| 379 |
+
transport: ClientTransport | FastMCPServer | AnyUrl | Path | str,
|
| 380 |
+
) -> ClientTransport:
|
| 381 |
+
"""
|
| 382 |
+
Infer the appropriate transport type from the given transport argument.
|
| 383 |
+
|
| 384 |
+
This function attempts to infer the correct transport type from the provided
|
| 385 |
+
argument, handling various input types and converting them to the appropriate
|
| 386 |
+
ClientTransport subclass.
|
| 387 |
+
"""
|
| 388 |
+
# the transport is already a ClientTransport
|
| 389 |
+
if isinstance(transport, ClientTransport):
|
| 390 |
+
return transport
|
| 391 |
+
|
| 392 |
+
# the transport is a FastMCP server
|
| 393 |
+
elif isinstance(transport, FastMCPServer):
|
| 394 |
+
return FastMCPTransport(mcp=transport)
|
| 395 |
+
|
| 396 |
+
# the transport is a path to a script
|
| 397 |
+
elif isinstance(transport, (Path, str)) and Path(transport).exists():
|
| 398 |
+
if str(transport).endswith(".py"):
|
| 399 |
+
return PythonStdioTransport(script_path=transport)
|
| 400 |
+
elif str(transport).endswith(".js"):
|
| 401 |
+
return NodeStdioTransport(script_path=transport)
|
| 402 |
+
else:
|
| 403 |
+
raise ValueError(f"Unsupported script type: {transport}")
|
| 404 |
+
|
| 405 |
+
# the transport is an http(s) URL
|
| 406 |
+
elif isinstance(transport, (AnyUrl, str)) and str(transport).startswith("http"):
|
| 407 |
+
return SSETransport(url=transport)
|
| 408 |
+
|
| 409 |
+
# the transport is a websocket URL
|
| 410 |
+
elif isinstance(transport, (AnyUrl, str)) and str(transport).startswith("ws"):
|
| 411 |
+
return WSTransport(url=transport)
|
| 412 |
+
|
| 413 |
+
# the transport is an unknown type
|
| 414 |
+
else:
|
| 415 |
+
raise ValueError(f"Could not infer a valid transport from: {transport}")
|
src/fastmcp/clients/__init__.py
DELETED
|
@@ -1,12 +0,0 @@
|
|
| 1 |
-
from .websocket import WebSocketClient
|
| 2 |
-
from .sse import SSEClient
|
| 3 |
-
from .stdio import StdioClient, UvxClient
|
| 4 |
-
from .fastmcp_client import FastMCPClient
|
| 5 |
-
|
| 6 |
-
__all__ = [
|
| 7 |
-
"StdioClient",
|
| 8 |
-
"SSEClient",
|
| 9 |
-
"WebSocketClient",
|
| 10 |
-
"UvxClient",
|
| 11 |
-
"FastMCPClient",
|
| 12 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/clients/fastmcp_client.py
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
import contextlib
|
| 2 |
-
from typing import TypeVar
|
| 3 |
-
|
| 4 |
-
from mcp.shared.memory import create_connected_server_and_client_session
|
| 5 |
-
from typing_extensions import Unpack
|
| 6 |
-
|
| 7 |
-
from fastmcp.clients.base import BaseClient, ClientKwargs
|
| 8 |
-
from fastmcp.server.server import FastMCP
|
| 9 |
-
|
| 10 |
-
T = TypeVar("T")
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
class FastMCPClient(BaseClient):
|
| 14 |
-
"""Client that connects directly to an in-memory FastMCP server.
|
| 15 |
-
|
| 16 |
-
This client creates and manages an in-memory connection to a server,
|
| 17 |
-
without using any external processes or network connections.
|
| 18 |
-
"""
|
| 19 |
-
|
| 20 |
-
def __init__(
|
| 21 |
-
self,
|
| 22 |
-
server: FastMCP,
|
| 23 |
-
**kwargs: Unpack[ClientKwargs],
|
| 24 |
-
):
|
| 25 |
-
"""Initialize an InMemoryClient that connects to an in-memory MCP server.
|
| 26 |
-
|
| 27 |
-
Args:
|
| 28 |
-
server: The FastMCP instance to connect to
|
| 29 |
-
**kwargs: Additional arguments for BaseClient
|
| 30 |
-
"""
|
| 31 |
-
super().__init__(**kwargs)
|
| 32 |
-
self.server = server
|
| 33 |
-
self._cm_session = None
|
| 34 |
-
|
| 35 |
-
@contextlib.asynccontextmanager
|
| 36 |
-
async def _connect(self):
|
| 37 |
-
"""Set up in-memory connection and session"""
|
| 38 |
-
self._cm_session = create_connected_server_and_client_session(
|
| 39 |
-
server=self.server._mcp_server,
|
| 40 |
-
read_timeout_seconds=self._read_timeout_seconds,
|
| 41 |
-
sampling_callback=self._sampling_callback,
|
| 42 |
-
list_roots_callback=self._list_roots_callback,
|
| 43 |
-
logging_callback=self._logging_callback,
|
| 44 |
-
message_handler=self._message_handler,
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
async with self._cm_session as session:
|
| 48 |
-
# No need to call initialize as create_connected_server_and_client_session already does
|
| 49 |
-
async with self._set_session((None, None), session):
|
| 50 |
-
yield self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/clients/sse.py
DELETED
|
@@ -1,32 +0,0 @@
|
|
| 1 |
-
import contextlib
|
| 2 |
-
|
| 3 |
-
from mcp import ClientSession
|
| 4 |
-
from mcp.client.sse import sse_client
|
| 5 |
-
from typing_extensions import Unpack
|
| 6 |
-
|
| 7 |
-
from fastmcp.clients.base import BaseClient, ClientKwargs
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class SSEClient(BaseClient):
|
| 11 |
-
def __init__(
|
| 12 |
-
self,
|
| 13 |
-
url: str,
|
| 14 |
-
headers: dict[str, str] | None = None,
|
| 15 |
-
**kwargs: Unpack[ClientKwargs],
|
| 16 |
-
):
|
| 17 |
-
super().__init__(**kwargs)
|
| 18 |
-
self.url = url
|
| 19 |
-
self.headers = headers or {}
|
| 20 |
-
|
| 21 |
-
@contextlib.asynccontextmanager
|
| 22 |
-
async def _connect(self):
|
| 23 |
-
"""Set up SSE connection and session"""
|
| 24 |
-
async with sse_client(self.url, headers=self.headers) as transport:
|
| 25 |
-
read_stream, write_stream = transport
|
| 26 |
-
async with ClientSession(
|
| 27 |
-
read_stream=read_stream,
|
| 28 |
-
write_stream=write_stream,
|
| 29 |
-
**self._session_kwargs(),
|
| 30 |
-
) as session:
|
| 31 |
-
async with self._set_session(transport, session):
|
| 32 |
-
yield self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/clients/stdio.py
DELETED
|
@@ -1,134 +0,0 @@
|
|
| 1 |
-
import contextlib
|
| 2 |
-
import os
|
| 3 |
-
|
| 4 |
-
from mcp import ClientSession, StdioServerParameters
|
| 5 |
-
from mcp.client.stdio import stdio_client
|
| 6 |
-
from typing_extensions import Unpack
|
| 7 |
-
|
| 8 |
-
from fastmcp.clients.base import BaseClient, ClientKwargs
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
class StdioClient(BaseClient):
|
| 12 |
-
def __init__(
|
| 13 |
-
self,
|
| 14 |
-
server_script_path: str,
|
| 15 |
-
**kwargs: Unpack[ClientKwargs],
|
| 16 |
-
):
|
| 17 |
-
super().__init__(**kwargs)
|
| 18 |
-
self.server_script_path = server_script_path
|
| 19 |
-
|
| 20 |
-
@contextlib.asynccontextmanager
|
| 21 |
-
async def _connect(self):
|
| 22 |
-
"""Set up stdio connection and session"""
|
| 23 |
-
is_python = self.server_script_path.endswith(".py")
|
| 24 |
-
is_js = self.server_script_path.endswith(".js")
|
| 25 |
-
if not (is_python or is_js):
|
| 26 |
-
raise ValueError("Server script must be a .py or .js file")
|
| 27 |
-
|
| 28 |
-
command = "python" if is_python else "node"
|
| 29 |
-
server_params = StdioServerParameters(
|
| 30 |
-
command=command, args=[self.server_script_path], env=None
|
| 31 |
-
)
|
| 32 |
-
|
| 33 |
-
async with stdio_client(server_params) as transport:
|
| 34 |
-
stdio, write = transport
|
| 35 |
-
|
| 36 |
-
async with ClientSession(
|
| 37 |
-
read_stream=stdio,
|
| 38 |
-
write_stream=write,
|
| 39 |
-
**self._session_kwargs(),
|
| 40 |
-
) as session:
|
| 41 |
-
async with self._set_session(transport, session):
|
| 42 |
-
yield self
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
class UvxClient(BaseClient):
|
| 46 |
-
"""Client that uses uvx to run Python tools in isolated environments.
|
| 47 |
-
|
| 48 |
-
uvx automatically installs and manages dependencies from pyproject.toml.
|
| 49 |
-
"""
|
| 50 |
-
|
| 51 |
-
def __init__(
|
| 52 |
-
self,
|
| 53 |
-
tool_name: str,
|
| 54 |
-
tool_args: list[str] | None = None,
|
| 55 |
-
project_directory: str | None = None,
|
| 56 |
-
python_version: str | None = None,
|
| 57 |
-
with_packages: list[str] | None = None,
|
| 58 |
-
from_package: str | None = None,
|
| 59 |
-
env_vars: dict[str, str] | None = None,
|
| 60 |
-
**kwargs: Unpack[ClientKwargs],
|
| 61 |
-
):
|
| 62 |
-
"""Initialize a UvxClient that uses uvx to run Python tools in isolated environments.
|
| 63 |
-
|
| 64 |
-
Args:
|
| 65 |
-
tool_name: Name of the tool/command to run
|
| 66 |
-
tool_args: Arguments to pass to the tool
|
| 67 |
-
project_directory: Path to the project directory (optional)
|
| 68 |
-
python_version: Specific Python version to use (e.g., "3.10")
|
| 69 |
-
with_packages: Additional packages to include
|
| 70 |
-
from_package: Package that provides the tool if different from tool_name
|
| 71 |
-
env_vars: Environment variables to set for the process
|
| 72 |
-
**kwargs: Additional arguments for BaseClient
|
| 73 |
-
"""
|
| 74 |
-
super().__init__(**kwargs)
|
| 75 |
-
self.tool_name = tool_name
|
| 76 |
-
self.tool_args = tool_args or []
|
| 77 |
-
self.project_directory = project_directory
|
| 78 |
-
self.python_version = python_version
|
| 79 |
-
self.with_packages = with_packages or []
|
| 80 |
-
self.from_package = from_package
|
| 81 |
-
self.env_vars = env_vars or {}
|
| 82 |
-
|
| 83 |
-
@contextlib.asynccontextmanager
|
| 84 |
-
async def _connect(self):
|
| 85 |
-
"""Set up uvx connection and session"""
|
| 86 |
-
# Check if project directory exists if provided
|
| 87 |
-
if self.project_directory and not os.path.isdir(self.project_directory):
|
| 88 |
-
raise ValueError(
|
| 89 |
-
f"Project directory does not exist: {self.project_directory}"
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
# Build the uvx command arguments
|
| 93 |
-
args = []
|
| 94 |
-
|
| 95 |
-
# Add Python version if specified
|
| 96 |
-
if self.python_version:
|
| 97 |
-
args.extend(["--python", self.python_version])
|
| 98 |
-
|
| 99 |
-
# Add from package if specified
|
| 100 |
-
if self.from_package:
|
| 101 |
-
args.extend(["--from", self.from_package])
|
| 102 |
-
|
| 103 |
-
# Add with packages if specified
|
| 104 |
-
for pkg in self.with_packages:
|
| 105 |
-
args.extend(["--with", pkg])
|
| 106 |
-
|
| 107 |
-
# Add the tool name
|
| 108 |
-
args.append(self.tool_name)
|
| 109 |
-
|
| 110 |
-
# Add the tool arguments
|
| 111 |
-
args.extend(self.tool_args)
|
| 112 |
-
|
| 113 |
-
# Create environment variables dictionary
|
| 114 |
-
env = os.environ.copy()
|
| 115 |
-
env.update(self.env_vars)
|
| 116 |
-
|
| 117 |
-
# Configure the server parameters
|
| 118 |
-
server_params = StdioServerParameters(
|
| 119 |
-
command="uvx",
|
| 120 |
-
args=args,
|
| 121 |
-
env=env,
|
| 122 |
-
cwd=self.project_directory,
|
| 123 |
-
)
|
| 124 |
-
|
| 125 |
-
async with stdio_client(server_params) as transport:
|
| 126 |
-
stdio, write = transport
|
| 127 |
-
|
| 128 |
-
async with ClientSession(
|
| 129 |
-
read_stream=stdio,
|
| 130 |
-
write_stream=write,
|
| 131 |
-
**self._session_kwargs(),
|
| 132 |
-
) as session:
|
| 133 |
-
async with self._set_session(transport, session):
|
| 134 |
-
yield self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/clients/websocket.py
DELETED
|
@@ -1,31 +0,0 @@
|
|
| 1 |
-
import contextlib
|
| 2 |
-
|
| 3 |
-
from mcp import ClientSession
|
| 4 |
-
from mcp.client.websocket import websocket_client
|
| 5 |
-
from typing_extensions import Unpack
|
| 6 |
-
|
| 7 |
-
from fastmcp.clients.base import BaseClient, ClientKwargs
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class WebSocketClient(BaseClient):
|
| 11 |
-
def __init__(
|
| 12 |
-
self,
|
| 13 |
-
url: str,
|
| 14 |
-
**kwargs: Unpack[ClientKwargs],
|
| 15 |
-
):
|
| 16 |
-
super().__init__(**kwargs)
|
| 17 |
-
self.url = url
|
| 18 |
-
|
| 19 |
-
@contextlib.asynccontextmanager
|
| 20 |
-
async def _connect(self):
|
| 21 |
-
"""Set up WebSocket connection and session"""
|
| 22 |
-
async with websocket_client(self.url) as transport:
|
| 23 |
-
read_stream, write_stream = transport
|
| 24 |
-
|
| 25 |
-
async with ClientSession(
|
| 26 |
-
read_stream=read_stream,
|
| 27 |
-
write_stream=write_stream,
|
| 28 |
-
**self._session_kwargs(),
|
| 29 |
-
) as session:
|
| 30 |
-
async with self._set_session(transport, session):
|
| 31 |
-
yield self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/server/proxy.py
CHANGED
|
@@ -3,7 +3,8 @@ from typing import Any, cast
|
|
| 3 |
import mcp.types
|
| 4 |
from mcp.types import BlobResourceContents, PromptMessage, TextResourceContents
|
| 5 |
|
| 6 |
-
|
|
|
|
| 7 |
from fastmcp.prompts import Prompt
|
| 8 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 9 |
from fastmcp.server.context import Context
|
|
@@ -20,14 +21,12 @@ def _proxy_passthrough():
|
|
| 20 |
|
| 21 |
|
| 22 |
class ProxyTool(Tool):
|
| 23 |
-
def __init__(self, client: "
|
| 24 |
super().__init__(**kwargs)
|
| 25 |
self._client = client
|
| 26 |
|
| 27 |
@classmethod
|
| 28 |
-
async def from_client(
|
| 29 |
-
cls, client: "BaseClient", tool: mcp.types.Tool
|
| 30 |
-
) -> "ProxyTool":
|
| 31 |
return cls(
|
| 32 |
client=client,
|
| 33 |
name=tool.name,
|
|
@@ -50,7 +49,7 @@ class ProxyTool(Tool):
|
|
| 50 |
|
| 51 |
class ProxyResource(Resource):
|
| 52 |
def __init__(
|
| 53 |
-
self, client: "
|
| 54 |
):
|
| 55 |
super().__init__(**kwargs)
|
| 56 |
self._client = client
|
|
@@ -58,7 +57,7 @@ class ProxyResource(Resource):
|
|
| 58 |
|
| 59 |
@classmethod
|
| 60 |
async def from_client(
|
| 61 |
-
cls, client: "
|
| 62 |
) -> "ProxyResource":
|
| 63 |
return cls(
|
| 64 |
client=client,
|
|
@@ -83,13 +82,13 @@ class ProxyResource(Resource):
|
|
| 83 |
|
| 84 |
|
| 85 |
class ProxyTemplate(ResourceTemplate):
|
| 86 |
-
def __init__(self, client: "
|
| 87 |
super().__init__(**kwargs)
|
| 88 |
self._client = client
|
| 89 |
|
| 90 |
@classmethod
|
| 91 |
async def from_client(
|
| 92 |
-
cls, client: "
|
| 93 |
) -> "ProxyTemplate":
|
| 94 |
return cls(
|
| 95 |
client=client,
|
|
@@ -123,13 +122,13 @@ class ProxyTemplate(ResourceTemplate):
|
|
| 123 |
|
| 124 |
|
| 125 |
class ProxyPrompt(Prompt):
|
| 126 |
-
def __init__(self, client: "
|
| 127 |
super().__init__(**kwargs)
|
| 128 |
self._client = client
|
| 129 |
|
| 130 |
@classmethod
|
| 131 |
async def from_client(
|
| 132 |
-
cls, client: "
|
| 133 |
) -> "ProxyPrompt":
|
| 134 |
return cls(
|
| 135 |
client=client,
|
|
@@ -155,7 +154,10 @@ class FastMCPProxy(FastMCP):
|
|
| 155 |
|
| 156 |
@classmethod
|
| 157 |
async def from_client(
|
| 158 |
-
cls,
|
|
|
|
|
|
|
|
|
|
| 159 |
) -> "FastMCPProxy":
|
| 160 |
"""Create a FastMCP proxy server from a client.
|
| 161 |
|
|
@@ -210,3 +212,8 @@ class FastMCPProxy(FastMCP):
|
|
| 210 |
|
| 211 |
logger.info(f"Created server '{server.name}' proxying to client: {client}")
|
| 212 |
return server
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import mcp.types
|
| 4 |
from mcp.types import BlobResourceContents, PromptMessage, TextResourceContents
|
| 5 |
|
| 6 |
+
import fastmcp
|
| 7 |
+
from fastmcp.client import Client
|
| 8 |
from fastmcp.prompts import Prompt
|
| 9 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 10 |
from fastmcp.server.context import Context
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class ProxyTool(Tool):
|
| 24 |
+
def __init__(self, client: "Client", **kwargs):
|
| 25 |
super().__init__(**kwargs)
|
| 26 |
self._client = client
|
| 27 |
|
| 28 |
@classmethod
|
| 29 |
+
async def from_client(cls, client: "Client", tool: mcp.types.Tool) -> "ProxyTool":
|
|
|
|
|
|
|
| 30 |
return cls(
|
| 31 |
client=client,
|
| 32 |
name=tool.name,
|
|
|
|
| 49 |
|
| 50 |
class ProxyResource(Resource):
|
| 51 |
def __init__(
|
| 52 |
+
self, client: "Client", *, _value: str | bytes | None = None, **kwargs
|
| 53 |
):
|
| 54 |
super().__init__(**kwargs)
|
| 55 |
self._client = client
|
|
|
|
| 57 |
|
| 58 |
@classmethod
|
| 59 |
async def from_client(
|
| 60 |
+
cls, client: "Client", resource: mcp.types.Resource
|
| 61 |
) -> "ProxyResource":
|
| 62 |
return cls(
|
| 63 |
client=client,
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
class ProxyTemplate(ResourceTemplate):
|
| 85 |
+
def __init__(self, client: "Client", **kwargs):
|
| 86 |
super().__init__(**kwargs)
|
| 87 |
self._client = client
|
| 88 |
|
| 89 |
@classmethod
|
| 90 |
async def from_client(
|
| 91 |
+
cls, client: "Client", template: mcp.types.ResourceTemplate
|
| 92 |
) -> "ProxyTemplate":
|
| 93 |
return cls(
|
| 94 |
client=client,
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
class ProxyPrompt(Prompt):
|
| 125 |
+
def __init__(self, client: "Client", **kwargs):
|
| 126 |
super().__init__(**kwargs)
|
| 127 |
self._client = client
|
| 128 |
|
| 129 |
@classmethod
|
| 130 |
async def from_client(
|
| 131 |
+
cls, client: "Client", prompt: mcp.types.Prompt
|
| 132 |
) -> "ProxyPrompt":
|
| 133 |
return cls(
|
| 134 |
client=client,
|
|
|
|
| 154 |
|
| 155 |
@classmethod
|
| 156 |
async def from_client(
|
| 157 |
+
cls,
|
| 158 |
+
client: "Client",
|
| 159 |
+
name: str | None = None,
|
| 160 |
+
**settings: fastmcp.settings.ServerSettings,
|
| 161 |
) -> "FastMCPProxy":
|
| 162 |
"""Create a FastMCP proxy server from a client.
|
| 163 |
|
|
|
|
| 212 |
|
| 213 |
logger.info(f"Created server '{server.name}' proxying to client: {client}")
|
| 214 |
return server
|
| 215 |
+
|
| 216 |
+
@classmethod
|
| 217 |
+
async def from_server(cls, server: FastMCP, **settings: Any) -> "FastMCPProxy":
|
| 218 |
+
client = Client(transport=fastmcp.client.transports.FastMCPTransport(server))
|
| 219 |
+
return await cls.from_client(client, **settings)
|
src/fastmcp/server/server.py
CHANGED
|
@@ -50,11 +50,10 @@ from fastmcp.utilities.logging import configure_logging, get_logger
|
|
| 50 |
from fastmcp.utilities.types import Image
|
| 51 |
|
| 52 |
if TYPE_CHECKING:
|
| 53 |
-
from fastmcp.
|
| 54 |
from fastmcp.server.context import Context
|
| 55 |
from fastmcp.server.openapi import FastMCPOpenAPI
|
| 56 |
from fastmcp.server.proxy import FastMCPProxy
|
| 57 |
-
|
| 58 |
logger = get_logger(__name__)
|
| 59 |
|
| 60 |
|
|
@@ -117,20 +116,29 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 117 |
def instructions(self) -> str | None:
|
| 118 |
return self._mcp_server.instructions
|
| 119 |
|
| 120 |
-
def
|
| 121 |
-
"""Run the FastMCP server
|
| 122 |
|
| 123 |
Args:
|
| 124 |
transport: Transport protocol to use ("stdio" or "sse")
|
| 125 |
"""
|
| 126 |
-
|
| 127 |
-
|
|
|
|
| 128 |
raise ValueError(f"Unknown transport: {transport}")
|
| 129 |
|
| 130 |
if transport == "stdio":
|
| 131 |
-
|
| 132 |
else: # transport == "sse"
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
def _setup_handlers(self) -> None:
|
| 136 |
"""Set up core MCP protocol handlers."""
|
|
@@ -547,7 +555,9 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 547 |
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
|
| 548 |
|
| 549 |
@classmethod
|
| 550 |
-
async def as_proxy(
|
|
|
|
|
|
|
| 551 |
"""
|
| 552 |
Create a FastMCP proxy server from a client.
|
| 553 |
|
|
@@ -562,9 +572,18 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 562 |
Returns:
|
| 563 |
A FastMCP server that proxies requests to the client
|
| 564 |
"""
|
|
|
|
|
|
|
| 565 |
from .proxy import FastMCPProxy
|
| 566 |
|
| 567 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
@classmethod
|
| 570 |
def from_openapi(
|
|
|
|
| 50 |
from fastmcp.utilities.types import Image
|
| 51 |
|
| 52 |
if TYPE_CHECKING:
|
| 53 |
+
from fastmcp.client import Client
|
| 54 |
from fastmcp.server.context import Context
|
| 55 |
from fastmcp.server.openapi import FastMCPOpenAPI
|
| 56 |
from fastmcp.server.proxy import FastMCPProxy
|
|
|
|
| 57 |
logger = get_logger(__name__)
|
| 58 |
|
| 59 |
|
|
|
|
| 116 |
def instructions(self) -> str | None:
|
| 117 |
return self._mcp_server.instructions
|
| 118 |
|
| 119 |
+
async def run_async(self, transport: Literal["stdio", "sse"] | None = None) -> None:
|
| 120 |
+
"""Run the FastMCP server asynchronously.
|
| 121 |
|
| 122 |
Args:
|
| 123 |
transport: Transport protocol to use ("stdio" or "sse")
|
| 124 |
"""
|
| 125 |
+
if transport is None:
|
| 126 |
+
transport = "stdio"
|
| 127 |
+
if transport not in ["stdio", "sse"]:
|
| 128 |
raise ValueError(f"Unknown transport: {transport}")
|
| 129 |
|
| 130 |
if transport == "stdio":
|
| 131 |
+
await self.run_stdio_async()
|
| 132 |
else: # transport == "sse"
|
| 133 |
+
await self.run_sse_async()
|
| 134 |
+
|
| 135 |
+
def run(self, transport: Literal["stdio", "sse"] | None = None) -> None:
|
| 136 |
+
"""Run the FastMCP server. Note this is a synchronous function.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
transport: Transport protocol to use ("stdio" or "sse")
|
| 140 |
+
"""
|
| 141 |
+
anyio.run(self.run_async, transport)
|
| 142 |
|
| 143 |
def _setup_handlers(self) -> None:
|
| 144 |
"""Set up core MCP protocol handlers."""
|
|
|
|
| 555 |
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
|
| 556 |
|
| 557 |
@classmethod
|
| 558 |
+
async def as_proxy(
|
| 559 |
+
cls, client: "Client | FastMCP", **settings: Any
|
| 560 |
+
) -> "FastMCPProxy":
|
| 561 |
"""
|
| 562 |
Create a FastMCP proxy server from a client.
|
| 563 |
|
|
|
|
| 572 |
Returns:
|
| 573 |
A FastMCP server that proxies requests to the client
|
| 574 |
"""
|
| 575 |
+
from fastmcp.client import Client
|
| 576 |
+
|
| 577 |
from .proxy import FastMCPProxy
|
| 578 |
|
| 579 |
+
if isinstance(client, Client):
|
| 580 |
+
return await FastMCPProxy.from_client(client=client, **settings)
|
| 581 |
+
|
| 582 |
+
elif isinstance(client, FastMCP):
|
| 583 |
+
return await FastMCPProxy.from_server(server=client, **settings)
|
| 584 |
+
|
| 585 |
+
else:
|
| 586 |
+
raise ValueError(f"Unknown client type: {type(client)}")
|
| 587 |
|
| 588 |
@classmethod
|
| 589 |
def from_openapi(
|
tests/{clients → client}/__init__.py
RENAMED
|
File without changes
|
tests/{clients/test_fastmcp_client.py → client/test_fastmcp_transport.py}
RENAMED
|
@@ -3,7 +3,8 @@ from typing import cast
|
|
| 3 |
import pytest
|
| 4 |
from pydantic import AnyUrl
|
| 5 |
|
| 6 |
-
from fastmcp.
|
|
|
|
| 7 |
from fastmcp.server.server import FastMCP
|
| 8 |
|
| 9 |
|
|
@@ -44,7 +45,7 @@ def fastmcp_server():
|
|
| 44 |
|
| 45 |
async def test_list_tools(fastmcp_server):
|
| 46 |
"""Test listing tools with InMemoryClient."""
|
| 47 |
-
client =
|
| 48 |
|
| 49 |
async with client:
|
| 50 |
result = await client.list_tools()
|
|
@@ -56,7 +57,7 @@ async def test_list_tools(fastmcp_server):
|
|
| 56 |
|
| 57 |
async def test_call_tool(fastmcp_server):
|
| 58 |
"""Test calling a tool with InMemoryClient."""
|
| 59 |
-
client =
|
| 60 |
|
| 61 |
async with client:
|
| 62 |
result = await client.call_tool("greet", {"name": "World"})
|
|
@@ -68,7 +69,7 @@ async def test_call_tool(fastmcp_server):
|
|
| 68 |
|
| 69 |
async def test_list_resources(fastmcp_server):
|
| 70 |
"""Test listing resources with InMemoryClient."""
|
| 71 |
-
client =
|
| 72 |
|
| 73 |
async with client:
|
| 74 |
result = await client.list_resources()
|
|
@@ -80,7 +81,7 @@ async def test_list_resources(fastmcp_server):
|
|
| 80 |
|
| 81 |
async def test_list_prompts(fastmcp_server):
|
| 82 |
"""Test listing prompts with InMemoryClient."""
|
| 83 |
-
client =
|
| 84 |
|
| 85 |
async with client:
|
| 86 |
result = await client.list_prompts()
|
|
@@ -92,7 +93,7 @@ async def test_list_prompts(fastmcp_server):
|
|
| 92 |
|
| 93 |
async def test_get_prompt(fastmcp_server):
|
| 94 |
"""Test getting a prompt with InMemoryClient."""
|
| 95 |
-
client =
|
| 96 |
|
| 97 |
async with client:
|
| 98 |
result = await client.get_prompt("welcome", {"name": "Developer"})
|
|
@@ -104,7 +105,7 @@ async def test_get_prompt(fastmcp_server):
|
|
| 104 |
|
| 105 |
async def test_read_resource(fastmcp_server):
|
| 106 |
"""Test reading a resource with InMemoryClient."""
|
| 107 |
-
client =
|
| 108 |
|
| 109 |
async with client:
|
| 110 |
# Use the URI from the resource we know exists in our server
|
|
@@ -122,7 +123,7 @@ async def test_read_resource(fastmcp_server):
|
|
| 122 |
|
| 123 |
async def test_client_connection(fastmcp_server):
|
| 124 |
"""Test that the client connects and disconnects properly."""
|
| 125 |
-
client =
|
| 126 |
|
| 127 |
# Before connection
|
| 128 |
assert not client.is_connected()
|
|
@@ -137,7 +138,7 @@ async def test_client_connection(fastmcp_server):
|
|
| 137 |
|
| 138 |
async def test_resource_template(fastmcp_server):
|
| 139 |
"""Test using a resource template with InMemoryClient."""
|
| 140 |
-
client =
|
| 141 |
|
| 142 |
async with client:
|
| 143 |
# First, list templates
|
|
|
|
| 3 |
import pytest
|
| 4 |
from pydantic import AnyUrl
|
| 5 |
|
| 6 |
+
from fastmcp.client import Client
|
| 7 |
+
from fastmcp.client.transports import FastMCPTransport
|
| 8 |
from fastmcp.server.server import FastMCP
|
| 9 |
|
| 10 |
|
|
|
|
| 45 |
|
| 46 |
async def test_list_tools(fastmcp_server):
|
| 47 |
"""Test listing tools with InMemoryClient."""
|
| 48 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 49 |
|
| 50 |
async with client:
|
| 51 |
result = await client.list_tools()
|
|
|
|
| 57 |
|
| 58 |
async def test_call_tool(fastmcp_server):
|
| 59 |
"""Test calling a tool with InMemoryClient."""
|
| 60 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 61 |
|
| 62 |
async with client:
|
| 63 |
result = await client.call_tool("greet", {"name": "World"})
|
|
|
|
| 69 |
|
| 70 |
async def test_list_resources(fastmcp_server):
|
| 71 |
"""Test listing resources with InMemoryClient."""
|
| 72 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 73 |
|
| 74 |
async with client:
|
| 75 |
result = await client.list_resources()
|
|
|
|
| 81 |
|
| 82 |
async def test_list_prompts(fastmcp_server):
|
| 83 |
"""Test listing prompts with InMemoryClient."""
|
| 84 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 85 |
|
| 86 |
async with client:
|
| 87 |
result = await client.list_prompts()
|
|
|
|
| 93 |
|
| 94 |
async def test_get_prompt(fastmcp_server):
|
| 95 |
"""Test getting a prompt with InMemoryClient."""
|
| 96 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 97 |
|
| 98 |
async with client:
|
| 99 |
result = await client.get_prompt("welcome", {"name": "Developer"})
|
|
|
|
| 105 |
|
| 106 |
async def test_read_resource(fastmcp_server):
|
| 107 |
"""Test reading a resource with InMemoryClient."""
|
| 108 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 109 |
|
| 110 |
async with client:
|
| 111 |
# Use the URI from the resource we know exists in our server
|
|
|
|
| 123 |
|
| 124 |
async def test_client_connection(fastmcp_server):
|
| 125 |
"""Test that the client connects and disconnects properly."""
|
| 126 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 127 |
|
| 128 |
# Before connection
|
| 129 |
assert not client.is_connected()
|
|
|
|
| 138 |
|
| 139 |
async def test_resource_template(fastmcp_server):
|
| 140 |
"""Test using a resource template with InMemoryClient."""
|
| 141 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 142 |
|
| 143 |
async with client:
|
| 144 |
# First, list templates
|
tests/server/test_proxy.py
CHANGED
|
@@ -5,7 +5,8 @@ import pytest
|
|
| 5 |
from dirty_equals import Contains
|
| 6 |
|
| 7 |
from fastmcp import FastMCP
|
| 8 |
-
from fastmcp.
|
|
|
|
| 9 |
from fastmcp.server.proxy import FastMCPProxy
|
| 10 |
|
| 11 |
USERS = [
|
|
@@ -62,13 +63,13 @@ def fastmcp_server():
|
|
| 62 |
@pytest.fixture
|
| 63 |
async def proxy_server(fastmcp_server):
|
| 64 |
"""Fixture that creates a FastMCP proxy server."""
|
| 65 |
-
return await FastMCP.as_proxy(
|
| 66 |
|
| 67 |
|
| 68 |
async def test_create_proxy(fastmcp_server):
|
| 69 |
"""Test that the proxy server properly forwards requests to the original server."""
|
| 70 |
# Create a client
|
| 71 |
-
client =
|
| 72 |
|
| 73 |
server = await FastMCPProxy.from_client(client)
|
| 74 |
|
|
|
|
| 5 |
from dirty_equals import Contains
|
| 6 |
|
| 7 |
from fastmcp import FastMCP
|
| 8 |
+
from fastmcp.client import Client
|
| 9 |
+
from fastmcp.client.transports import FastMCPTransport
|
| 10 |
from fastmcp.server.proxy import FastMCPProxy
|
| 11 |
|
| 12 |
USERS = [
|
|
|
|
| 63 |
@pytest.fixture
|
| 64 |
async def proxy_server(fastmcp_server):
|
| 65 |
"""Fixture that creates a FastMCP proxy server."""
|
| 66 |
+
return await FastMCP.as_proxy(Client(transport=FastMCPTransport(fastmcp_server)))
|
| 67 |
|
| 68 |
|
| 69 |
async def test_create_proxy(fastmcp_server):
|
| 70 |
"""Test that the proxy server properly forwards requests to the original server."""
|
| 71 |
# Create a client
|
| 72 |
+
client = Client(transport=FastMCPTransport(fastmcp_server))
|
| 73 |
|
| 74 |
server = await FastMCPProxy.from_client(client)
|
| 75 |
|
tests/server/test_run_server.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# from pathlib import Path
|
| 2 |
+
# from typing import TYPE_CHECKING, Any
|
| 3 |
+
|
| 4 |
+
# import pytest
|
| 5 |
+
|
| 6 |
+
# import fastmcp
|
| 7 |
+
# from fastmcp import FastMCP
|
| 8 |
+
|
| 9 |
+
# if TYPE_CHECKING:
|
| 10 |
+
# pass
|
| 11 |
+
|
| 12 |
+
# USERS = [
|
| 13 |
+
# {"id": "1", "name": "Alice", "active": True},
|
| 14 |
+
# {"id": "2", "name": "Bob", "active": True},
|
| 15 |
+
# {"id": "3", "name": "Charlie", "active": False},
|
| 16 |
+
# ]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# @pytest.fixture
|
| 20 |
+
# def fastmcp_server():
|
| 21 |
+
# server = FastMCP("TestServer")
|
| 22 |
+
|
| 23 |
+
# # --- Tools ---
|
| 24 |
+
|
| 25 |
+
# @server.tool()
|
| 26 |
+
# def greet(name: str) -> str:
|
| 27 |
+
# """Greet someone by name."""
|
| 28 |
+
# return f"Hello, {name}!"
|
| 29 |
+
|
| 30 |
+
# @server.tool()
|
| 31 |
+
# def add(a: int, b: int) -> int:
|
| 32 |
+
# """Add two numbers together."""
|
| 33 |
+
# return a + b
|
| 34 |
+
|
| 35 |
+
# @server.tool()
|
| 36 |
+
# def error_tool():
|
| 37 |
+
# """This tool always raises an error."""
|
| 38 |
+
# raise ValueError("This is a test error")
|
| 39 |
+
|
| 40 |
+
# # --- Resources ---
|
| 41 |
+
|
| 42 |
+
# @server.resource(uri="resource://wave")
|
| 43 |
+
# def wave() -> str:
|
| 44 |
+
# return "👋"
|
| 45 |
+
|
| 46 |
+
# @server.resource(uri="data://users")
|
| 47 |
+
# async def get_users() -> list[dict[str, Any]]:
|
| 48 |
+
# return USERS
|
| 49 |
+
|
| 50 |
+
# @server.resource(uri="data://user/{user_id}")
|
| 51 |
+
# async def get_user(user_id: str) -> dict[str, Any] | None:
|
| 52 |
+
# return next((user for user in USERS if user["id"] == user_id), None)
|
| 53 |
+
|
| 54 |
+
# # --- Prompts ---
|
| 55 |
+
|
| 56 |
+
# @server.prompt()
|
| 57 |
+
# def welcome(name: str) -> str:
|
| 58 |
+
# return f"Welcome to FastMCP, {name}!"
|
| 59 |
+
|
| 60 |
+
# return server
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# @pytest.fixture
|
| 64 |
+
# async def stdio_client():
|
| 65 |
+
# # Find the stdio.py script path
|
| 66 |
+
# base_dir = Path(__file__).parent
|
| 67 |
+
# stdio_script = base_dir / "test_servers" / "stdio.py"
|
| 68 |
+
|
| 69 |
+
# if not stdio_script.exists():
|
| 70 |
+
# raise FileNotFoundError(f"Could not find stdio.py script at {stdio_script}")
|
| 71 |
+
|
| 72 |
+
# client = fastmcp.Client(
|
| 73 |
+
# transport=fastmcp.client.transports.StdioTransport(
|
| 74 |
+
# command="python",
|
| 75 |
+
# args=[str(stdio_script)],
|
| 76 |
+
# )
|
| 77 |
+
# )
|
| 78 |
+
|
| 79 |
+
# async with client:
|
| 80 |
+
# print("READY")
|
| 81 |
+
# yield client
|
| 82 |
+
# print("DONE")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# class TestRunServerStdio:
|
| 86 |
+
# async def test_run_server_stdio(
|
| 87 |
+
# self, fastmcp_server: FastMCP, stdio_client: fastmcp.Client
|
| 88 |
+
# ):
|
| 89 |
+
# print("TEST")
|
| 90 |
+
# tools = await stdio_client.list_tools()
|
| 91 |
+
# print("TEST 2")
|
| 92 |
+
# assert tools == 1
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# class TestRunServerSSE:
|
| 96 |
+
# @pytest.mark.anyio
|
| 97 |
+
# async def test_run_server_sse(self, fastmcp_server: FastMCP):
|
| 98 |
+
# pass
|
tests/server/test_servers/fastmcp_server.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from fastmcp import FastMCP
|
| 4 |
+
|
| 5 |
+
USERS = [
|
| 6 |
+
{"id": "1", "name": "Alice", "active": True},
|
| 7 |
+
{"id": "2", "name": "Bob", "active": True},
|
| 8 |
+
{"id": "3", "name": "Charlie", "active": False},
|
| 9 |
+
]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
server = FastMCP("TestServer")
|
| 13 |
+
|
| 14 |
+
# --- Tools ---
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@server.tool()
|
| 18 |
+
def greet(name: str) -> str:
|
| 19 |
+
"""Greet someone by name."""
|
| 20 |
+
return f"Hello, {name}!"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@server.tool()
|
| 24 |
+
def add(a: int, b: int) -> int:
|
| 25 |
+
"""Add two numbers together."""
|
| 26 |
+
return a + b
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@server.tool()
|
| 30 |
+
def error_tool():
|
| 31 |
+
"""This tool always raises an error."""
|
| 32 |
+
raise ValueError("This is a test error")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# --- Resources ---
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@server.resource(uri="resource://wave")
|
| 39 |
+
def wave() -> str:
|
| 40 |
+
return "👋"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@server.resource(uri="data://users")
|
| 44 |
+
async def get_users() -> list[dict[str, Any]]:
|
| 45 |
+
return USERS
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@server.resource(uri="data://user/{user_id}")
|
| 49 |
+
async def get_user(user_id: str) -> dict[str, Any] | None:
|
| 50 |
+
return next((user for user in USERS if user["id"] == user_id), None)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --- Prompts ---
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@server.prompt()
|
| 57 |
+
def welcome(name: str) -> str:
|
| 58 |
+
return f"Welcome to FastMCP, {name}!"
|
tests/server/test_servers/sse.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
import fastmcp_server
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
asyncio.run(fastmcp_server.server.run_sse_async())
|
tests/server/test_servers/stdio.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
import fastmcp_server
|
| 4 |
+
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
asyncio.run(fastmcp_server.server.run_stdio_async())
|