Jeremiah Lowin commited on
Commit
d4adea4
·
1 Parent(s): 9bb4597

Support mounting FastMCPs as sub-servers

Browse files
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__