Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
d4adea4
1
Parent(s): 9bb4597
Support mounting FastMCPs as sub-servers
Browse files- examples/modular_app.py +150 -0
- examples/mount_example.py +112 -0
- src/fastmcp/prompts/__init__.py +3 -0
- src/fastmcp/prompts/prompt_manager.py +33 -0
- src/fastmcp/resources/__init__.py +3 -0
- src/fastmcp/resources/resource_manager.py +55 -0
- src/fastmcp/server/server.py +58 -1
- src/fastmcp/tools/tool_manager.py +38 -0
- tests/prompts/test_prompt_manager.py +166 -0
- tests/resources/test_resource_manager.py +221 -0
- tests/server.py +0 -0
- tests/server/test_mount.py +184 -0
- tests/tools/__init__.py +0 -0
- tests/tools/test_tool_manager.py +101 -0
- tests/tools/tool_manager.py +104 -0
examples/modular_app.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, Optional
|
| 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) -> Optional[Dict[str, Any]]:
|
| 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()
|
examples/mount_example.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Example of mounting FastMCP apps together.
|
| 2 |
+
|
| 3 |
+
This example demonstrates how to mount FastMCP apps together using
|
| 4 |
+
the ToolManager's import_tools functionality. It shows how to:
|
| 5 |
+
|
| 6 |
+
1. Create sub-applications for different domains
|
| 7 |
+
2. Mount those sub-applications to a main application
|
| 8 |
+
3. Access tools with prefixed names and resources with prefixed URIs
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
from typing import Dict, List
|
| 13 |
+
|
| 14 |
+
from fastmcp import FastMCP
|
| 15 |
+
|
| 16 |
+
# Weather sub-application
|
| 17 |
+
weather_app = FastMCP("Weather App")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@weather_app.tool()
|
| 21 |
+
def get_weather_forecast(location: str) -> str:
|
| 22 |
+
"""Get the weather forecast for a location."""
|
| 23 |
+
return f"Sunny skies for {location} today!"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@weather_app.resource(uri="weather://forecast")
|
| 27 |
+
async def weather_data():
|
| 28 |
+
"""Return current weather data."""
|
| 29 |
+
return {"temperature": 72, "conditions": "sunny", "humidity": 45, "wind_speed": 5}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# News sub-application
|
| 33 |
+
news_app = FastMCP("News App")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@news_app.tool()
|
| 37 |
+
def get_news_headlines() -> List[str]:
|
| 38 |
+
"""Get the latest news headlines."""
|
| 39 |
+
return [
|
| 40 |
+
"Tech company launches new product",
|
| 41 |
+
"Local team wins championship",
|
| 42 |
+
"Scientists make breakthrough discovery",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@news_app.resource(uri="news://headlines")
|
| 47 |
+
async def news_data():
|
| 48 |
+
"""Return latest news data."""
|
| 49 |
+
return {
|
| 50 |
+
"top_story": "Breaking news: Important event happened",
|
| 51 |
+
"categories": ["politics", "sports", "technology"],
|
| 52 |
+
"sources": ["AP", "Reuters", "Local Sources"],
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# Main application
|
| 57 |
+
app = FastMCP("Main App")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@app.tool()
|
| 61 |
+
def check_app_status() -> Dict[str, str]:
|
| 62 |
+
"""Check the status of the main application."""
|
| 63 |
+
return {"status": "running", "version": "1.0.0", "uptime": "3h 24m"}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# Mount sub-applications
|
| 67 |
+
app.mount("weather", weather_app)
|
| 68 |
+
app.mount("news", news_app)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def start_server():
|
| 72 |
+
"""Print information about mounted resources."""
|
| 73 |
+
# Print available tools
|
| 74 |
+
tools = app._tool_manager.list_tools()
|
| 75 |
+
print(f"\nAvailable tools ({len(tools)}):")
|
| 76 |
+
for tool in tools:
|
| 77 |
+
print(f" - {tool.name}: {tool.description}")
|
| 78 |
+
|
| 79 |
+
# Print available resources
|
| 80 |
+
print("\nAvailable resources:")
|
| 81 |
+
|
| 82 |
+
# Distinguish between native and imported resources
|
| 83 |
+
# Native resources would be those directly in the main app (not prefixed)
|
| 84 |
+
native_resources = [
|
| 85 |
+
uri
|
| 86 |
+
for uri in app._resource_manager._resources
|
| 87 |
+
if not (uri.startswith("weather+") or uri.startswith("news+"))
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
# Imported resources - categorized by source app
|
| 91 |
+
weather_resources = [
|
| 92 |
+
uri for uri in app._resource_manager._resources if uri.startswith("weather+")
|
| 93 |
+
]
|
| 94 |
+
news_resources = [
|
| 95 |
+
uri for uri in app._resource_manager._resources if uri.startswith("news+")
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
print(f" - Native app resources: {native_resources}")
|
| 99 |
+
print(f" - Imported from weather app: {weather_resources}")
|
| 100 |
+
print(f" - Imported from news app: {news_resources}")
|
| 101 |
+
|
| 102 |
+
# Let's try to access resources using the prefixed URI
|
| 103 |
+
weather_data = await app.read_resource("weather+weather://forecast")
|
| 104 |
+
print(f"\nWeather data from prefixed URI: {weather_data}")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
# First run our async function to display info
|
| 109 |
+
asyncio.run(start_server())
|
| 110 |
+
|
| 111 |
+
# Then start the server (uncomment to run the server)
|
| 112 |
+
# app.run()
|
src/fastmcp/prompts/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .prompt_manager import PromptManager
|
| 2 |
+
|
| 3 |
+
__all__ = ["PromptManager"]
|
src/fastmcp/prompts/prompt_manager.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from mcp.server.fastmcp.prompts import PromptManager as BasePromptManager
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class PromptManager(BasePromptManager):
|
| 9 |
+
"""
|
| 10 |
+
Extended PromptManager that supports importing prompts from other managers.
|
| 11 |
+
Adds ability to import prompts from other managers with prefixed names.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
def import_prompts(self, manager: "PromptManager", prefix: str) -> None:
|
| 15 |
+
"""
|
| 16 |
+
Import all prompts from another PromptManager with prefixed names.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
manager: Another PromptManager instance to import prompts from
|
| 20 |
+
prefix: Prefix to add to prompt names. The resulting prompt name will
|
| 21 |
+
be in the format "{prefix}/{original_name}"
|
| 22 |
+
For example, with prefix "weather" and prompt "forecast_prompt",
|
| 23 |
+
the imported prompt would be available as "weather/forecast_prompt"
|
| 24 |
+
"""
|
| 25 |
+
for name, prompt in manager._prompts.items():
|
| 26 |
+
# Create prefixed name - we keep the original name in the Prompt object
|
| 27 |
+
prefixed_name = f"{prefix}/{name}"
|
| 28 |
+
|
| 29 |
+
# Log the import
|
| 30 |
+
logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
|
| 31 |
+
|
| 32 |
+
# Store the prompt with the prefixed name
|
| 33 |
+
self._prompts[prefixed_name] = prompt
|
src/fastmcp/resources/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .resource_manager import ResourceManager
|
| 2 |
+
|
| 3 |
+
__all__ = ["ResourceManager"]
|
src/fastmcp/resources/resource_manager.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from mcp.server.fastmcp.resources import (
|
| 4 |
+
ResourceManager as BaseResourceManager,
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ResourceManager(BaseResourceManager):
|
| 11 |
+
"""ResourceManager that adds methods to import resources from other managers."""
|
| 12 |
+
|
| 13 |
+
def import_resources(self, manager: "ResourceManager", prefix: str) -> None:
|
| 14 |
+
"""Import resources from another resource manager.
|
| 15 |
+
|
| 16 |
+
Resources are imported with a prefixed URI. For example, if a resource has
|
| 17 |
+
URI "data://users" and you import it with prefix "app", the imported resource
|
| 18 |
+
will have URI "app+data://users".
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
manager: The ResourceManager to import from
|
| 22 |
+
prefix: A prefix to apply to the resource URIs
|
| 23 |
+
"""
|
| 24 |
+
for uri, resource in manager._resources.items():
|
| 25 |
+
# Create prefixed URI and copy the resource with the new URI
|
| 26 |
+
prefixed_uri = f"{prefix}+{uri}"
|
| 27 |
+
|
| 28 |
+
# Log the import
|
| 29 |
+
logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
|
| 30 |
+
|
| 31 |
+
# Store directly in resources dictionary
|
| 32 |
+
self._resources[prefixed_uri] = resource
|
| 33 |
+
|
| 34 |
+
def import_templates(self, manager: "ResourceManager", prefix: str) -> None:
|
| 35 |
+
"""Import resource templates from another resource manager.
|
| 36 |
+
|
| 37 |
+
Templates are imported with a prefixed URI template. For example, if a template has
|
| 38 |
+
URI template "data://users/{id}" and you import it with prefix "app", the
|
| 39 |
+
imported template will have URI template "app+data://users/{id}".
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
manager: The ResourceManager to import templates from
|
| 43 |
+
prefix: A prefix to apply to the template URIs
|
| 44 |
+
"""
|
| 45 |
+
for uri_template, template in manager._templates.items():
|
| 46 |
+
# Create prefixed URI template and copy the template with the new URI template
|
| 47 |
+
prefixed_uri_template = f"{prefix}+{uri_template}"
|
| 48 |
+
|
| 49 |
+
# Log the import
|
| 50 |
+
logger.debug(
|
| 51 |
+
f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Store directly in templates dictionary
|
| 55 |
+
self._templates[prefixed_uri_template] = template
|
src/fastmcp/server/server.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
| 1 |
-
from typing import Any
|
| 2 |
|
| 3 |
import mcp.server.fastmcp
|
|
|
|
| 4 |
|
|
|
|
|
|
|
| 5 |
from fastmcp.server.context import Context
|
|
|
|
| 6 |
from fastmcp.utilities.logging import get_logger
|
| 7 |
|
| 8 |
logger = get_logger(__name__)
|
|
@@ -10,8 +14,23 @@ logger = get_logger(__name__)
|
|
| 10 |
|
| 11 |
class FastMCP(mcp.server.fastmcp.FastMCP):
|
| 12 |
def __init__(self, name: str | None = None, **settings: Any):
|
|
|
|
| 13 |
super().__init__(name=name or "FastMCP", **settings)
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
def get_context(self) -> Context:
|
| 16 |
"""
|
| 17 |
Returns a Context object. Note that the context will only be valid
|
|
@@ -22,3 +41,41 @@ class FastMCP(mcp.server.fastmcp.FastMCP):
|
|
| 22 |
except LookupError:
|
| 23 |
request_context = None
|
| 24 |
return Context(request_context=request_context, fastmcp=self)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict
|
| 2 |
|
| 3 |
import mcp.server.fastmcp
|
| 4 |
+
import mcp.types
|
| 5 |
|
| 6 |
+
from fastmcp.prompts.prompt_manager import PromptManager
|
| 7 |
+
from fastmcp.resources.resource_manager import ResourceManager
|
| 8 |
from fastmcp.server.context import Context
|
| 9 |
+
from fastmcp.tools.tool_manager import ToolManager
|
| 10 |
from fastmcp.utilities.logging import get_logger
|
| 11 |
|
| 12 |
logger = get_logger(__name__)
|
|
|
|
| 14 |
|
| 15 |
class FastMCP(mcp.server.fastmcp.FastMCP):
|
| 16 |
def __init__(self, name: str | None = None, **settings: Any):
|
| 17 |
+
# First initialize with default settings
|
| 18 |
super().__init__(name=name or "FastMCP", **settings)
|
| 19 |
|
| 20 |
+
# Replace the default managers with our extended ones
|
| 21 |
+
self._tool_manager = ToolManager(
|
| 22 |
+
warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
|
| 23 |
+
)
|
| 24 |
+
self._resource_manager = ResourceManager(
|
| 25 |
+
warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
|
| 26 |
+
)
|
| 27 |
+
self._prompt_manager = PromptManager(
|
| 28 |
+
warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Setup for mounted apps
|
| 32 |
+
self._mounted_apps: Dict[str, "FastMCP"] = {}
|
| 33 |
+
|
| 34 |
def get_context(self) -> Context:
|
| 35 |
"""
|
| 36 |
Returns a Context object. Note that the context will only be valid
|
|
|
|
| 41 |
except LookupError:
|
| 42 |
request_context = None
|
| 43 |
return Context(request_context=request_context, fastmcp=self)
|
| 44 |
+
|
| 45 |
+
def mount(self, prefix: str, app: "FastMCP") -> None:
|
| 46 |
+
"""Mount another FastMCP application with a given prefix.
|
| 47 |
+
|
| 48 |
+
When an application is mounted:
|
| 49 |
+
- The tools are imported with prefixed names
|
| 50 |
+
Example: If app has a tool named "get_weather", it will be available as "weather/get_weather"
|
| 51 |
+
- The resources are imported with prefixed URIs
|
| 52 |
+
Example: If app has a resource with URI "weather://forecast", it will be available as "weather+weather://forecast"
|
| 53 |
+
- The templates are imported with prefixed URI templates
|
| 54 |
+
Example: If app has a template with URI "weather://location/{id}", it will be available as "weather+weather://location/{id}"
|
| 55 |
+
- The prompts are imported with prefixed names
|
| 56 |
+
Example: If app has a prompt named "weather_prompt", it will be available as "weather/weather_prompt"
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
prefix: The prefix to use for the mounted application
|
| 60 |
+
app: The FastMCP application to mount
|
| 61 |
+
"""
|
| 62 |
+
# Mount the app in the list of mounted apps
|
| 63 |
+
self._mounted_apps[prefix] = app
|
| 64 |
+
|
| 65 |
+
# Import tools from the mounted app
|
| 66 |
+
self._tool_manager.import_tools(app._tool_manager, prefix)
|
| 67 |
+
|
| 68 |
+
# Import resources from the mounted app
|
| 69 |
+
self._resource_manager.import_resources(app._resource_manager, prefix)
|
| 70 |
+
|
| 71 |
+
# Import resource templates
|
| 72 |
+
self._resource_manager.import_templates(app._resource_manager, prefix)
|
| 73 |
+
|
| 74 |
+
# Import prompts
|
| 75 |
+
self._prompt_manager.import_prompts(app._prompt_manager, prefix)
|
| 76 |
+
|
| 77 |
+
logger.info(f"Mounted app with prefix '{prefix}'")
|
| 78 |
+
logger.debug(f"Imported tools with prefix '{prefix}/'")
|
| 79 |
+
logger.debug(f"Imported resources with prefix '{prefix}+'")
|
| 80 |
+
logger.debug(f"Imported templates with prefix '{prefix}+'")
|
| 81 |
+
logger.debug(f"Imported prompts with prefix '{prefix}/'")
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import mcp.server.fastmcp.tools
|
| 2 |
+
from mcp.server.fastmcp.tools import Tool
|
| 3 |
+
|
| 4 |
+
from fastmcp.utilities.logging import get_logger
|
| 5 |
+
|
| 6 |
+
logger = get_logger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ToolManager(mcp.server.fastmcp.tools.ToolManager):
|
| 10 |
+
"""
|
| 11 |
+
Extended ToolManager that supports importing tools from other managers.
|
| 12 |
+
Adds ability to import tools from other managers with prefixed names.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
def import_tools(self, tool_manager: "ToolManager", prefix: str) -> None:
|
| 16 |
+
"""
|
| 17 |
+
Import all tools from another ToolManager with prefixed names.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
tool_manager: Another ToolManager instance to import tools from
|
| 21 |
+
prefix: Prefix to add to tool names. The resulting tool name will
|
| 22 |
+
be in the format "{prefix}/{original_name}"
|
| 23 |
+
For example, with prefix "weather" and tool "forecast",
|
| 24 |
+
the imported tool would be available as "weather/forecast"
|
| 25 |
+
"""
|
| 26 |
+
for name, tool in tool_manager._tools.items():
|
| 27 |
+
prefixed_name = f"{prefix}/{name}"
|
| 28 |
+
|
| 29 |
+
# Create a shallow copy of the tool with the prefixed name
|
| 30 |
+
copied_tool = Tool.from_function(
|
| 31 |
+
tool.fn,
|
| 32 |
+
name=prefixed_name,
|
| 33 |
+
description=tool.description,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Store the copied tool
|
| 37 |
+
self._tools[prefixed_name] = copied_tool
|
| 38 |
+
logger.debug(f"Imported tool: {name} as {prefixed_name}")
|
tests/prompts/test_prompt_manager.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from mcp.server.fastmcp.prompts import Prompt
|
| 2 |
+
from mcp.server.fastmcp.prompts.base import PromptArgument
|
| 3 |
+
|
| 4 |
+
from fastmcp.prompts.prompt_manager import PromptManager
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_import_prompts():
|
| 8 |
+
"""Test importing prompts from one manager to another with a prefix."""
|
| 9 |
+
# Setup source manager with prompts
|
| 10 |
+
source_manager = PromptManager()
|
| 11 |
+
|
| 12 |
+
# Create test prompts with proper function handlers
|
| 13 |
+
async def summary_fn(**kwargs):
|
| 14 |
+
return [{"role": "assistant", "content": f"Summary of: {kwargs.get('text')}"}]
|
| 15 |
+
|
| 16 |
+
async def translate_fn(**kwargs):
|
| 17 |
+
return [
|
| 18 |
+
{
|
| 19 |
+
"role": "assistant",
|
| 20 |
+
"content": f"Translation to {kwargs.get('language')}: {kwargs.get('text')}",
|
| 21 |
+
}
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
summary_prompt = Prompt(
|
| 25 |
+
name="summary",
|
| 26 |
+
description="Generate a summary of text",
|
| 27 |
+
arguments=[PromptArgument(name="text", description="Text to summarize")],
|
| 28 |
+
fn=summary_fn,
|
| 29 |
+
)
|
| 30 |
+
source_manager._prompts["summary"] = summary_prompt
|
| 31 |
+
|
| 32 |
+
translate_prompt = Prompt(
|
| 33 |
+
name="translate",
|
| 34 |
+
description="Translate text to another language",
|
| 35 |
+
arguments=[
|
| 36 |
+
PromptArgument(name="text", description="Text to translate"),
|
| 37 |
+
PromptArgument(name="language", description="Target language"),
|
| 38 |
+
],
|
| 39 |
+
fn=translate_fn,
|
| 40 |
+
)
|
| 41 |
+
source_manager._prompts["translate"] = translate_prompt
|
| 42 |
+
|
| 43 |
+
# Create target manager
|
| 44 |
+
target_manager = PromptManager()
|
| 45 |
+
|
| 46 |
+
# Import prompts from source to target
|
| 47 |
+
prefix = "nlp"
|
| 48 |
+
target_manager.import_prompts(source_manager, prefix)
|
| 49 |
+
|
| 50 |
+
# Verify prompts were imported with prefixes
|
| 51 |
+
assert "nlp/summary" in target_manager._prompts
|
| 52 |
+
assert "nlp/translate" in target_manager._prompts
|
| 53 |
+
|
| 54 |
+
# Verify the original prompts still exist in source manager
|
| 55 |
+
assert "summary" in source_manager._prompts
|
| 56 |
+
assert "translate" in source_manager._prompts
|
| 57 |
+
|
| 58 |
+
# Verify the imported prompts have the correct properties
|
| 59 |
+
assert target_manager._prompts["nlp/summary"].name == "summary"
|
| 60 |
+
assert (
|
| 61 |
+
target_manager._prompts["nlp/summary"].description
|
| 62 |
+
== "Generate a summary of text"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
assert target_manager._prompts["nlp/translate"].name == "translate"
|
| 66 |
+
assert (
|
| 67 |
+
target_manager._prompts["nlp/translate"].description
|
| 68 |
+
== "Translate text to another language"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Verify functions were properly copied
|
| 72 |
+
if hasattr(target_manager._prompts["nlp/summary"], "fn"):
|
| 73 |
+
assert target_manager._prompts["nlp/summary"].fn.__name__ == summary_fn.__name__
|
| 74 |
+
|
| 75 |
+
if hasattr(target_manager._prompts["nlp/translate"], "fn"):
|
| 76 |
+
assert (
|
| 77 |
+
target_manager._prompts["nlp/translate"].fn.__name__
|
| 78 |
+
== translate_fn.__name__
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_import_prompts_with_duplicates():
|
| 83 |
+
"""Test handling of duplicate prompts during import."""
|
| 84 |
+
# Setup source and target managers with same prompt names
|
| 85 |
+
source_manager = PromptManager()
|
| 86 |
+
target_manager = PromptManager()
|
| 87 |
+
|
| 88 |
+
# Add the same prompt name to both managers with functions
|
| 89 |
+
async def source_fn(**kwargs):
|
| 90 |
+
return [{"role": "assistant", "content": "Source content"}]
|
| 91 |
+
|
| 92 |
+
async def target_fn(**kwargs):
|
| 93 |
+
return [{"role": "assistant", "content": "Target content"}]
|
| 94 |
+
|
| 95 |
+
source_prompt = Prompt(
|
| 96 |
+
name="common",
|
| 97 |
+
description="Source description",
|
| 98 |
+
arguments=None,
|
| 99 |
+
fn=source_fn,
|
| 100 |
+
)
|
| 101 |
+
source_manager._prompts["common"] = source_prompt
|
| 102 |
+
|
| 103 |
+
target_prompt = Prompt(
|
| 104 |
+
name="common",
|
| 105 |
+
description="Target description",
|
| 106 |
+
arguments=None,
|
| 107 |
+
fn=target_fn,
|
| 108 |
+
)
|
| 109 |
+
target_manager._prompts["common"] = target_prompt
|
| 110 |
+
|
| 111 |
+
# Import prompts with prefix
|
| 112 |
+
prefix = "external"
|
| 113 |
+
target_manager.import_prompts(source_manager, prefix)
|
| 114 |
+
|
| 115 |
+
# Verify both prompts exist in target manager
|
| 116 |
+
assert "common" in target_manager._prompts
|
| 117 |
+
assert "external/common" in target_manager._prompts
|
| 118 |
+
|
| 119 |
+
# Verify the functions of both prompts
|
| 120 |
+
if hasattr(target_manager._prompts["common"], "fn") and hasattr(
|
| 121 |
+
target_manager._prompts["external/common"], "fn"
|
| 122 |
+
):
|
| 123 |
+
assert target_manager._prompts["common"].fn.__name__ == target_fn.__name__
|
| 124 |
+
assert (
|
| 125 |
+
target_manager._prompts["external/common"].fn.__name__ == source_fn.__name__
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_import_prompts_with_nested_prefixes():
|
| 130 |
+
"""Test importing already prefixed prompts."""
|
| 131 |
+
# Setup source manager with already prefixed prompts
|
| 132 |
+
first_manager = PromptManager()
|
| 133 |
+
second_manager = PromptManager()
|
| 134 |
+
third_manager = PromptManager()
|
| 135 |
+
|
| 136 |
+
# Add prompt to first manager with a function
|
| 137 |
+
async def analyze_fn(**kwargs):
|
| 138 |
+
return [{"role": "assistant", "content": f"Analysis of: {kwargs.get('text')}"}]
|
| 139 |
+
|
| 140 |
+
original_prompt = Prompt(
|
| 141 |
+
name="analyze",
|
| 142 |
+
description="Analyze text",
|
| 143 |
+
arguments=[PromptArgument(name="text", description="Text to analyze")],
|
| 144 |
+
fn=analyze_fn,
|
| 145 |
+
)
|
| 146 |
+
first_manager._prompts["analyze"] = original_prompt
|
| 147 |
+
|
| 148 |
+
# Import to second manager with prefix
|
| 149 |
+
second_manager.import_prompts(first_manager, "text")
|
| 150 |
+
|
| 151 |
+
# Import from second to third with another prefix
|
| 152 |
+
third_manager.import_prompts(second_manager, "ai")
|
| 153 |
+
|
| 154 |
+
# Verify the nested prefixing
|
| 155 |
+
assert "text/analyze" in second_manager._prompts
|
| 156 |
+
assert "ai/text/analyze" in third_manager._prompts
|
| 157 |
+
|
| 158 |
+
# Verify the properties of the most nested prompt
|
| 159 |
+
assert third_manager._prompts["ai/text/analyze"].name == "analyze"
|
| 160 |
+
assert third_manager._prompts["ai/text/analyze"].description == "Analyze text"
|
| 161 |
+
|
| 162 |
+
# Verify function was properly copied through multiple imports
|
| 163 |
+
if hasattr(third_manager._prompts["ai/text/analyze"], "fn"):
|
| 164 |
+
assert (
|
| 165 |
+
third_manager._prompts["ai/text/analyze"].fn.__name__ == analyze_fn.__name__
|
| 166 |
+
)
|
tests/resources/test_resource_manager.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from mcp.server.fastmcp.resources import FunctionResource, ResourceTemplate
|
| 2 |
+
from pydantic.networks import AnyUrl
|
| 3 |
+
|
| 4 |
+
from fastmcp.resources.resource_manager import ResourceManager
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_import_resources():
|
| 8 |
+
"""Test importing resources from one manager to another with a prefix."""
|
| 9 |
+
# Setup source manager with resources
|
| 10 |
+
source_manager = ResourceManager()
|
| 11 |
+
|
| 12 |
+
# Create mock resource functions
|
| 13 |
+
async def weather_fn():
|
| 14 |
+
return "Weather data"
|
| 15 |
+
|
| 16 |
+
async def traffic_fn():
|
| 17 |
+
return "Traffic data"
|
| 18 |
+
|
| 19 |
+
# Add resources to source manager
|
| 20 |
+
weather_resource = FunctionResource(
|
| 21 |
+
uri=AnyUrl("weather://forecast"),
|
| 22 |
+
name="weather_forecast",
|
| 23 |
+
description="Get weather forecast",
|
| 24 |
+
mime_type="application/json",
|
| 25 |
+
fn=weather_fn,
|
| 26 |
+
)
|
| 27 |
+
source_manager._resources["weather://forecast"] = weather_resource
|
| 28 |
+
|
| 29 |
+
traffic_resource = FunctionResource(
|
| 30 |
+
uri=AnyUrl("traffic://status"),
|
| 31 |
+
name="traffic_status",
|
| 32 |
+
description="Get traffic status",
|
| 33 |
+
mime_type="application/json",
|
| 34 |
+
fn=traffic_fn,
|
| 35 |
+
)
|
| 36 |
+
source_manager._resources["traffic://status"] = traffic_resource
|
| 37 |
+
|
| 38 |
+
# Create target manager
|
| 39 |
+
target_manager = ResourceManager()
|
| 40 |
+
|
| 41 |
+
# Import resources from source to target
|
| 42 |
+
prefix = "data"
|
| 43 |
+
target_manager.import_resources(source_manager, prefix)
|
| 44 |
+
|
| 45 |
+
# Verify resources were imported with prefixes
|
| 46 |
+
assert "data+weather://forecast" in target_manager._resources
|
| 47 |
+
assert "data+traffic://status" in target_manager._resources
|
| 48 |
+
|
| 49 |
+
# Verify the original resources still exist in source manager
|
| 50 |
+
assert "weather://forecast" in source_manager._resources
|
| 51 |
+
assert "traffic://status" in source_manager._resources
|
| 52 |
+
|
| 53 |
+
# Verify the imported resources have the correct properties
|
| 54 |
+
assert (
|
| 55 |
+
target_manager._resources["data+weather://forecast"].name == "weather_forecast"
|
| 56 |
+
)
|
| 57 |
+
assert (
|
| 58 |
+
target_manager._resources["data+weather://forecast"].description
|
| 59 |
+
== "Get weather forecast"
|
| 60 |
+
)
|
| 61 |
+
assert (
|
| 62 |
+
target_manager._resources["data+weather://forecast"].mime_type
|
| 63 |
+
== "application/json"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
assert target_manager._resources["data+traffic://status"].name == "traffic_status"
|
| 67 |
+
assert (
|
| 68 |
+
target_manager._resources["data+traffic://status"].description
|
| 69 |
+
== "Get traffic status"
|
| 70 |
+
)
|
| 71 |
+
assert (
|
| 72 |
+
target_manager._resources["data+traffic://status"].mime_type
|
| 73 |
+
== "application/json"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Since we're dealing with FunctionResource type, we can safely check function attributes
|
| 77 |
+
assert isinstance(
|
| 78 |
+
target_manager._resources["data+weather://forecast"], FunctionResource
|
| 79 |
+
)
|
| 80 |
+
assert isinstance(
|
| 81 |
+
target_manager._resources["data+traffic://status"], FunctionResource
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
weather_resource = target_manager._resources["data+weather://forecast"]
|
| 85 |
+
traffic_resource = target_manager._resources["data+traffic://status"]
|
| 86 |
+
|
| 87 |
+
if hasattr(weather_resource, "fn") and hasattr(traffic_resource, "fn"):
|
| 88 |
+
assert weather_resource.fn.__name__ == weather_fn.__name__
|
| 89 |
+
assert traffic_resource.fn.__name__ == traffic_fn.__name__
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_import_templates():
|
| 93 |
+
"""Test importing resource templates from one manager to another with a prefix."""
|
| 94 |
+
# Setup source manager with templates
|
| 95 |
+
source_manager = ResourceManager()
|
| 96 |
+
|
| 97 |
+
# Create mock template functions
|
| 98 |
+
async def user_fn(**params):
|
| 99 |
+
return f"User data for id {params.get('id')}"
|
| 100 |
+
|
| 101 |
+
async def product_fn(**params):
|
| 102 |
+
return f"Product data for id {params.get('id')}"
|
| 103 |
+
|
| 104 |
+
# Add templates to source manager
|
| 105 |
+
user_template = ResourceTemplate(
|
| 106 |
+
uri_template="api://users/{id}",
|
| 107 |
+
name="user_template",
|
| 108 |
+
description="Get user by ID",
|
| 109 |
+
mime_type="application/json",
|
| 110 |
+
fn=user_fn,
|
| 111 |
+
parameters={"id": {"type": "string", "description": "User ID"}},
|
| 112 |
+
)
|
| 113 |
+
source_manager._templates["api://users/{id}"] = user_template
|
| 114 |
+
|
| 115 |
+
product_template = ResourceTemplate(
|
| 116 |
+
uri_template="api://products/{id}",
|
| 117 |
+
name="product_template",
|
| 118 |
+
description="Get product by ID",
|
| 119 |
+
mime_type="application/json",
|
| 120 |
+
fn=product_fn,
|
| 121 |
+
parameters={"id": {"type": "string", "description": "Product ID"}},
|
| 122 |
+
)
|
| 123 |
+
source_manager._templates["api://products/{id}"] = product_template
|
| 124 |
+
|
| 125 |
+
# Create target manager
|
| 126 |
+
target_manager = ResourceManager()
|
| 127 |
+
|
| 128 |
+
# Import templates from source to target
|
| 129 |
+
prefix = "shop"
|
| 130 |
+
target_manager.import_templates(source_manager, prefix)
|
| 131 |
+
|
| 132 |
+
# Verify templates were imported with prefixes
|
| 133 |
+
assert "shop+api://users/{id}" in target_manager._templates
|
| 134 |
+
assert "shop+api://products/{id}" in target_manager._templates
|
| 135 |
+
|
| 136 |
+
# Verify the original templates still exist in source manager
|
| 137 |
+
assert "api://users/{id}" in source_manager._templates
|
| 138 |
+
assert "api://products/{id}" in source_manager._templates
|
| 139 |
+
|
| 140 |
+
# Verify the imported templates have the correct properties
|
| 141 |
+
assert target_manager._templates["shop+api://users/{id}"].name == "user_template"
|
| 142 |
+
assert (
|
| 143 |
+
target_manager._templates["shop+api://users/{id}"].description
|
| 144 |
+
== "Get user by ID"
|
| 145 |
+
)
|
| 146 |
+
assert (
|
| 147 |
+
target_manager._templates["shop+api://users/{id}"].mime_type
|
| 148 |
+
== "application/json"
|
| 149 |
+
)
|
| 150 |
+
assert target_manager._templates["shop+api://users/{id}"].parameters == {
|
| 151 |
+
"id": {"type": "string", "description": "User ID"}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
assert (
|
| 155 |
+
target_manager._templates["shop+api://products/{id}"].name == "product_template"
|
| 156 |
+
)
|
| 157 |
+
assert (
|
| 158 |
+
target_manager._templates["shop+api://products/{id}"].description
|
| 159 |
+
== "Get product by ID"
|
| 160 |
+
)
|
| 161 |
+
assert (
|
| 162 |
+
target_manager._templates["shop+api://products/{id}"].mime_type
|
| 163 |
+
== "application/json"
|
| 164 |
+
)
|
| 165 |
+
assert target_manager._templates["shop+api://products/{id}"].parameters == {
|
| 166 |
+
"id": {"type": "string", "description": "Product ID"}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
# Verify the template functions were properly copied (only if the fn attribute exists)
|
| 170 |
+
user_template = target_manager._templates["shop+api://users/{id}"]
|
| 171 |
+
product_template = target_manager._templates["shop+api://products/{id}"]
|
| 172 |
+
|
| 173 |
+
if hasattr(user_template, "fn") and hasattr(product_template, "fn"):
|
| 174 |
+
assert user_template.fn.__name__ == user_fn.__name__
|
| 175 |
+
assert product_template.fn.__name__ == product_fn.__name__
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_import_multiple_resource_types():
|
| 179 |
+
"""Test importing both resources and templates with the same prefix."""
|
| 180 |
+
# Setup source manager with both resources and templates
|
| 181 |
+
source_manager = ResourceManager()
|
| 182 |
+
|
| 183 |
+
# Create mock functions
|
| 184 |
+
async def resource_fn():
|
| 185 |
+
return "Resource data"
|
| 186 |
+
|
| 187 |
+
async def template_fn(**params):
|
| 188 |
+
return f"Template data for id {params.get('id')}"
|
| 189 |
+
|
| 190 |
+
# Add a resource to source manager
|
| 191 |
+
resource = FunctionResource(
|
| 192 |
+
uri=AnyUrl("data://resource"),
|
| 193 |
+
name="test_resource",
|
| 194 |
+
description="Test resource",
|
| 195 |
+
mime_type="application/json",
|
| 196 |
+
fn=resource_fn,
|
| 197 |
+
)
|
| 198 |
+
source_manager._resources["data://resource"] = resource
|
| 199 |
+
|
| 200 |
+
# Add a template to source manager
|
| 201 |
+
template = ResourceTemplate(
|
| 202 |
+
uri_template="data://template/{id}",
|
| 203 |
+
name="test_template",
|
| 204 |
+
description="Test template",
|
| 205 |
+
mime_type="application/json",
|
| 206 |
+
fn=template_fn,
|
| 207 |
+
parameters={"id": {"type": "string", "description": "ID parameter"}},
|
| 208 |
+
)
|
| 209 |
+
source_manager._templates["data://template/{id}"] = template
|
| 210 |
+
|
| 211 |
+
# Create target manager
|
| 212 |
+
target_manager = ResourceManager()
|
| 213 |
+
|
| 214 |
+
# Import both resources and templates
|
| 215 |
+
prefix = "test"
|
| 216 |
+
target_manager.import_resources(source_manager, prefix)
|
| 217 |
+
target_manager.import_templates(source_manager, prefix)
|
| 218 |
+
|
| 219 |
+
# Verify both resource types were imported with prefixes
|
| 220 |
+
assert "test+data://resource" in target_manager._resources
|
| 221 |
+
assert "test+data://template/{id}" in target_manager._templates
|
tests/server.py
ADDED
|
File without changes
|
tests/server/test_mount.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastmcp.server.server import FastMCP
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
async def test_mount_basic_functionality():
|
| 5 |
+
"""Test that the mount method properly imports tools and other resources."""
|
| 6 |
+
# Create main app and sub-app
|
| 7 |
+
main_app = FastMCP("MainApp")
|
| 8 |
+
sub_app = FastMCP("SubApp")
|
| 9 |
+
|
| 10 |
+
# Add a tool to the sub-app
|
| 11 |
+
@sub_app.tool()
|
| 12 |
+
def sub_tool() -> str:
|
| 13 |
+
return "This is from the sub app"
|
| 14 |
+
|
| 15 |
+
# Mount the sub-app to the main app
|
| 16 |
+
main_app.mount("sub", sub_app)
|
| 17 |
+
|
| 18 |
+
# Verify the tool was imported with the prefix
|
| 19 |
+
assert "sub/sub_tool" in main_app._tool_manager._tools
|
| 20 |
+
assert "sub_tool" in sub_app._tool_manager._tools
|
| 21 |
+
|
| 22 |
+
# Verify the original tool still exists in the sub-app
|
| 23 |
+
tool = main_app._tool_manager._tools["sub/sub_tool"]
|
| 24 |
+
assert tool.name == "sub/sub_tool"
|
| 25 |
+
assert callable(tool.fn)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def test_mount_multiple_apps():
|
| 29 |
+
"""Test mounting multiple apps to a main app."""
|
| 30 |
+
# Create main app and multiple sub-apps
|
| 31 |
+
main_app = FastMCP("MainApp")
|
| 32 |
+
weather_app = FastMCP("WeatherApp")
|
| 33 |
+
news_app = FastMCP("NewsApp")
|
| 34 |
+
|
| 35 |
+
# Add tools to each sub-app
|
| 36 |
+
@weather_app.tool()
|
| 37 |
+
def get_forecast() -> str:
|
| 38 |
+
return "Weather forecast"
|
| 39 |
+
|
| 40 |
+
@news_app.tool()
|
| 41 |
+
def get_headlines() -> str:
|
| 42 |
+
return "News headlines"
|
| 43 |
+
|
| 44 |
+
# Mount both sub-apps to the main app
|
| 45 |
+
main_app.mount("weather", weather_app)
|
| 46 |
+
main_app.mount("news", news_app)
|
| 47 |
+
|
| 48 |
+
# Verify tools were imported with the correct prefixes
|
| 49 |
+
assert "weather/get_forecast" in main_app._tool_manager._tools
|
| 50 |
+
assert "news/get_headlines" in main_app._tool_manager._tools
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def test_mount_combines_tools():
|
| 54 |
+
"""Test that mounting preserves existing tools with the same prefix."""
|
| 55 |
+
# Create apps
|
| 56 |
+
main_app = FastMCP("MainApp")
|
| 57 |
+
first_app = FastMCP("FirstApp")
|
| 58 |
+
second_app = FastMCP("SecondApp")
|
| 59 |
+
|
| 60 |
+
# Add tools to each sub-app
|
| 61 |
+
@first_app.tool()
|
| 62 |
+
def first_tool() -> str:
|
| 63 |
+
return "First app tool"
|
| 64 |
+
|
| 65 |
+
@second_app.tool()
|
| 66 |
+
def second_tool() -> str:
|
| 67 |
+
return "Second app tool"
|
| 68 |
+
|
| 69 |
+
# Mount first app
|
| 70 |
+
main_app.mount("api", first_app)
|
| 71 |
+
assert "api/first_tool" in main_app._tool_manager._tools
|
| 72 |
+
|
| 73 |
+
# Mount second app to same prefix
|
| 74 |
+
main_app.mount("api", second_app)
|
| 75 |
+
|
| 76 |
+
# Verify second tool is there
|
| 77 |
+
assert "api/second_tool" in main_app._tool_manager._tools
|
| 78 |
+
|
| 79 |
+
# Tools from both mounts are combined
|
| 80 |
+
assert "api/first_tool" in main_app._tool_manager._tools
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def test_mount_with_resources():
|
| 84 |
+
"""Test mounting with resources."""
|
| 85 |
+
# Create apps
|
| 86 |
+
main_app = FastMCP("MainApp")
|
| 87 |
+
data_app = FastMCP("DataApp")
|
| 88 |
+
|
| 89 |
+
# Add a resource to the data app
|
| 90 |
+
@data_app.resource(uri="data://users")
|
| 91 |
+
async def get_users():
|
| 92 |
+
return ["user1", "user2"]
|
| 93 |
+
|
| 94 |
+
# Mount the data app
|
| 95 |
+
main_app.mount("data", data_app)
|
| 96 |
+
|
| 97 |
+
# Verify the resource was imported with the prefix
|
| 98 |
+
assert "data+data://users" in main_app._resource_manager._resources
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def test_mount_with_resource_templates():
|
| 102 |
+
"""Test mounting with resource templates."""
|
| 103 |
+
# Create apps
|
| 104 |
+
main_app = FastMCP("MainApp")
|
| 105 |
+
user_app = FastMCP("UserApp")
|
| 106 |
+
|
| 107 |
+
# Add a resource template to the user app
|
| 108 |
+
@user_app.resource(uri="users://{user_id}/profile")
|
| 109 |
+
def get_user_profile(user_id: str) -> dict:
|
| 110 |
+
return {"id": user_id, "name": f"User {user_id}"}
|
| 111 |
+
|
| 112 |
+
# Mount the user app
|
| 113 |
+
main_app.mount("api", user_app)
|
| 114 |
+
|
| 115 |
+
# Verify the template was imported with the prefix
|
| 116 |
+
assert "api+users://{user_id}/profile" in main_app._resource_manager._templates
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def test_mount_with_prompts():
|
| 120 |
+
"""Test mounting with prompts."""
|
| 121 |
+
# Create apps
|
| 122 |
+
main_app = FastMCP("MainApp")
|
| 123 |
+
assistant_app = FastMCP("AssistantApp")
|
| 124 |
+
|
| 125 |
+
# Add a prompt to the assistant app
|
| 126 |
+
@assistant_app.prompt()
|
| 127 |
+
def greeting(name: str) -> str:
|
| 128 |
+
return f"Hello, {name}!"
|
| 129 |
+
|
| 130 |
+
# Mount the assistant app
|
| 131 |
+
main_app.mount("assistant", assistant_app)
|
| 132 |
+
|
| 133 |
+
# Verify the prompt was imported with the prefix
|
| 134 |
+
assert "assistant/greeting" in main_app._prompt_manager._prompts
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
async def test_mount_multiple_resource_templates():
|
| 138 |
+
"""Test mounting multiple apps with resource templates."""
|
| 139 |
+
# Create apps
|
| 140 |
+
main_app = FastMCP("MainApp")
|
| 141 |
+
weather_app = FastMCP("WeatherApp")
|
| 142 |
+
news_app = FastMCP("NewsApp")
|
| 143 |
+
|
| 144 |
+
# Add templates to each app
|
| 145 |
+
@weather_app.resource(uri="weather://{city}")
|
| 146 |
+
def get_weather(city: str) -> str:
|
| 147 |
+
return f"Weather for {city}"
|
| 148 |
+
|
| 149 |
+
@news_app.resource(uri="news://{category}")
|
| 150 |
+
def get_news(category: str) -> str:
|
| 151 |
+
return f"News for {category}"
|
| 152 |
+
|
| 153 |
+
# Mount both apps
|
| 154 |
+
main_app.mount("data", weather_app)
|
| 155 |
+
main_app.mount("content", news_app)
|
| 156 |
+
|
| 157 |
+
# Verify templates were imported with correct prefixes
|
| 158 |
+
assert "data+weather://{city}" in main_app._resource_manager._templates
|
| 159 |
+
assert "content+news://{category}" in main_app._resource_manager._templates
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
async def test_mount_multiple_prompts():
|
| 163 |
+
"""Test mounting multiple apps with prompts."""
|
| 164 |
+
# Create apps
|
| 165 |
+
main_app = FastMCP("MainApp")
|
| 166 |
+
python_app = FastMCP("PythonApp")
|
| 167 |
+
sql_app = FastMCP("SQLApp")
|
| 168 |
+
|
| 169 |
+
# Add prompts to each app
|
| 170 |
+
@python_app.prompt()
|
| 171 |
+
def review_python(code: str) -> str:
|
| 172 |
+
return f"Reviewing Python code:\n{code}"
|
| 173 |
+
|
| 174 |
+
@sql_app.prompt()
|
| 175 |
+
def explain_sql(query: str) -> str:
|
| 176 |
+
return f"Explaining SQL query:\n{query}"
|
| 177 |
+
|
| 178 |
+
# Mount both apps
|
| 179 |
+
main_app.mount("python", python_app)
|
| 180 |
+
main_app.mount("sql", sql_app)
|
| 181 |
+
|
| 182 |
+
# Verify prompts were imported with correct prefixes
|
| 183 |
+
assert "python/review_python" in main_app._prompt_manager._prompts
|
| 184 |
+
assert "sql/explain_sql" in main_app._prompt_manager._prompts
|
tests/tools/__init__.py
ADDED
|
File without changes
|
tests/tools/test_tool_manager.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastmcp.tools.tool_manager import ToolManager
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_import_tools():
|
| 5 |
+
"""Test importing tools from one manager to another with a prefix."""
|
| 6 |
+
# Setup source manager with tools
|
| 7 |
+
source_manager = ToolManager()
|
| 8 |
+
|
| 9 |
+
# Create some test tools
|
| 10 |
+
def tool1_fn():
|
| 11 |
+
return "Tool 1 result"
|
| 12 |
+
|
| 13 |
+
def tool2_fn():
|
| 14 |
+
return "Tool 2 result"
|
| 15 |
+
|
| 16 |
+
# Add tools to source manager
|
| 17 |
+
source_manager.add_tool(tool1_fn, name="get_data", description="Get some data")
|
| 18 |
+
source_manager.add_tool(
|
| 19 |
+
tool2_fn, name="process_data", description="Process the data"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# Create target manager
|
| 23 |
+
target_manager = ToolManager()
|
| 24 |
+
|
| 25 |
+
# Import tools from source to target
|
| 26 |
+
prefix = "source"
|
| 27 |
+
target_manager.import_tools(source_manager, prefix)
|
| 28 |
+
|
| 29 |
+
# Verify tools were imported with prefixes
|
| 30 |
+
assert "source/get_data" in target_manager._tools
|
| 31 |
+
assert "source/process_data" in target_manager._tools
|
| 32 |
+
|
| 33 |
+
# Verify the original tools still exist in source manager
|
| 34 |
+
assert "get_data" in source_manager._tools
|
| 35 |
+
assert "process_data" in source_manager._tools
|
| 36 |
+
|
| 37 |
+
# Verify the imported tools have the correct descriptions
|
| 38 |
+
assert target_manager._tools["source/get_data"].description == "Get some data"
|
| 39 |
+
assert (
|
| 40 |
+
target_manager._tools["source/process_data"].description == "Process the data"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Verify the tool functions were properly copied
|
| 44 |
+
# We can't directly compare functions, so we'll check their __name__ attribute
|
| 45 |
+
assert target_manager._tools["source/get_data"].fn.__name__ == tool1_fn.__name__
|
| 46 |
+
assert target_manager._tools["source/process_data"].fn.__name__ == tool2_fn.__name__
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_tool_duplicate_behavior():
|
| 50 |
+
"""Test the behavior when importing tools with duplicate names."""
|
| 51 |
+
# Setup source and target managers
|
| 52 |
+
source_manager = ToolManager()
|
| 53 |
+
target_manager = ToolManager()
|
| 54 |
+
|
| 55 |
+
# Add the same tool name to both managers
|
| 56 |
+
def source_fn():
|
| 57 |
+
return "Source result"
|
| 58 |
+
|
| 59 |
+
def target_fn():
|
| 60 |
+
return "Target result"
|
| 61 |
+
|
| 62 |
+
source_manager.add_tool(source_fn, name="common_tool")
|
| 63 |
+
target_manager.add_tool(
|
| 64 |
+
target_fn, name="source/common_tool"
|
| 65 |
+
) # Pre-create with the prefixed name
|
| 66 |
+
|
| 67 |
+
# Import tools from source to target
|
| 68 |
+
target_manager.import_tools(source_manager, "source")
|
| 69 |
+
|
| 70 |
+
# The original tool in the target manager is replaced by the imported one
|
| 71 |
+
assert target_manager._tools["source/common_tool"].fn.__name__ == source_fn.__name__
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_import_tools_with_multiple_prefixes():
|
| 75 |
+
"""Test importing tools from multiple managers with different prefixes."""
|
| 76 |
+
# Setup source managers
|
| 77 |
+
weather_manager = ToolManager()
|
| 78 |
+
news_manager = ToolManager()
|
| 79 |
+
|
| 80 |
+
# Add tools to source managers
|
| 81 |
+
def forecast_fn():
|
| 82 |
+
return "Weather forecast"
|
| 83 |
+
|
| 84 |
+
def headlines_fn():
|
| 85 |
+
return "News headlines"
|
| 86 |
+
|
| 87 |
+
weather_manager.add_tool(forecast_fn, name="forecast")
|
| 88 |
+
news_manager.add_tool(headlines_fn, name="headlines")
|
| 89 |
+
|
| 90 |
+
# Create target manager and import from both sources
|
| 91 |
+
main_manager = ToolManager()
|
| 92 |
+
main_manager.import_tools(weather_manager, "weather")
|
| 93 |
+
main_manager.import_tools(news_manager, "news")
|
| 94 |
+
|
| 95 |
+
# Verify tools were imported with correct prefixes
|
| 96 |
+
assert "weather/forecast" in main_manager._tools
|
| 97 |
+
assert "news/headlines" in main_manager._tools
|
| 98 |
+
|
| 99 |
+
# Verify the tools are accessible and functioning
|
| 100 |
+
assert main_manager._tools["weather/forecast"].fn.__name__ == forecast_fn.__name__
|
| 101 |
+
assert main_manager._tools["news/headlines"].fn.__name__ == headlines_fn.__name__
|
tests/tools/tool_manager.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastmcp.tools.tool_manager import ToolManager
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_import_tools():
|
| 5 |
+
"""Test importing tools from one manager to another with a prefix."""
|
| 6 |
+
# Setup source manager with tools
|
| 7 |
+
source_manager = ToolManager()
|
| 8 |
+
|
| 9 |
+
# Create some test tools
|
| 10 |
+
def tool1_fn():
|
| 11 |
+
return "Tool 1 result"
|
| 12 |
+
|
| 13 |
+
def tool2_fn():
|
| 14 |
+
return "Tool 2 result"
|
| 15 |
+
|
| 16 |
+
# Add tools to source manager
|
| 17 |
+
source_manager.add_tool(tool1_fn, name="get_data", description="Get some data")
|
| 18 |
+
source_manager.add_tool(
|
| 19 |
+
tool2_fn, name="process_data", description="Process the data"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
# Create target manager
|
| 23 |
+
target_manager = ToolManager()
|
| 24 |
+
|
| 25 |
+
# Import tools from source to target
|
| 26 |
+
prefix = "source"
|
| 27 |
+
target_manager.import_tools(source_manager, prefix)
|
| 28 |
+
|
| 29 |
+
# Verify tools were imported with prefixes
|
| 30 |
+
assert "source:get_data" in target_manager._tools
|
| 31 |
+
assert "source:process_data" in target_manager._tools
|
| 32 |
+
|
| 33 |
+
# Verify the original tools still exist in source manager
|
| 34 |
+
assert "get_data" in source_manager._tools
|
| 35 |
+
assert "process_data" in source_manager._tools
|
| 36 |
+
|
| 37 |
+
# Verify the imported tools have the correct descriptions
|
| 38 |
+
assert target_manager._tools["source:get_data"].description == "Get some data"
|
| 39 |
+
assert (
|
| 40 |
+
target_manager._tools["source:process_data"].description == "Process the data"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Verify the tool functions were properly copied
|
| 44 |
+
# We can't directly compare functions, so we'll check their __name__ attribute
|
| 45 |
+
assert target_manager._tools["source:get_data"].fn.__name__ == tool1_fn.__name__
|
| 46 |
+
assert target_manager._tools["source:process_data"].fn.__name__ == tool2_fn.__name__
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_import_tools_duplicate_warning(caplog):
|
| 50 |
+
"""Test that warning is logged when importing a tool with a name that already exists."""
|
| 51 |
+
# Setup source and target managers
|
| 52 |
+
source_manager = ToolManager()
|
| 53 |
+
target_manager = ToolManager(warn_on_duplicate_tools=True)
|
| 54 |
+
|
| 55 |
+
# Add the same tool name to both managers
|
| 56 |
+
def source_fn():
|
| 57 |
+
return "Source result"
|
| 58 |
+
|
| 59 |
+
def target_fn():
|
| 60 |
+
return "Target result"
|
| 61 |
+
|
| 62 |
+
source_manager.add_tool(source_fn, name="common_tool")
|
| 63 |
+
target_manager.add_tool(
|
| 64 |
+
target_fn, name="source:common_tool"
|
| 65 |
+
) # Pre-create with the prefixed name
|
| 66 |
+
|
| 67 |
+
# Import tools from source to target
|
| 68 |
+
target_manager.import_tools(source_manager, "source")
|
| 69 |
+
|
| 70 |
+
# Verify a warning was logged
|
| 71 |
+
assert any("already exists" in record.message for record in caplog.records)
|
| 72 |
+
|
| 73 |
+
# The original tool in the target manager should be preserved
|
| 74 |
+
assert target_manager._tools["source:common_tool"].fn.__name__ == target_fn.__name__
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_import_tools_with_multiple_prefixes():
|
| 78 |
+
"""Test importing tools from multiple managers with different prefixes."""
|
| 79 |
+
# Setup source managers
|
| 80 |
+
weather_manager = ToolManager()
|
| 81 |
+
news_manager = ToolManager()
|
| 82 |
+
|
| 83 |
+
# Add tools to source managers
|
| 84 |
+
def forecast_fn():
|
| 85 |
+
return "Weather forecast"
|
| 86 |
+
|
| 87 |
+
def headlines_fn():
|
| 88 |
+
return "News headlines"
|
| 89 |
+
|
| 90 |
+
weather_manager.add_tool(forecast_fn, name="forecast")
|
| 91 |
+
news_manager.add_tool(headlines_fn, name="headlines")
|
| 92 |
+
|
| 93 |
+
# Create target manager and import from both sources
|
| 94 |
+
main_manager = ToolManager()
|
| 95 |
+
main_manager.import_tools(weather_manager, "weather")
|
| 96 |
+
main_manager.import_tools(news_manager, "news")
|
| 97 |
+
|
| 98 |
+
# Verify tools were imported with correct prefixes
|
| 99 |
+
assert "weather:forecast" in main_manager._tools
|
| 100 |
+
assert "news:headlines" in main_manager._tools
|
| 101 |
+
|
| 102 |
+
# Verify the tools are accessible and functioning
|
| 103 |
+
assert main_manager._tools["weather:forecast"].fn.__name__ == forecast_fn.__name__
|
| 104 |
+
assert main_manager._tools["news:headlines"].fn.__name__ == headlines_fn.__name__
|