diff --git a/README.md b/README.md
index 387b50437..a27c58fa5 100644
--- a/README.md
+++ b/README.md
@@ -93,7 +93,7 @@ FastMCP provides a high-level, Pythonic interface for building and interacting w
## Why FastMCP?
-The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It’s designed to be high-level and Pythonic; in most cases, decorating a function is all you need.
+The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic; in most cases, decorating a function is all you need.
FastMCP aims to be:
@@ -552,13 +552,13 @@ proxy_client = Client(
)
# Create a proxy server that connects to the client and exposes its capabilities
-proxy = FastMCP.as_proxy(proxy_client, name="Stdio-to-SSE Proxy")
+proxy = FastMCP.from_client(proxy_client, name="Stdio-to-SSE Proxy")
if __name__ == "__main__":
proxy.run(transport='sse')
```
-`FastMCP.as_proxy` is an `async` classmethod. It connects to the target, discovers its capabilities, and dynamically builds the proxy server instance.
+`FastMCP.from_client` is a class method that connects to the target, discovers its capabilities, and dynamically builds the proxy server instance.
diff --git a/docs/clients/overview.mdx b/docs/clients/client.mdx
similarity index 97%
rename from docs/clients/overview.mdx
rename to docs/clients/client.mdx
index 7f4212f08..479120ce6 100644
--- a/docs/clients/overview.mdx
+++ b/docs/clients/client.mdx
@@ -5,6 +5,10 @@ description: Learn how to use the FastMCP Client to interact with MCP servers.
icon: user-robot
---
+import { VersionBadge } from '/snippets/version-badge.mdx'
+
+
+
The `fastmcp.Client` provides a high-level, asynchronous interface for interacting with any Model Context Protocol (MCP) server, whether it's built with FastMCP or another implementation. It simplifies communication by handling protocol details and connection management.
## FastMCP Client
@@ -248,5 +252,5 @@ async def safe_call_tool():
Other errors, like connection failures, will raise standard Python exceptions (e.g., `ConnectionError`, `TimeoutError`).
-The client transport often has its own error-handling mechanisms, so you can not always trap errors like those raised by `call_tool` outside of the `async with` block. Instead, you can call `call_tools(..., return_raw_result=True)` to get the raw result object and handle errors yourself by checking its `isError` attribute.
+The client transport often has its own error-handling mechanisms, so you can not always trap errors like those raised by `call_tool` outside of the `async with` block. Instead, you can call `call_tool(..., _return_raw_result=True)` to get the raw `mcp.types.CallToolResult` object and handle errors yourself by checking its `isError` attribute.
diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx
index b86ca3d65..a64fb0c25 100644
--- a/docs/clients/transports.mdx
+++ b/docs/clients/transports.mdx
@@ -7,7 +7,7 @@ icon: link
The FastMCP `Client` relies on a `ClientTransport` object to handle the specifics of connecting to and communicating with an MCP server. FastMCP provides several built-in transport implementations for common connection methods.
-While the `Client` often infers the correct transport automatically (see [Client Overview](/clients/overview#transport-inference)), you can also instantiate transports explicitly for more control.
+While the `Client` often infers the correct transport automatically (see [Client Overview](/clients/client#transport-inference)), you can also instantiate transports explicitly for more control.
## Stdio Transports
diff --git a/docs/docs.json b/docs/docs.json
index dc7d9197d..237dee35b 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -50,14 +50,14 @@
{
"group": "Clients",
"pages": [
- "clients/overview",
+ "clients/client",
"clients/transports"
]
},
{
"group": "Patterns",
"pages": [
- "patterns/proxying",
+ "patterns/proxy",
"patterns/composition",
"patterns/decorating-methods",
"patterns/openapi",
diff --git a/docs/patterns/composition.mdx b/docs/patterns/composition.mdx
index f9a531372..6d620954f 100644
--- a/docs/patterns/composition.mdx
+++ b/docs/patterns/composition.mdx
@@ -1,11 +1,17 @@
---
title: Server Composition
sidebarTitle: Composition
-description: Combine multiple FastMCP servers into a single, larger application using mounting.
+description: Combine multiple FastMCP servers into a single, larger application using mounting and importing.
icon: puzzle-piece
---
+import { VersionBadge } from '/snippets/version-badge.mdx'
-As your MCP applications grow, you might want to organize your tools, resources, and prompts into logical modules or reuse existing server components. FastMCP supports composition through the `server.mount()` method, allowing you to combine multiple `FastMCP` instances into a single, unified server.
+
+
+As your MCP applications grow, you might want to organize your tools, resources, and prompts into logical modules or reuse existing server components. FastMCP supports composition through two methods:
+
+- **`import_server`**: For a one-time copy of components with prefixing (static composition).
+- **`mount`**: For creating a live link where the main server delegates requests to the subserver (dynamic composition).
## Why Compose Servers?
@@ -14,13 +20,32 @@ As your MCP applications grow, you might want to organize your tools, resources,
- **Teamwork**: Different teams can work on separate FastMCP servers that are later combined.
- **Organization**: Keep related functionality grouped together logically.
-## Mounting Subservers
+### Importing vs Mounting
-The `mount()` method attaches all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) onto another (the *main server*). A `prefix` is added to avoid naming conflicts.
+The choice of importing or mounting depends on your use case and requirements. In general, importing is best for simpler cases because it copies the imported server's components into the main server, treating them as native integrations. Mounting is best for more complex cases where you need to delegate requests to the subserver at runtime.
+
+
+| Feature | Importing | Mounting |
+|---------|----------------|---------|
+| **Method** | `FastMCP.import_server()` | `FastMCP.mount()` |
+| **Composition Type** | One-time copy (static) | Live link (dynamic) |
+| **Updates** | Changes to subserver NOT reflected | Changes to subserver immediately reflected |
+| **Lifespan** | Not managed | Automatically managed |
+| **Synchronicity** | Async (must be awaited) | Sync |
+| **Best For** | Bundling finalized components | Modular runtime composition |
+
+### Proxy Servers
+
+FastMCP supports [MCP proxying](/patterns/proxy), which allows you to mirror a local or remote server in a local FastMCP instance. Proxies are fully compatible with both importing and mounting.
+
+
+## Importing (Static Composition)
+
+The `import_server()` method copies all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) into another (the *main server*). A `prefix` is added to avoid naming conflicts.
```python
from fastmcp import FastMCP
-from typing import dict, list
+import asyncio
# --- Define Subservers ---
@@ -53,14 +78,15 @@ def explain_addition() -> str:
# --- Define Main Server ---
main_mcp = FastMCP(name="MainApp")
-# --- Mount Subservers ---
-# Mount weather service with prefix "weather"
-main_mcp.mount("weather", weather_mcp)
+# --- Import Subservers ---
+async def setup():
+ # Import weather service with prefix "weather"
+ await main_mcp.import_server("weather", weather_mcp)
-# Mount calculator service with prefix "calc"
-main_mcp.mount("calc", calc_mcp)
+ # Import calculator service with prefix "calc"
+ await main_mcp.import_server("calc", calc_mcp)
-# --- Now, main_mcp contains combined components ---
+# --- Now, main_mcp contains *copied* components ---
# Tools:
# - "weather_get_forecast"
# - "calc_add"
@@ -70,13 +96,15 @@ main_mcp.mount("calc", calc_mcp)
# - "calc_explain_addition"
if __name__ == "__main__":
+ # In a real app, you might run this async or setup imports differently
+ asyncio.run(setup())
# Run the main server, which now includes components from both subservers
main_mcp.run()
```
-### How Mounting Works
+### How Importing Works
-When you call `main_mcp.mount(prefix, subserver)`:
+When you call `await main_mcp.import_server(prefix, subserver)`:
1. **Tools**: All tools from `subserver` are added to `main_mcp`. Their names are automatically prefixed using the `prefix` and a default separator (`_`).
- `subserver.tool(name="my_tool")` becomes `main_mcp.tool(name="{prefix}_my_tool")`.
@@ -86,14 +114,15 @@ When you call `main_mcp.mount(prefix, subserver)`:
- `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="{prefix}+data://{id}")`.
4. **Prompts**: All prompts from `subserver` are added, with names prefixed like tools.
- `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`.
-5. **Lifespan Management**: If the `subserver` has a `lifespan` function defined, it will be automatically executed within the `main_mcp`'s lifespan context. This ensures that setup and teardown logic for the subserver runs correctly.
+
+Note that `import_server` performs a **one-time copy** of components from the `subserver` into the `main_mcp` instance at the time the method is called. Changes made to the `subserver` *after* `import_server` is called **will not** be reflected in `main_mcp`. Also, the `subserver`'s `lifespan` context is **not** executed by the main server when using `import_server`.
### Customizing Separators
-You might prefer different separators for the prefixed names and URIs. You can customize these when calling `mount()`:
+You might prefer different separators for the prefixed names and URIs. You can customize these when calling `import_server()`:
```python
-main_mcp.mount(
+await main_mcp.import_server(
prefix="api",
app=some_subserver,
tool_separator="/", # Tool name becomes: "api/sub_tool_name"
@@ -106,12 +135,125 @@ main_mcp.mount(
Be cautious when choosing separators. Some MCP clients (like Claude Desktop) might have restrictions on characters allowed in tool names (e.g., `/` might not be supported). The defaults (`_` for names, `+` for URIs) are generally safe.
-## Example: Modular Application
+## Mounting (Live Linking)
+
+The `mount()` method creates a **live link** between the `main_mcp` server and the `subserver`. Instead of copying components, requests for components matching the `prefix` are **delegated** to the `subserver` at runtime.
```python
-# modules/text_utils.py
+import asyncio
+from fastmcp import FastMCP, Client
+
+# --- Define Subserver ---
+dynamic_mcp = FastMCP(name="DynamicService")
+@dynamic_mcp.tool()
+def initial_tool(): return "Initial Tool Exists"
+
+# --- Define Main Server ---
+main_mcp = FastMCP(name="MainAppLive")
+
+# --- Mount Subserver (Sync operation) ---
+main_mcp.mount("dynamic", dynamic_mcp)
+
+print("Mounted dynamic_mcp.")
+
+# --- Add a tool AFTER mounting ---
+@dynamic_mcp.tool()
+def added_later(): return "Tool Added Dynamically!"
+
+print("Added 'added_later' tool to dynamic_mcp.")
+
+# --- Test Access ---
+async def test_dynamic_mount():
+ # Need to use await for get_tools now
+ tools_before = await main_mcp.get_tools()
+ print("Tools available via main_mcp:", list(tools_before.keys()))
+ # Expected: ['dynamic_initial_tool', 'dynamic_added_later']
+
+ async with Client(main_mcp) as client:
+ # Call the dynamically added tool via the main server
+ result = await client.call_tool("dynamic_added_later")
+ print("Result of calling dynamic_added_later:", result[0].text)
+ # Expected: Tool Added Dynamically!
+
+if __name__ == "__main__":
+ # Need async context to test
+ asyncio.run(test_dynamic_mount())
+ # To run the server itself:
+ # main_mcp.run()
+```
+
+### How Mounting Works
+
+When you call `main_mcp.mount(prefix, server)`:
+
+1. **Live Link**: A live connection is established between `main_mcp` and the `subserver`.
+2. **Dynamic Updates**: Changes made to the `subserver` (e.g., adding new tools) **will be reflected** immediately when accessing components through `main_mcp`.
+3. **Lifespan Management**: The `subserver`'s `lifespan` context **is automatically managed** and executed within the `main_mcp`'s lifespan.
+4. **Delegation**: Requests for components matching the prefix are delegated to the subserver at runtime.
+
+The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts.
+
+### Customizing Separators
+
+Similar to `import_server`, you can customize the separators for the prefixed names and URIs:
+
+```python
+main_mcp.mount(
+ prefix="api",
+ app=some_subserver,
+ tool_separator="/", # Tool name becomes: "api/sub_tool_name"
+ resource_separator=":", # Resource URI becomes: "api:data://sub_resource"
+ prompt_separator="." # Prompt name becomes: "api.sub_prompt_name"
+)
+```
+
+
+## Example: Modular Application
+
+Here's how a modular application might use `import_server`:
+
+
+```python main.py
+from fastmcp import FastMCP
+import asyncio
+
+# Import the servers (see other files)
+from modules.text_server import text_mcp
+from modules.data_server import data_mcp
+
+app = FastMCP(name="MainApplication")
+
+# Setup function for async imports
+async def setup():
+ # Import the utility servers
+ await app.import_server("text", text_mcp)
+ await app.import_server("data", data_mcp)
+
+@app.tool()
+def process_and_analyze(record_id: int) -> str:
+ """Fetches a record and analyzes its string representation."""
+ # In a real application, you'd use proper methods to interact between
+ # imported tools rather than accessing internal managers
+
+ # Get record data
+ record = {"id": record_id, "value": random.random()}
+
+ # Count words in the record string representation
+ word_count = len(str(record).split())
+
+ return (
+ f"Record {record_id} has {word_count} words in its string "
+ f"representation."
+ )
+
+if __name__ == "__main__":
+ # Run async setup before starting the server
+ asyncio.run(setup())
+ # Run the server
+ app.run()
+```
+```python modules/text_server.py
from fastmcp import FastMCP
-from typing import list
text_mcp = FastMCP(name="TextUtilities")
@@ -124,9 +266,9 @@ def count_words(text: str) -> int:
def get_stopwords() -> list[str]:
"""Return a list of common stopwords."""
return ["the", "a", "is", "in"]
+```
-# ------------------------------
-# modules/data_api.py
+```python modules/data_server.py
from fastmcp import FastMCP
import random
from typing import dict
@@ -142,41 +284,10 @@ def fetch_record(record_id: int) -> dict:
def get_table_schema(table: str) -> dict:
"""Provides a dummy schema for a table."""
return {"table": table, "columns": ["id", "value"]}
-
-# ------------------------------
-# main_app.py
-from fastmcp import FastMCP
-from modules.text_utils import text_mcp # Import server instances
-from modules.data_api import data_mcp
-
-app = FastMCP(name="MainApplication")
-
-# Mount the utility servers
-app.mount("text", text_mcp)
-app.mount("data", data_mcp)
-
-@app.tool()
-def process_and_analyze(record_id: int) -> str:
- """Fetches a record and analyzes its string representation."""
- # In a real application, you'd use proper methods to interact between
- # mounted tools rather than accessing internal managers
-
- # Get record data
- record = {"id": record_id, "value": random.random()}
-
- # Count words in the record string representation
- word_count = len(str(record).split())
-
- return (
- f"Record {record_id} has {word_count} words in its string "
- f"representation."
- )
-
-if __name__ == "__main__":
- app.run()
```
-Now, running `main_app.py` starts a server that exposes:
+
+Now, running `main.py` starts a server that exposes:
- `text_count_words`
- `data_fetch_record`
- `process_and_analyze`
diff --git a/docs/patterns/fastapi.mdx b/docs/patterns/fastapi.mdx
index 45e9d2e78..a5501a225 100644
--- a/docs/patterns/fastapi.mdx
+++ b/docs/patterns/fastapi.mdx
@@ -1,114 +1,123 @@
---
title: FastAPI Integration
sidebarTitle: FastAPI
-description: Automatically create FastMCP servers directly from FastAPI applications.
+description: Generate MCP servers from FastAPI apps
icon: square-bolt
---
+import { VersionBadge } from '/snippets/version-badge.mdx'
-If you build your APIs using the popular [FastAPI](https://fastapi.tiangolo.com/) framework, FastMCP offers a seamless way to expose your FastAPI application as an MCP server. This leverages the OpenAPI integration internally but simplifies the setup significantly.
+
-## The Goal: FastAPI App -> MCP Server
-FastAPI automatically generates an OpenAPI specification for your application. FastMCP uses this built-in capability to create an MCP server that mirrors your API routes.
+FastMCP can automatically convert FastAPI applications into MCP servers.
-- FastAPI path operations (`@app.get`, `@app.post`, etc.) become MCP tools, resources, or templates.
-- Pydantic models used in FastAPI for request/response validation are used to generate MCP schemas.
-- Communication happens directly in memory, making it very efficient.
+
+FastMCP does *not* include FastAPI as a dependency; you must install it separately to run these examples.
+
-## Creating from FastAPI App
-Use the `FastMCP.from_fastapi()` class method. You only need your FastAPI `app` instance.
+```python {2, 22, 25}
+from fastapi import FastAPI
+from fastmcp import FastMCP
+
+
+# A FastAPI app
+app = FastAPI()
+
+@app.get("/items")
+def list_items():
+ return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
+
+@app.get("/items/{item_id}")
+def get_item(item_id: int):
+ return {"id": item_id, "name": f"Item {item_id}"}
+
+@app.post("/items")
+def create_item(name: str):
+ return {"id": 3, "name": name}
+
+
+# Create an MCP server from your FastAPI app
+mcp = FastMCP.from_fastapi(app=app)
+
+if __name__ == "__main__":
+ mcp.run() # Start the MCP server
+```
+
+## Route Mapping
+
+By default, FastMCP will map FastAPI routes to MCP components according to the following rules:
+
+| FastAPI Route Type | FastAPI Example | MCP Component | Notes |
+|--------------------|--------------|---------|-------|
+| GET without path params | `@app.get("/stats")` | Resource | Simple resources for fetching data |
+| GET with path params | `@app.get("/users/{id}")` | Resource Template | Path parameters become template parameters |
+| POST, PUT, DELETE, etc. | `@app.post("/users")` | Tool | Operations that modify data |
+
+For more details on route mapping or custom mapping rules, see the [OpenAPI integration documentation](/patterns/openapi#route-mapping); FastMCP uses the same mapping rules for both FastAPI and OpenAPI integrations.
+
+## Complete Example
+
+Here's a more detailed example with a data model:
```python
import asyncio
-from fastapi import FastAPI
+from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
-from fastmcp import FastMCP, Client # Import FastMCP and Client
-
-# 1. Define your FastAPI application
-api_app = FastAPI(title="MyFastAPIApp")
+from fastmcp import FastMCP, Client
+# Define your Pydantic model
class Item(BaseModel):
name: str
price: float
- is_offer: bool | None = None
-@api_app.get("/")
-def read_root():
- return {"Hello": "World"}
+# Create your FastAPI app
+app = FastAPI()
+items = {} # In-memory database
-@api_app.get("/items/{item_id}") # -> Resource Template
-def read_item(item_id: int, q: str | None = None):
- # This will become resource://openapi/read_item_items__item_id__get/{item_id}
- return {"item_id": item_id, "q": q, "description": f"Details for item {item_id}"}
+@app.get("/items")
+def list_items():
+ """List all items"""
+ return list(items.values())
-@api_app.post("/items/") # -> Tool
+@app.get("/items/{item_id}")
+def get_item(item_id: int):
+ """Get item by ID"""
+ if item_id not in items:
+ raise HTTPException(404, "Item not found")
+ return items[item_id]
+
+@app.post("/items")
def create_item(item: Item):
- # This will become the 'create_item_items__post' tool
- print(f"Creating item: {item.name}")
- return {"item_name": item.name, "status": "created"}
+ """Create a new item"""
+ item_id = len(items) + 1
+ items[item_id] = {"id": item_id, **item.model_dump()}
+ return items[item_id]
-# 2. Create the FastMCP server directly from the FastAPI app
-# This is an async class method
-async def create_mcp_server_from_fastapi():
- mcp_server = await FastMCP.from_fastapi(
- app=api_app,
- name="FastAPI_MCP_Bridge" # Optional name for the MCP server
- )
- return mcp_server
-
-# 3. (Example) Run the MCP server and test with an in-memory client
-async def run_and_test():
- server = await create_mcp_server_from_fastapi()
- print(f"Created MCP server '{server.name}' from FastAPI app '{api_app.title}'")
-
- # List discovered components
- tools = await server.list_tools()
- templates = await server.list_resource_templates()
- print("Discovered Tools:", [t.name for t in tools])
- print("Discovered Templates:", [t.uriTemplate for t in templates])
-
- # Test using an in-memory client
- client = Client(server) # Uses FastMCPTransport
- async with client:
- # Call the tool derived from POST /items/
- create_result = await client.call_tool(
- "create_item_items__post",
- {"name": "MCP Special", "price": 99.99} # Pydantic model fields become args
- )
- print("Create Item Tool Result:", create_result[0].text) # JSON string
-
- # Read the resource derived from GET /items/{item_id}
- read_result = await client.read_resource(
- "resource://openapi/read_item_items__item_id__get/42" # Match template URI
- )
- print("Read Item Resource Result:", read_result[0].text) # JSON string
-
- # In a real scenario, you might run the MCP server via stdio or sse
- # print("Running MCP server via stdio...")
- # server.run()
+# Test your MCP server with a client
+async def test():
+ # Create MCP server from FastAPI app
+ mcp = await FastMCP.from_fastapi(app=app)
+
+ # List the components that were created
+ tools = await mcp.list_tools()
+ resources = await mcp.list_resources()
+ templates = await mcp.list_resource_templates()
+
+ print(f"Generated {len(tools)} tools")
+ print(f"Generated {len(resources)} resources")
+ print(f"Generated {len(templates)} templates")
+
+ # In a real scenario, you would run the server:
+ # mcp.run()
if __name__ == "__main__":
- # Requires fastapi, uvicorn, httpx:
- # uv pip install "fastapi[all]" httpx
- try:
- asyncio.run(run_and_test())
- except ImportError as e:
- print(f"Error: {e}. Please install required packages: uv pip install \"fastapi[all]\" httpx")
-
-# Example Output might include:
-# Created MCP server 'FastAPI_MCP_Bridge' from FastAPI app 'MyFastAPIApp'
-# Discovered Tools: ['read_root___get', 'create_item_items__post']
-# Discovered Templates: ['resource://openapi/read_item_items__item_id__get/{item_id}']
-# Create Item Tool Result: {"item_name": "MCP Special", "status": "created"}
-# Read Item Resource Result: {"item_id": 42, "q": null, "description": "Details for item 42"}
+ asyncio.run(test())
```
-### How it Works Internally
+## Benefits
-1. **OpenAPI Generation**: `from_fastapi` asks the FastAPI `app` for its OpenAPI schema dictionary (`app.openapi()`).
-2. **In-Memory Client**: It creates an `httpx.AsyncClient` configured with an `ASGITransport`. This special transport allows `httpx` to call the FastAPI application directly in memory without needing a running web server process.
-3. **OpenAPI Integration**: It calls `FastMCP.from_openapi()`, passing the generated schema and the in-memory `httpx` client.
-4. **MCP Server Creation**: The standard OpenAPI integration logic then proceeds to parse the schema and create the `Tool`, `Resource`, and `ResourceTemplate` components that wrap calls to the in-memory FastAPI app.
-
-This provides a highly efficient way to expose your FastAPI logic through the MCP protocol, leveraging FastAPI's routing, dependency injection, and validation features.
\ No newline at end of file
+- **Leverage existing FastAPI apps** - No need to rewrite your API logic
+- **Schema reuse** - FastAPI's Pydantic models and validation are inherited
+- **Full feature support** - Works with FastAPI's authentication, dependencies, etc.
+- **ASGI transport** - Direct communication without additional HTTP overhead
diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx
index 1767700c2..ca8df1983 100644
--- a/docs/patterns/openapi.mdx
+++ b/docs/patterns/openapi.mdx
@@ -1,174 +1,156 @@
---
title: OpenAPI Integration
sidebarTitle: OpenAPI
-description: Automatically create FastMCP servers from existing OpenAPI specifications.
+description: Generate MCP servers from OpenAPI specs
icon: code-branch
---
+import { VersionBadge } from '/snippets/version-badge.mdx'
-If you have existing REST APIs documented with the OpenAPI Specification (OAS), FastMCP can automatically generate MCP tools, resources, and resource templates directly from that specification. This provides a quick way to make your existing HTTP APIs accessible to MCP clients and LLMs.
+
-FastMCP supports both OpenAPI 3.0 and 3.1 specifications for maximum compatibility with existing API definitions.
+FastMCP can automatically generate an MCP server from an OpenAPI specification. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client.
-## The Goal: API -> MCP Server
+```python
+import httpx
+from fastmcp import FastMCP
-The core idea is to map OpenAPI paths and operations (like `GET /users/{id}` or `POST /orders`) to their corresponding MCP components:
+# Create a client for your API
+api_client = httpx.AsyncClient(base_url="https://api.example.com")
-- `GET` requests often map to MCP **Resources** (for fetching single items) or **Resource Templates** (if the path has parameters).
-- `POST`, `PUT`, `PATCH`, `DELETE` requests typically map to MCP **Tools** (for actions that create or modify data).
+# Load your OpenAPI spec
+spec = {...}
-FastMCP automates this mapping process.
+# Create an MCP server from your OpenAPI spec
+mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
-## Creating from OpenAPI Spec
+if __name__ == "__main__":
+ mcp.run()
+```
-Use the `FastMCP.from_openapi()` class method. You need:
+## Route Mapping
-1. The OpenAPI specification as a Python dictionary.
-2. An `httpx.AsyncClient` configured to make requests to the actual API backend.
+By default, OpenAPI routes are mapped to MCP components based on these rules:
-
+| OpenAPI Route | Example |MCP Component | Notes |
+|- | - | - | - |
+| `GET` without path params | `GET /stats` | Resource | Simple resources for fetching data |
+| `GET` with path params | `GET /users/{id}` | Resource Template | Path parameters become template parameters |
+| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool | Operations that modify data |
-```python server.py
+
+Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determine the component type. Route maps indicate that a specific HTTP method (or methods) and path pattern should be treated as a specific component type. This is the default set of route maps:
+
+```python
+# Simplified version of the actual mapping rules
+DEFAULT_ROUTE_MAPPINGS = [
+ # GET with path parameters -> ResourceTemplate
+ RouteMap(methods=["GET"], pattern=r".*\{.*\}.*",
+ route_type=RouteType.RESOURCE_TEMPLATE),
+
+ # GET without path parameters -> Resource
+ RouteMap(methods=["GET"], pattern=r".*",
+ route_type=RouteType.RESOURCE),
+
+ # All other methods -> Tool
+ RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
+ pattern=r".*", route_type=RouteType.TOOL),
+]
+```
+
+### Custom Route Maps
+
+Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps.
+
+```python
+from fastmcp.server.openapi import RouteMap, RouteType
+
+# Custom mapping rules
+custom_maps = [
+ # Force all analytics endpoints to be Tools
+ RouteMap(methods=["GET"],
+ pattern=r"^/analytics/.*",
+ route_type=RouteType.TOOL)
+]
+
+# Apply custom mappings
+mcp = await FastMCP.from_openapi(
+ openapi_spec=spec,
+ client=api_client,
+ route_maps=custom_maps
+)
+```
+
+## How It Works
+
+1. FastMCP parses your OpenAPI spec to extract routes and schemas
+2. It applies mapping rules to categorize each route
+3. When an MCP client calls a tool or accesses a resource:
+ - FastMCP constructs an HTTP request based on the OpenAPI definition
+ - It sends the request through the provided httpx client
+ - It translates the HTTP response to the appropriate MCP format
+
+## Complete Example
+
+```python
import asyncio
import httpx
from fastmcp import FastMCP
-# load the OpenAPI specification from the openapi_spec.py file
-petstore_spec = PETSTORE_SPEC
-
-# Client to communicate with the actual Pet Store API backend
-# The base_url should match the server URL in the OpenAPI spec
-http_client = httpx.AsyncClient(base_url="http://petstore.example.com/api")
-
-# Create the FastMCP server from the spec
-# This is an async class method
-async def create_openapi_server():
- mcp_server = await FastMCP.from_openapi(
- openapi_spec=petstore_spec,
- client=http_client,
- name="PetStoreMCP" # Optional name for the MCP server
- )
- return mcp_server
-
-async def run_server():
- server = await create_openapi_server()
- print(f"Starting OpenAPI-based server '{server.name}'...")
-
- # List discovered components
- tools = await server.list_tools()
- resources = await server.list_resources()
- templates = await server.list_resource_templates()
- print("Discovered Tools:", [t.name for t in tools])
- print("Discovered Resources:", [r.uri for r in resources]) # Should be empty if no parameterless GETs
- print("Discovered Templates:", [t.uriTemplate for t in templates])
-
- # Run the server (e.g., via stdio)
- # server.run()
-
-if __name__ == "__main__":
- # Example: Create the server and print discovered components
- # Requires httpx: uv pip install httpx
- asyncio.run(run_server())
-
-# Expected Output might include:
-# Discovered Tools: ['listPets', 'createPet']
-# Discovered Resources: []
-# Discovered Templates: ['resource://openapi/showPetById/{petId}']
-```
-
-```python openapi_spec.py
-# Example OpenAPI Specification (simplified Pet Store)
-PETSTORE_SPEC = {
- "openapi": "3.1.0",
- "info": {"title": "Simple Pet Store", "version": "1.0.0"},
- "servers": [{"url": "http://petstore.example.com/api"}], # Base URL for API calls
+# Sample OpenAPI spec for a Pet Store API
+petstore_spec = {
+ "openapi": "3.0.0",
"paths": {
"/pets": {
"get": {
- "summary": "List all pets",
"operationId": "listPets",
- "tags": ["pets"],
- "parameters": [{ # Query parameter -> Tool argument
- "name": "limit", "in": "query", "schema": {"type": "integer"}
- }],
- "responses": {"200": {"description": "A list of pets."}},
+ "summary": "List all pets"
},
- "post": { # POST -> Tool
- "summary": "Create a pet",
+ "post": {
"operationId": "createPet",
- "tags": ["pets"],
- "requestBody": { # Request body -> Tool arguments
- "required": True,
- "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PetInput"}}}
- },
- "responses": {"201": {"description": "Pet created."}},
- },
+ "summary": "Create a new pet"
+ }
},
- "/pets/{petId}": { # Path parameter -> Resource Template
- "get": { # GET with path param -> Resource Template / FunctionResource
- "summary": "Info for a specific pet",
- "operationId": "showPetById",
- "tags": ["pets"],
- "parameters": [{ # Path parameter -> Template function argument
- "name": "petId", "in": "path", "required": True, "schema": {"type": "string"}
- }],
- "responses": {"200": {"description": "Information about the pet."}},
- },
- },
- },
- "components": {
- "schemas": {
- "PetInput": {"type": "object", "properties": {"name": {"type": "string"}, "tag": {"type": "string"}}},
+ "/pets/{petId}": {
+ "get": {
+ "operationId": "getPet",
+ "summary": "Get a pet by ID",
+ "parameters": [
+ {
+ "name": "petId",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"}
+ }
+ ]
+ }
}
}
}
-```
-
-
-### How it Works Internally
-
-1. **Parsing**: `from_openapi` parses the spec using utilities that leverage `openapi-pydantic`. It extracts paths, operations, parameters, request bodies, and responses.
-2. **Mapping**: It applies mapping rules (see below) to decide whether each OpenAPI operation (`GET /pets`, `POST /pets`, `GET /pets/{petId}`) becomes an MCP `Tool`, `Resource`, or `ResourceTemplate`.
-3. **Component Creation**: It creates specialized internal components (`OpenAPITool`, `OpenAPIResource`, `OpenAPIResourceTemplate`).
-4. **HTTP Execution**: When an MCP client calls a tool or reads a resource from this server:
- * The corresponding OpenAPI component constructs an HTTP request based on the OpenAPI definition and the arguments provided by the MCP client.
- * It uses the provided `httpx.AsyncClient` to send the request to the backend API.
- * It processes the HTTP response and returns it to the MCP client in the appropriate MCP format.
-5. **Schema Generation**: The schemas for MCP tools are derived by combining OpenAPI parameters (path, query, header) and request body schemas. Resource template function arguments are derived from path parameters.
-6. **Descriptions**: Tool/Resource descriptions are enhanced with information from OpenAPI responses to give the LLM more context about potential outcomes.
-
-### Default Mapping Rules
-
-FastMCP uses the following default rules to map OpenAPI operations:
-
-- `GET` operation with path parameters (e.g., `/users/{id}`) -> **`ResourceTemplate`**
-- `GET` operation without path parameters (e.g., `/users`) -> **`Resource`**
-- `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `HEAD` -> **`Tool`**
-
-### Customize Route Mapping
-
-You can customize the mapping rules by providing a list of `RouteMap` objects directly to `FastMCP.from_openapi()` using the `route_maps` parameter:
-
-```python
-from fastmcp.server.openapi import RouteMap, RouteType
-from fastmcp import FastMCP
-
-# Custom mapping: Treat GET /admin/stats as a Tool, not a Resource
-custom_maps = [
- RouteMap(methods=["GET"], pattern=r"^/admin/stats$", route_type=RouteType.TOOL)
-]
-
-async def create_server_with_custom_mapping():
- mcp_server = await FastMCP.from_openapi(
+async def main():
+ # Client for the Pet Store API
+ client = httpx.AsyncClient(base_url="https://petstore.example.com/api")
+
+ # Create the MCP server
+ mcp = await FastMCP.from_openapi(
openapi_spec=petstore_spec,
- client=http_client,
- name="PetStoreMCP",
- route_maps=custom_maps # Pass custom mapping rules
+ client=client,
+ name="PetStore"
)
- return mcp_server
+
+ # List what components were created
+ tools = await mcp.list_tools()
+ resources = await mcp.list_resources()
+ templates = await mcp.list_resource_templates()
+
+ print(f"Tools: {len(tools)}") # Should include createPet
+ print(f"Resources: {len(resources)}") # Should include listPets
+ print(f"Templates: {len(templates)}") # Should include getPet
+
+ # Start the MCP server
+ mcp.run()
+
+if __name__ == "__main__":
+ asyncio.run(main())
```
-Each `RouteMap` maps one or more HTTP methods and a regular expression pattern for the route path to an MCP `RouteType`. Route maps are processed in order, and the first match wins.
-
-All parameters passed to `FastMCP.from_openapi()` will be forwarded to the underlying `FastMCPOpenAPI` constructor, so you can customize any aspect of the OpenAPI integration directly through this method call.
-
diff --git a/docs/patterns/proxying.mdx b/docs/patterns/proxy.mdx
similarity index 79%
rename from docs/patterns/proxying.mdx
rename to docs/patterns/proxy.mdx
index d880b95b2..412cbfd6e 100644
--- a/docs/patterns/proxying.mdx
+++ b/docs/patterns/proxy.mdx
@@ -4,8 +4,11 @@ sidebarTitle: Proxying
description: Use FastMCP to act as an intermediary or change transport for other MCP servers.
icon: arrows-retweet
---
+import { VersionBadge } from '/snippets/version-badge.mdx'
-FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.as_proxy()` class method.
+
+
+FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.from_client()` class method.
## What is Proxying?
@@ -32,7 +35,7 @@ sequenceDiagram
## Creating a Proxy
-The easiest way to create a proxy is using the `FastMCP.as_proxy()` class method. This creates a standard FastMCP server that forwards requests to another MCP server.
+The easiest way to create a proxy is using the `FastMCP.from_client()` class method. This creates a standard FastMCP server that forwards requests to another MCP server.
```python
from fastmcp import FastMCP, Client
@@ -41,8 +44,8 @@ from fastmcp import FastMCP, Client
# This could be any MCP server - remote, local, or using any transport
backend_client = Client("backend_server.py") # Could be "http://remote.server/sse", etc.
-# Create the proxy server with as_proxy()
-proxy_server = await FastMCP.as_proxy(
+# Create the proxy server with from_client()
+proxy_server = FastMCP.from_client(
backend_client,
name="MyProxyServer" # Optional settings for the proxy
)
@@ -51,13 +54,17 @@ proxy_server = await FastMCP.as_proxy(
# with any transport (SSE, stdio, etc.) just like any other FastMCP server
```
-**How `as_proxy` Works:**
+**How `from_client` Works:**
1. It connects to the backend server using the provided client.
2. It discovers all the tools, resources, resource templates, and prompts available on the backend server.
3. It creates corresponding "proxy" components that forward requests to the backend.
4. It returns a standard `FastMCP` server instance that can be used like any other.
+
+Currently, proxying focuses primarily on exposing the major MCP objects (tools, resources, templates, and prompts). Some advanced MCP features like notifications and sampling are not fully supported in proxies in the current version. Support for these additional features may be added in future releases.
+
+
### Bridging Transports
A common use case is to bridge transports. For example, making a remote SSE server available locally via Stdio:
@@ -69,7 +76,7 @@ from fastmcp import FastMCP, Client
client = Client("http://example.com/mcp/sse")
# Create a proxy server - it's just a regular FastMCP server
-proxy = await FastMCP.as_proxy(client, name="SSE to Stdio Proxy")
+proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy")
# The proxy can now be used with any transport
# No special handling needed - it works like any FastMCP server
@@ -90,7 +97,7 @@ def tool_a() -> str:
return "A"
# Create a proxy of the original server
-proxy = await FastMCP.as_proxy(
+proxy = FastMCP.from_client(
original_server,
name="Proxy Server"
)
@@ -101,9 +108,6 @@ proxy = await FastMCP.as_proxy(
## `FastMCPProxy` Class
-Internally, `FastMCP.as_proxy()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed. It has two primary async constructors:
-
-* `FastMCPProxy.from_client(client: Client, **settings)`: Creates a proxy from a client instance.
-* `FastMCPProxy.from_server(server: FastMCP, **settings)`: Creates a proxy from another FastMCP server instance.
+Internally, `FastMCP.from_client()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed.
Using the class directly might be necessary for advanced scenarios, like subclassing `FastMCPProxy` to add custom logic before or after forwarding requests.
\ No newline at end of file
diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx
index ea0880097..e6ce5a56f 100644
--- a/docs/servers/context.mdx
+++ b/docs/servers/context.mdx
@@ -4,6 +4,7 @@ sidebarTitle: Context
description: Access MCP capabilities like logging, progress, and resources within your tools.
icon: rectangle-code
---
+import { VersionBadge } from '/snippets/version-badge.mdx'
When defining FastMCP [tools](/servers/tools), your functions might need to interact with the underlying MCP session or access server capabilities. FastMCP provides the `Context` object for this purpose.
@@ -174,6 +175,8 @@ The returned content is typically accessed via `content_list[0].content` and can
### LLM Sampling
+
+
Request the client's LLM to generate text based on provided messages. This is useful when your tool needs to leverage the LLM's capabilities to process data or generate responses.
```python
@@ -227,7 +230,7 @@ async def generate_example(concept: str, ctx: Context) -> str:
return f"```python\n{code_example}\n```"
```
-See [Client Sampling](/clients/overview#llm-sampling) for more details on how clients handle these requests.
+See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests.
### Request Information
diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx
index d950dcea0..745d56bdf 100644
--- a/docs/servers/fastmcp.mdx
+++ b/docs/servers/fastmcp.mdx
@@ -223,84 +223,40 @@ fastmcp run my_server.py:mcp --transport sse --log-level DEBUG
The CLI can dynamically find and run FastMCP server objects in your files, but including the `if __name__ == "__main__":` block ensures compatibility with all clients.
+## Composing Servers
-## Mounting Subservers
+FastMCP supports composing multiple servers together using `import_server` (static copy) and `mount` (live link). This allows you to organize large applications into modular components or reuse existing servers.
-FastMCP allows you to compose complex applications by mounting other FastMCP servers as subservers. This is useful for:
-
-- Organizing large applications into logical components
-- Reusing existing FastMCP servers as parts of a larger system
-- Creating domain-specific servers that can be used independently or composed
+See the [Server Composition](/patterns/composition) guide for full details, best practices, and examples.
```python
+# Example: Importing a subserver
from fastmcp import FastMCP
+import asyncio
-# Create the main server
-main_mcp = FastMCP(name="MainServer")
+main = FastMCP(name="Main")
+sub = FastMCP(name="Sub")
-# Create a domain-specific subserver
-weather_mcp = FastMCP(name="WeatherService")
+@sub.tool()
+def hello():
+ return "hi"
-@weather_mcp.tool()
-def get_forecast(city: str) -> dict:
- """Get the weather forecast for a city."""
- return {"city": city, "forecast": "Sunny", "temperature": 72}
-
-# Create another domain-specific subserver
-calculator_mcp = FastMCP(name="CalculatorService")
-
-@calculator_mcp.tool()
-def add(a: float, b: float) -> float:
- """Add two numbers."""
- return a + b
-
-# Mount the subservers with prefixes
-main_mcp.mount("weather", weather_mcp)
-main_mcp.mount("calc", calculator_mcp)
-
-# Now main_mcp has access to both subservers' tools:
-# - "weather_get_forecast" (from weather_mcp)
-# - "calc_add" (from calculator_mcp)
-
-if __name__ == "__main__":
- main_mcp.run()
+main.mount("sub", sub)
```
-### How Mounting Works
+## Proxying Servers
-When you mount a server with `main_mcp.mount(prefix, subserver)`:
+FastMCP can act as a proxy for any MCP server (local or remote) using `FastMCP.from_client`, letting you bridge transports or add a frontend to existing servers. For example, you can expose a remote SSE server locally via stdio, or vice versa.
-1. All tools from the subserver are imported with prefixed names:
- - `tool_name` becomes `{prefix}_tool_name`
- - Default separator is `_`, but can be customized
-
-2. All resources and resource templates are imported with prefixed URIs:
- - `resource://data` becomes `{prefix}+resource://data`
- - Default separator is `+`, but can be customized
-
-3. All prompts are imported with prefixed names:
- - `prompt_name` becomes `{prefix}_prompt_name`
- - Default separator is `_`, but can be customized
-
-4. The subserver's lifespan is managed automatically when the main server starts and stops
-
-### Customizing Separators
-
-You can customize the separators used for naming:
+See the [Proxying Servers](/patterns/proxy) guide for details and advanced usage.
```python
-main_mcp.mount(
- "weather",
- weather_mcp,
- tool_separator="-", # Use "weather-get_forecast" instead of "weather_get_forecast"
- resource_separator=".", # Use "weather.resource://data" instead of "weather+resource://data"
- prompt_separator=":" # Use "weather:prompt_name" instead of "weather_prompt_name"
-)
-```
+from fastmcp import FastMCP, Client
-
-Some MCP clients may reject certain separators as invalid. For example, Claude Desktop does not support `/` in tool names.
-
+backend = Client("http://example.com/mcp/sse")
+proxy = FastMCP.from_client(backend, name="ProxyServer")
+# Now use the proxy like any FastMCP server
+```
## Server Configuration
diff --git a/docs/snippets/version-badge.mdx b/docs/snippets/version-badge.mdx
new file mode 100644
index 000000000..a5021de94
--- /dev/null
+++ b/docs/snippets/version-badge.mdx
@@ -0,0 +1,8 @@
+export const VersionBadge = ({ version }) => {
+ return (
+
+ ✨
+ New in version {version}
+
+ );
+};
\ No newline at end of file
diff --git a/docs/style.css b/docs/style.css
index 08c49042a..f6bb832bc 100644
--- a/docs/style.css
+++ b/docs/style.css
@@ -1,4 +1,4 @@
-/* Target only inline code elements, not code blocks */
+/* Code highlighting -- target only inline code elements, not code blocks */
p code:not(pre code),
table code:not(pre code),
li code:not(pre code),
@@ -9,5 +9,45 @@ h4 code:not(pre code),
h5 code:not(pre code),
h6 code:not(pre code) {
color: #f72585 !important;
- background-color: #ea54551a !important;
+ background-color: rgba(247, 37, 133, 0.09);
+}
+
+/* Version badge -- display a badge with the current version of the documentation */
+.version-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3em;
+ padding: 0.32em 1em;
+ font-size: 0.92em;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ color: #7417e5;
+ background: #f3e8ff;
+ border: 1.5px solid #c084fc;
+ border-radius: 6px;
+ box-shadow: none;
+ vertical-align: middle;
+ position: relative;
+ transition: box-shadow 0.2s, transform 0.15s;
+}
+
+.version-badge:hover {
+ box-shadow: 0 2px 8px 0 rgba(160, 132, 252, 0.1);
+ transform: translateY(-1px) scale(1.03);
+}
+
+.dark .version-badge {
+ color: #fff;
+ background: #312e81;
+ border: 1.5px solid #a78bfa;
+}
+
+.badge-emoji {
+ font-size: 1.15em;
+ line-height: 1;
+ text-shadow: 0 1px 2px #fff, 0 0px 2px #c084fc;
+}
+
+.dark .badge-emoji {
+ text-shadow: 0 1px 2px #312e81, 0 0px 2px #a78bfa;
}
diff --git a/pyproject.toml b/pyproject.toml
index f5b6c5fd3..0943452cc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,12 +5,13 @@ description = "The fast, Pythonic way to build MCP servers."
authors = [{ name = "Jeremiah Lowin" }]
dependencies = [
"dotenv>=0.9.9",
+ "exceptiongroup>=1.2.2",
+ "httpx>=0.28.1",
"mcp>=1.6.0,<2.0.0",
+ "openapi-pydantic>=0.5.1",
"rich>=13.9.4",
"typer>=0.15.2",
"websockets>=15.0.1",
- "fastapi>=0.115.12",
- "openapi-pydantic>=0.5.1",
]
requires-python = ">=3.10"
readme = "README.md"
@@ -37,6 +38,11 @@ classifiers = [
[dependency-groups]
dev = [
+ "copychat>=0.5.2",
+ "dirty-equals>=0.9.0",
+ "fastapi>=0.115.12",
+ "ipython>=8.12.3",
+ "pdbpp>=0.10.3",
"pre-commit",
"pyright>=1.1.389",
"pytest>=8.3.3",
@@ -44,10 +50,6 @@ dev = [
"pytest-flakefinder",
"pytest-xdist>=3.6.1",
"ruff",
- "copychat>=0.5.2",
- "ipython>=8.12.3",
- "pdbpp>=0.10.3",
- "dirty-equals>=0.9.0",
]
[project.scripts]
diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py
index dbae1b965..c49472d2b 100644
--- a/src/fastmcp/client/client.py
+++ b/src/fastmcp/client/client.py
@@ -17,6 +17,7 @@ from fastmcp.client.roots import (
create_roots_callback,
)
from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
+from fastmcp.exceptions import ClientError
from fastmcp.server import FastMCP
from .transports import ClientTransport, SessionKwargs, infer_transport
@@ -24,10 +25,6 @@ from .transports import ClientTransport, SessionKwargs, infer_transport
__all__ = ["Client", "RootsHandler", "RootsList"]
-class ClientError(ValueError):
- """Base class for errors raised by the client."""
-
-
class Client:
"""
MCP client that delegates connection management to a Transport instance.
@@ -48,7 +45,7 @@ class Client:
):
self.transport = infer_transport(transport)
self._session: ClientSession | None = None
- self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None
+ self._session_cms: list[AbstractAsyncContextManager[ClientSession]] = []
self._session_kwargs: SessionKwargs = {
"sampling_callback": None,
@@ -89,24 +86,28 @@ class Client:
async def __aenter__(self):
if self.is_connected():
- raise RuntimeError("Client is already connected in an async context.")
+ # We're already connected, no need to add None to the session_cms list
+ return self
+
try:
- self._session_cm = self.transport.connect_session(**self._session_kwargs)
- self._session = await self._session_cm.__aenter__()
+ session_cm = self.transport.connect_session(**self._session_kwargs)
+ self._session_cms.append(session_cm)
+ self._session = await self._session_cms[-1].__aenter__()
return self
except Exception as e:
# Ensure cleanup if __aenter__ fails partially
self._session = None
- self._session_cm = None
+ if self._session_cms:
+ self._session_cms.pop()
raise ConnectionError(
f"Failed to connect using {self.transport}: {e}"
) from e
async def __aexit__(self, exc_type, exc_val, exc_tb):
- if self._session_cm:
- await self._session_cm.__aexit__(exc_type, exc_val, exc_tb)
- self._session = None
- self._session_cm = None
+ if self._session_cms:
+ await self._session_cms[-1].__aexit__(exc_type, exc_val, exc_tb)
+ self._session = None
+ self._session_cms.pop()
# --- MCP Client Methods ---
async def ping(self) -> None:
@@ -168,10 +169,10 @@ class Client:
async def get_prompt(
self, name: str, arguments: dict[str, str] | None = None
- ) -> mcp.types.GetPromptResult:
+ ) -> list[mcp.types.PromptMessage]:
"""Send a prompts/get request."""
result = await self.session.get_prompt(name, arguments)
- return result
+ return result.messages
async def complete(
self,
diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py
index 27b885688..68544512a 100644
--- a/src/fastmcp/client/transports.py
+++ b/src/fastmcp/client/transports.py
@@ -2,13 +2,15 @@ import abc
import contextlib
import datetime
import os
+import shutil
from collections.abc import AsyncIterator
from pathlib import Path
from typing import (
TypedDict,
)
-from mcp import ClientSession, StdioServerParameters
+from exceptiongroup import BaseExceptionGroup, catch
+from mcp import ClientSession, McpError, StdioServerParameters
from mcp.client.session import (
ListRootsFnT,
LoggingFnT,
@@ -22,6 +24,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
from pydantic import AnyUrl
from typing_extensions import Unpack
+from fastmcp.exceptions import ClientError
from fastmcp.server import FastMCP as FastMCPServer
@@ -341,6 +344,10 @@ class NpxStdioTransport(StdioTransport):
env_vars: Additional environment variables
use_package_lock: Whether to use package-lock.json (--prefer-offline)
"""
+ # verify npx is installed
+ if shutil.which("npx") is None:
+ raise ValueError("Command 'npx' not found")
+
# Basic validation
if project_directory and not Path(project_directory).exists():
raise NotADirectoryError(
@@ -382,12 +389,26 @@ class FastMCPTransport(ClientTransport):
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
- # create_connected_server_and_client_session manages the session lifecycle itself
- async with create_connected_server_and_client_session(
- server=self._fastmcp._mcp_server,
- **session_kwargs,
- ) as session:
- yield session
+ def exception_handler(excgroup: BaseExceptionGroup):
+ for exc in excgroup.exceptions:
+ if isinstance(exc, BaseExceptionGroup):
+ exception_handler(exc)
+ raise exc
+
+ def mcperror_handler(excgroup: BaseExceptionGroup):
+ for exc in excgroup.exceptions:
+ if isinstance(exc, BaseExceptionGroup):
+ mcperror_handler(exc)
+ raise ClientError(exc)
+
+ # backport of 3.11's except* syntax
+ with catch({McpError: mcperror_handler, Exception: exception_handler}):
+ # create_connected_server_and_client_session manages the session lifecycle itself
+ async with create_connected_server_and_client_session(
+ server=self._fastmcp._mcp_server,
+ **session_kwargs,
+ ) as session:
+ yield session
def __repr__(self) -> str:
return f""
diff --git a/src/fastmcp/exceptions.py b/src/fastmcp/exceptions.py
index 1314ef615..c105cce8e 100644
--- a/src/fastmcp/exceptions.py
+++ b/src/fastmcp/exceptions.py
@@ -23,3 +23,11 @@ class PromptError(FastMCPError):
class InvalidSignature(Exception):
"""Invalid signature for use with FastMCP."""
+
+
+class ClientError(Exception):
+ """Error in client operations."""
+
+
+class NotFoundError(Exception):
+ """Object not found."""
diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py
index 5733b3705..c6bcefc62 100644
--- a/src/fastmcp/prompts/prompt.py
+++ b/src/fastmcp/prompts/prompt.py
@@ -115,7 +115,7 @@ class Prompt(BaseModel):
return cls(
name=func_name,
- description=description or fn.__doc__ or "",
+ description=description or fn.__doc__,
arguments=arguments,
fn=fn,
tags=tags or set(),
diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py
index ce423c731..a48ec91b9 100644
--- a/src/fastmcp/prompts/prompt_manager.py
+++ b/src/fastmcp/prompts/prompt_manager.py
@@ -3,8 +3,8 @@
from collections.abc import Awaitable, Callable
from typing import Any
-from fastmcp.exceptions import PromptError
-from fastmcp.prompts.prompt import MCPPrompt, Message, Prompt, PromptResult
+from fastmcp.exceptions import NotFoundError
+from fastmcp.prompts.prompt import Message, Prompt, PromptResult
from fastmcp.settings import DuplicateBehavior
from fastmcp.utilities.logging import get_logger
@@ -37,16 +37,6 @@ class PromptManager:
"""Get all registered prompts, indexed by registered key."""
return self._prompts
- def list_prompts(self) -> list[Prompt]:
- """List all registered prompts."""
- return list(self.get_prompts().values())
-
- def list_mcp_prompts(self) -> list[MCPPrompt]:
- """List all registered prompts in the format expected by the low-level MCP server."""
- return [
- prompt.to_mcp_prompt(name=key) for key, prompt in self.get_prompts().items()
- ]
-
def add_prompt_from_fn(
self,
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
@@ -84,28 +74,10 @@ class PromptManager:
"""Render a prompt by name with arguments."""
prompt = self.get_prompt(name)
if not prompt:
- raise PromptError(f"Unknown prompt: {name}")
+ raise NotFoundError(f"Unknown prompt: {name}")
return await prompt.render(arguments)
- def import_prompts(
- self, manager: "PromptManager", prefix: str | None = None
- ) -> None:
- """
- Import all prompts from another PromptManager with prefixed names.
-
- Args:
- manager: Another PromptManager instance to import prompts from
- prefix: Prefix to add to prompt names. The resulting prompt key will
- be in the format "{prefix}{original_name}" if prefix is provided,
- otherwise the original name is used.
- For example, with prefix "weather/" and prompt "forecast_prompt",
- the imported prompt would be available as "weather/forecast_prompt"
- """
- for name, prompt in manager._prompts.items():
- # Create prefixed key
- key = f"{prefix}{name}" if prefix else name
-
- # Store the prompt with the prefixed key
- self.add_prompt(prompt, key=key)
- logger.debug(f'Imported prompt "{prompt.name}" as "{key}"')
+ def has_prompt(self, key: str) -> bool:
+ """Check if a prompt exists."""
+ return key in self._prompts
diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py
index 7f3256a76..613cd728b 100644
--- a/src/fastmcp/resources/resource_manager.py
+++ b/src/fastmcp/resources/resource_manager.py
@@ -6,11 +6,10 @@ from typing import Any
from pydantic import AnyUrl
-from fastmcp.exceptions import ResourceError
+from fastmcp.exceptions import NotFoundError
from fastmcp.resources import FunctionResource
-from fastmcp.resources.resource import MCPResource, Resource
+from fastmcp.resources.resource import Resource
from fastmcp.resources.template import (
- MCPResourceTemplate,
ResourceTemplate,
match_uri_template,
)
@@ -203,11 +202,21 @@ class ResourceManager:
self._templates[storage_key] = template
return template
+ def has_resource(self, uri: AnyUrl | str) -> bool:
+ """Check if a resource exists."""
+ uri_str = str(uri)
+ if uri_str in self._resources:
+ return True
+ for template_key in self._templates.keys():
+ if match_uri_template(uri_str, template_key):
+ return True
+ return False
+
async def get_resource(self, uri: AnyUrl | str) -> Resource:
"""Get resource by URI, checking concrete resources first, then templates.
Raises:
- ResourceError: If no resource or template matching the URI is found.
+ NotFoundError: If no resource or template matching the URI is found.
"""
uri_str = str(uri)
logger.debug("Getting resource", extra={"uri": uri_str})
@@ -225,85 +234,12 @@ class ResourceManager:
except Exception as e:
raise ValueError(f"Error creating resource from template: {e}")
- raise ResourceError(f"Unknown resource: {uri_str}")
+ raise NotFoundError(f"Unknown resource: {uri_str}")
def get_resources(self) -> dict[str, Resource]:
"""Get all registered resources, keyed by URI."""
return self._resources
- def list_resources(self) -> list[Resource]:
- """List all registered resources."""
- logger.debug("Listing resources", extra={"count": len(self._resources)})
- return list(self._resources.values())
-
- def list_mcp_resources(self) -> list[MCPResource]:
- """List all registered resources in the format expected by the low-level MCP server."""
-
- return [
- resource.to_mcp_resource(uri=key)
- for key, resource in self._resources.items()
- ]
-
- def list_mcp_resource_templates(self) -> list[MCPResourceTemplate]:
- """List all registered resource templates in the format expected by the low-level MCP server."""
- return [
- template.to_mcp_template(uriTemplate=key)
- for key, template in self._templates.items()
- ]
-
def get_templates(self) -> dict[str, ResourceTemplate]:
"""Get all registered templates, keyed by URI template."""
return self._templates
-
- def list_templates(self) -> list[ResourceTemplate]:
- """List all registered templates."""
- logger.debug("Listing templates", extra={"count": len(self._templates)})
- return list(self._templates.values())
-
- def import_resources(
- self, manager: "ResourceManager", prefix: str | None = None
- ) -> None:
- """Import resources from another resource manager.
-
- Resources are imported with a prefixed URI if a prefix is provided. For example,
- if a resource has URI "data://users" and you import it with prefix "app+", the
- imported resource will have URI "app+data://users". If no prefix is provided,
- the original URI is used.
-
- Args:
- manager: The ResourceManager to import from
- prefix: A prefix to apply to the resource URIs, including the delimiter.
- For example, "app+" would result in URIs like "app+data://users".
- If None, the original URI is used.
- """
- for uri, resource in manager._resources.items():
- # Create prefixed URI and import the resource with the new URI as the storage key
- prefixed_uri = f"{prefix}{uri}" if prefix else uri
- self.add_resource(resource, key=prefixed_uri)
- logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"')
-
- def import_templates(
- self, manager: "ResourceManager", prefix: str | None = None
- ) -> None:
- """Import resource templates from another resource manager.
-
- Templates are imported with a prefixed URI template if a prefix is provided.
- For example, if a template has URI template "data://users/{id}" and you import
- it with prefix "app+", the imported template will have URI template
- "app+data://users/{id}". If no prefix is provided, the original URI template is used.
-
- Args:
- manager: The ResourceManager to import templates from
- prefix: A prefix to apply to the template URIs, including the delimiter.
- For example, "app+" would result in URI templates like "app+data://users/{id}".
- If None, the original URI template is used.
- """
- for uri_template, template in manager._templates.items():
- # Create prefixed URI template and import the template with the new URI as the storage key
- prefixed_uri_template = (
- f"{prefix}{uri_template}" if prefix else uri_template
- )
- self.add_template(template, key=prefixed_uri_template)
- logger.debug(
- f'Imported template "{uri_template}" as "{prefixed_uri_template}"'
- )
diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py
index d25dc314d..bc65c2ff7 100644
--- a/src/fastmcp/server/openapi.py
+++ b/src/fastmcp/server/openapi.py
@@ -8,6 +8,7 @@ from re import Pattern
from typing import Any, Literal
import httpx
+from mcp.types import TextContent
from pydantic.networks import AnyUrl
from fastmcp.resources import Resource, ResourceTemplate
@@ -613,25 +614,18 @@ class FastMCPOpenAPI(FastMCP):
f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
)
- async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
- """Override the call_tool method to return the raw result without converting to content.
+ async def _mcp_call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
+ """Override the call_tool method to return the raw result without converting to content."""
- For testing purposes, if specific tools are called, we convert the result to the expected object.
- """
context = self.get_context()
result = await self._tool_manager.call_tool(name, arguments, context=context)
- # For testing purposes, convert result to expected model based on tool name
- if name == "create_user_users_post":
- # Try to import User class from test module
- try:
- from tests.server.test_openapi import User
-
- # Convert dict to User object
- if isinstance(result, dict):
- return User(**result)
- except ImportError:
- # If User class not found, just return the raw result
- pass
+ # For other tools, ensure the response is wrapped in TextContent
+ if isinstance(result, dict | str):
+ if isinstance(result, dict):
+ result_text = json.dumps(result)
+ else:
+ result_text = result
+ return [TextContent(text=result_text, type="text")]
return result
diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py
index 887e60efc..e4c31e5c2 100644
--- a/src/fastmcp/server/proxy.py
+++ b/src/fastmcp/server/proxy.py
@@ -2,10 +2,19 @@ from typing import Any, cast
from urllib.parse import quote
import mcp.types
-from mcp.types import BlobResourceContents, TextResourceContents
+from mcp.server.lowlevel.helper_types import ReadResourceContents
+from mcp.types import (
+ BlobResourceContents,
+ EmbeddedResource,
+ GetPromptResult,
+ ImageContent,
+ TextContent,
+ TextResourceContents,
+)
+from pydantic.networks import AnyUrl
-import fastmcp
from fastmcp.client import Client
+from fastmcp.exceptions import NotFoundError
from fastmcp.prompts import Message, Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.context import Context
@@ -152,79 +161,89 @@ class ProxyPrompt(Prompt):
async def render(self, arguments: dict[str, Any]) -> list[Message]:
async with self._client:
result = await self._client.get_prompt(self.name, arguments)
- return [Message(role=m.role, content=m.content) for m in result.messages]
+ return [Message(role=m.role, content=m.content) for m in result]
class FastMCPProxy(FastMCP):
- def __init__(self, _async_constructor: bool, **kwargs):
- if not _async_constructor:
- raise ValueError(
- "FastMCPProxy() was initialied unexpectedly. Please use a constructor like `FastMCPProxy.from_client()` instead."
- )
+ def __init__(self, client: "Client", **kwargs):
super().__init__(**kwargs)
+ self.client = client
- @classmethod
- async def from_client(
- cls,
- client: "Client",
- name: str | None = None,
- **settings: fastmcp.settings.ServerSettings,
- ) -> "FastMCPProxy":
- """Create a FastMCP proxy server from a client.
+ async def get_tools(self) -> dict[str, Tool]:
+ tools = await super().get_tools()
- This method creates a new FastMCP server instance that proxies requests to the provided client.
- It discovers the client's tools, resources, prompts, and templates, and creates corresponding
- components in the server that forward requests to the client.
+ async with self.client:
+ for tool in await self.client.list_tools():
+ tool_proxy = await ProxyTool.from_client(self.client, tool)
+ tools[tool_proxy.name] = tool_proxy
- Args:
- client: The client to proxy requests to
- name: Optional name for the new FastMCP server (defaults to client name if available)
- **settings: Additional settings for the FastMCP server
+ return tools
- Returns:
- A FastMCP server that proxies requests to the client
- """
- server = cls(name=name, **settings, _async_constructor=True)
+ async def get_resources(self) -> dict[str, Resource]:
+ resources = await super().get_resources()
- async with client:
- # Register proxies for client tools
- tools = await client.list_tools()
- for tool in tools:
- tool_proxy = await ProxyTool.from_client(client, tool)
- server._tool_manager._tools[tool_proxy.name] = tool_proxy
- logger.debug(f"Created proxy for tool: {tool_proxy.name}")
+ async with self.client:
+ for resource in await self.client.list_resources():
+ resource_proxy = await ProxyResource.from_client(self.client, resource)
+ resources[str(resource_proxy.uri)] = resource_proxy
- # Register proxies for client resources
- resources = await client.list_resources()
- for resource in resources:
- resource_proxy = await ProxyResource.from_client(client, resource)
- server._resource_manager._resources[str(resource_proxy.uri)] = (
- resource_proxy
- )
- logger.debug(f"Created proxy for resource: {resource_proxy.uri}")
+ return resources
- # Register proxies for client resource templates
- templates = await client.list_resource_templates()
- for template in templates:
- template_proxy = await ProxyTemplate.from_client(client, template)
- server._resource_manager._templates[template_proxy.uri_template] = (
- template_proxy
- )
- logger.debug(
- f"Created proxy for template: {template_proxy.uri_template}"
- )
+ async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
+ templates = await super().get_resource_templates()
- # Register proxies for client prompts
- prompts = await client.list_prompts()
- for prompt in prompts:
- prompt_proxy = await ProxyPrompt.from_client(client, prompt)
- server._prompt_manager._prompts[prompt_proxy.name] = prompt_proxy
- logger.debug(f"Created proxy for prompt: {prompt_proxy.name}")
+ async with self.client:
+ for template in await self.client.list_resource_templates():
+ template_proxy = await ProxyTemplate.from_client(self.client, template)
+ templates[template_proxy.uri_template] = template_proxy
- logger.info(f"Created server '{server.name}' proxying to client: {client}")
- return server
+ return templates
- @classmethod
- async def from_server(cls, server: FastMCP, **settings: Any) -> "FastMCPProxy":
- client = Client(transport=fastmcp.client.transports.FastMCPTransport(server))
- return await cls.from_client(client, **settings)
+ async def get_prompts(self) -> dict[str, Prompt]:
+ prompts = await super().get_prompts()
+
+ async with self.client:
+ for prompt in await self.client.list_prompts():
+ prompt_proxy = await ProxyPrompt.from_client(self.client, prompt)
+ prompts[prompt_proxy.name] = prompt_proxy
+ return prompts
+
+ async def _mcp_call_tool(
+ self, key: str, arguments: dict[str, Any]
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
+ try:
+ result = await super()._mcp_call_tool(key, arguments)
+ return result
+ except NotFoundError:
+ async with self.client:
+ result = await self.client.call_tool(key, arguments)
+ return result
+
+ async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
+ try:
+ result = await super()._mcp_read_resource(uri)
+ return result
+ except NotFoundError:
+ async with self.client:
+ resource = await self.client.read_resource(uri)
+ if isinstance(resource[0], TextResourceContents):
+ content = resource[0].text
+ elif isinstance(resource[0], BlobResourceContents):
+ content = resource[0].blob
+ else:
+ raise ValueError(f"Unsupported content type: {type(resource[0])}")
+
+ return [
+ ReadResourceContents(content=content, mime_type=resource[0].mimeType)
+ ]
+
+ async def _mcp_get_prompt(
+ self, name: str, arguments: dict[str, Any] | None = None
+ ) -> GetPromptResult:
+ try:
+ result = await super()._mcp_get_prompt(name, arguments)
+ return result
+ except NotFoundError:
+ async with self.client:
+ result = await self.client.get_prompt(name, arguments)
+ return GetPromptResult(messages=result)
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index d1c04087d..8fd15d755 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -1,6 +1,6 @@
"""FastMCP - A more ergonomic interface for MCP servers."""
-import json
+import datetime
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import (
AbstractAsyncContextManager,
@@ -13,7 +13,6 @@ import anyio
import httpx
import pydantic_core
import uvicorn
-from fastapi import FastAPI
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.lowlevel.server import Server as MCPServer
@@ -38,24 +37,115 @@ from starlette.routing import Mount, Route
import fastmcp
import fastmcp.settings
-from fastmcp.exceptions import ResourceError
+from fastmcp.exceptions import NotFoundError, ResourceError
from fastmcp.prompts import Prompt, PromptManager
-from fastmcp.prompts.prompt import Message, PromptResult
+from fastmcp.prompts.prompt import PromptResult
from fastmcp.resources import Resource, ResourceManager
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools import ToolManager
from fastmcp.tools.tool import Tool
from fastmcp.utilities.decorators import DecoratedFunction
from fastmcp.utilities.logging import configure_logging, get_logger
-from fastmcp.utilities.types import Image
if TYPE_CHECKING:
from fastmcp.client import Client
from fastmcp.server.context import Context
from fastmcp.server.openapi import FastMCPOpenAPI
from fastmcp.server.proxy import FastMCPProxy
+
logger = get_logger(__name__)
+NOT_FOUND = object()
+
+
+class MountedServer:
+ def __init__(
+ self,
+ prefix: str,
+ server: "FastMCP",
+ tool_separator: str | None = None,
+ resource_separator: str | None = None,
+ prompt_separator: str | None = None,
+ ):
+ if tool_separator is None:
+ tool_separator = "_"
+ if resource_separator is None:
+ resource_separator = "+"
+ if prompt_separator is None:
+ prompt_separator = "_"
+
+ self.server = server
+ self.prefix = prefix
+ self.tool_separator = tool_separator
+ self.resource_separator = resource_separator
+ self.prompt_separator = prompt_separator
+
+ async def get_tools(self) -> dict[str, Tool]:
+ tools = await self.server.get_tools()
+ return {
+ f"{self.prefix}{self.tool_separator}{key}": tool
+ for key, tool in tools.items()
+ }
+
+ async def get_resources(self) -> dict[str, Resource]:
+ resources = await self.server.get_resources()
+ return {
+ f"{self.prefix}{self.resource_separator}{key}": resource
+ for key, resource in resources.items()
+ }
+
+ async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
+ templates = await self.server.get_resource_templates()
+ return {
+ f"{self.prefix}{self.resource_separator}{key}": template
+ for key, template in templates.items()
+ }
+
+ async def get_prompts(self) -> dict[str, Prompt]:
+ prompts = await self.server.get_prompts()
+ return {
+ f"{self.prefix}{self.prompt_separator}{key}": prompt
+ for key, prompt in prompts.items()
+ }
+
+ def match_tool(self, key: str) -> bool:
+ return key.startswith(f"{self.prefix}{self.tool_separator}")
+
+ def strip_tool_prefix(self, key: str) -> str:
+ return key.removeprefix(f"{self.prefix}{self.tool_separator}")
+
+ def match_resource(self, key: str) -> bool:
+ return key.startswith(f"{self.prefix}{self.resource_separator}")
+
+ def strip_resource_prefix(self, key: str) -> str:
+ return key.removeprefix(f"{self.prefix}{self.resource_separator}")
+
+ def match_prompt(self, key: str) -> bool:
+ return key.startswith(f"{self.prefix}{self.prompt_separator}")
+
+ def strip_prompt_prefix(self, key: str) -> str:
+ return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
+
+
+class TimedCache:
+ def __init__(self, expiration: datetime.timedelta):
+ self.expiration = expiration
+ self.cache: dict[Any, tuple[Any, datetime.datetime]] = {}
+
+ def set(self, key: Any, value: Any) -> None:
+ expires = datetime.datetime.now() + self.expiration
+ self.cache[key] = (value, expires)
+
+ def get(self, key: Any) -> Any:
+ value = self.cache.get(key)
+ if value is not None and value[1] > datetime.datetime.now():
+ return value[0]
+ else:
+ return NOT_FOUND
+
+ def clear(self) -> None:
+ self.cache.clear()
+
@asynccontextmanager
async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
@@ -70,7 +160,7 @@ async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
yield {}
-def lifespan_wrapper(
+def _lifespan_wrapper(
app: "FastMCP",
lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]],
) -> Callable[
@@ -79,17 +169,7 @@ def lifespan_wrapper(
@asynccontextmanager
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
async with AsyncExitStack() as stack:
- # enter main app's lifespan
context = await stack.enter_async_context(lifespan(app))
-
- # Enter all mounted app lifespans
- for prefix, mounted_app in app._mounted_apps.items():
- mounted_context = mounted_app._mcp_server.lifespan(
- mounted_app._mcp_server
- )
- await stack.enter_async_context(mounted_context)
- logger.debug(f"Prepared lifespan for mounted app '{prefix}'")
-
yield context
return wrap
@@ -108,9 +188,13 @@ class FastMCP(Generic[LifespanResultT]):
):
self.tags: set[str] = tags or set()
self.settings = fastmcp.settings.ServerSettings(**settings)
+ self._cache = TimedCache(
+ expiration=datetime.timedelta(
+ seconds=self.settings.cache_expiration_seconds
+ )
+ )
- # Setup for mounted apps - must be initialized before _mcp_server
- self._mounted_apps: dict[str, FastMCP] = {}
+ self._mounted_servers: dict[str, MountedServer] = {}
if lifespan is None:
lifespan = default_lifespan
@@ -118,7 +202,7 @@ class FastMCP(Generic[LifespanResultT]):
self._mcp_server = MCPServer[LifespanResultT](
name=name or "FastMCP",
instructions=instructions,
- lifespan=lifespan_wrapper(self, lifespan),
+ lifespan=_lifespan_wrapper(self, lifespan),
)
self._tool_manager = ToolManager(
duplicate_behavior=self.settings.on_duplicate_tools
@@ -137,6 +221,9 @@ class FastMCP(Generic[LifespanResultT]):
# Configure logging
configure_logging(self.settings.log_level)
+ def __repr__(self) -> str:
+ return f"{type(self).__name__}({self.name!r})"
+
@property
def name(self) -> str:
return self._mcp_server.name
@@ -177,30 +264,13 @@ class FastMCP(Generic[LifespanResultT]):
def _setup_handlers(self) -> None:
"""Set up core MCP protocol handlers."""
self._mcp_server.list_tools()(self._mcp_list_tools)
- self._mcp_server.call_tool()(self.call_tool)
+ self._mcp_server.call_tool()(self._mcp_call_tool)
self._mcp_server.list_resources()(self._mcp_list_resources)
self._mcp_server.read_resource()(self._mcp_read_resource)
self._mcp_server.list_prompts()(self._mcp_list_prompts)
self._mcp_server.get_prompt()(self._mcp_get_prompt)
self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
- def get_tools(self) -> dict[str, Tool]:
- """Get all registered tools, indexed by registered key."""
- return self._tool_manager.get_tools()
-
- def list_tools(self) -> list[Tool]:
- """List all registered tools."""
- return self._tool_manager.list_tools()
-
- async def _mcp_list_tools(self) -> list[MCPTool]:
- """
- List all available tools, in the format expected by the low-level MCP
- server.
-
- See `list_tools` for a more ergonomic way to list tools.
- """
- return self._tool_manager.list_mcp_tools()
-
def get_context(self) -> "Context[ServerSession, LifespanResultT]":
"""
Returns a Context object. Note that the context will only be valid
@@ -215,71 +285,152 @@ class FastMCP(Generic[LifespanResultT]):
return Context(request_context=request_context, fastmcp=self)
- async def call_tool(
- self, key: str, arguments: dict[str, Any]
- ) -> list[TextContent | ImageContent | EmbeddedResource]:
- """Call a tool by name with arguments."""
- context = self.get_context()
- result = await self._tool_manager.call_tool(key, arguments, context=context)
- converted_result = _convert_to_content(result)
- return converted_result
+ async def get_tools(self) -> dict[str, Tool]:
+ """Get all registered tools, indexed by registered key."""
+ if (tools := self._cache.get("tools")) is NOT_FOUND:
+ tools = {}
+ for server in self._mounted_servers.values():
+ server_tools = await server.get_tools()
+ tools.update(server_tools)
+ tools.update(self._tool_manager.get_tools())
+ self._cache.set("tools", tools)
+ return tools
- def get_resources(self) -> dict[str, Resource]:
+ async def get_resources(self) -> dict[str, Resource]:
"""Get all registered resources, indexed by registered key."""
- return self._resource_manager.get_resources()
+ if (resources := self._cache.get("resources")) is NOT_FOUND:
+ resources = {}
+ for server in self._mounted_servers.values():
+ server_resources = await server.get_resources()
+ resources.update(server_resources)
+ resources.update(self._resource_manager.get_resources())
+ self._cache.set("resources", resources)
+ return resources
- def list_resources(self) -> list[Resource]:
- """List all registered resources."""
- return self._resource_manager.list_resources()
+ async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
+ """Get all registered resource templates, indexed by registered key."""
+ if (templates := self._cache.get("resource_templates")) is NOT_FOUND:
+ templates = {}
+ for server in self._mounted_servers.values():
+ server_templates = await server.get_resource_templates()
+ templates.update(server_templates)
+ templates.update(self._resource_manager.get_templates())
+ self._cache.set("resource_templates", templates)
+ return templates
+
+ async def get_prompts(self) -> dict[str, Prompt]:
+ """
+ List all available prompts.
+ """
+ if (prompts := self._cache.get("prompts")) is NOT_FOUND:
+ prompts = {}
+ for server in self._mounted_servers.values():
+ server_prompts = await server.get_prompts()
+ prompts.update(server_prompts)
+ prompts.update(self._prompt_manager.get_prompts())
+ self._cache.set("prompts", prompts)
+ return prompts
+
+ async def _mcp_list_tools(self) -> list[MCPTool]:
+ """
+ List all available tools, in the format expected by the low-level MCP
+ server.
+
+ """
+ tools = await self.get_tools()
+ return [tool.to_mcp_tool(name=key) for key, tool in tools.items()]
async def _mcp_list_resources(self) -> list[MCPResource]:
"""
List all available resources, in the format expected by the low-level MCP
server.
- See `list_resources` for a more ergonomic way to list resources.
"""
-
- return self._resource_manager.list_mcp_resources()
-
- def list_resource_templates(self) -> list[ResourceTemplate]:
- return self._resource_manager.list_templates()
+ resources = await self.get_resources()
+ return [
+ resource.to_mcp_resource(uri=key) for key, resource in resources.items()
+ ]
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
"""
List all available resource templates, in the format expected by the low-level
MCP server.
- See `list_resource_templates` for a more ergonomic way to list resource
- templates.
"""
- return self._resource_manager.list_mcp_resource_templates()
+ templates = await self.get_resource_templates()
+ return [
+ template.to_mcp_template(uriTemplate=key)
+ for key, template in templates.items()
+ ]
- async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
- """Read a resource by URI."""
- resource = await self._resource_manager.get_resource(uri)
- if not resource:
- raise ResourceError(f"Unknown resource: {uri}")
- return await resource.read()
+ async def _mcp_list_prompts(self) -> list[MCPPrompt]:
+ """
+ List all available prompts, in the format expected by the low-level MCP
+ server.
+
+ """
+ prompts = await self.get_prompts()
+ return [prompt.to_mcp_prompt(name=key) for key, prompt in prompts.items()]
+
+ async def _mcp_call_tool(
+ self, key: str, arguments: dict[str, Any]
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
+ """Call a tool by name with arguments."""
+ if self._tool_manager.has_tool(key):
+ context = self.get_context()
+ result = await self._tool_manager.call_tool(key, arguments, context=context)
+
+ else:
+ for server in self._mounted_servers.values():
+ if server.match_tool(key):
+ new_key = server.strip_tool_prefix(key)
+ result = await server.server._mcp_call_tool(new_key, arguments)
+ break
+ else:
+ raise NotFoundError(f"Unknown tool: {key}")
+ return result
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
"""
Read a resource by URI, in the format expected by the low-level MCP
server.
-
- See `read_resource` for a more ergonomic way to read resources.
"""
+ if self._resource_manager.has_resource(uri):
+ resource = await self._resource_manager.get_resource(uri)
+ try:
+ content = await resource.read()
+ return [
+ ReadResourceContents(content=content, mime_type=resource.mime_type)
+ ]
+ except Exception as e:
+ logger.error(f"Error reading resource {uri}: {e}")
+ raise ResourceError(str(e))
+ else:
+ for server in self._mounted_servers.values():
+ if server.match_resource(str(uri)):
+ new_uri = server.strip_resource_prefix(str(uri))
+ return await server.server._mcp_read_resource(new_uri)
+ else:
+ raise NotFoundError(f"Unknown resource: {uri}")
- resource = await self._resource_manager.get_resource(uri)
- if not resource:
- raise ResourceError(f"Unknown resource: {uri}")
+ async def _mcp_get_prompt(
+ self, name: str, arguments: dict[str, Any] | None = None
+ ) -> GetPromptResult:
+ """
+ Get a prompt by name with arguments, in the format expected by the low-level
+ MCP server.
- try:
- content = await self.read_resource(uri)
- return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
- except Exception as e:
- logger.error(f"Error reading resource {uri}: {e}")
- raise ResourceError(str(e))
+ """
+ if self._prompt_manager.has_prompt(name):
+ messages = await self._prompt_manager.render_prompt(name, arguments)
+ return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
+ else:
+ for server in self._mounted_servers.values():
+ if server.match_prompt(name):
+ new_key = server.strip_prompt_prefix(name)
+ return await server.server._mcp_get_prompt(new_key, arguments)
+ else:
+ raise NotFoundError(f"Unknown prompt: {name}")
def add_tool(
self,
@@ -302,6 +453,7 @@ class FastMCP(Generic[LifespanResultT]):
self._tool_manager.add_tool_from_fn(
fn, name=name, description=description, tags=tags
)
+ self._cache.clear()
def tool(
self,
@@ -357,6 +509,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
self._resource_manager.add_resource(resource, key=key)
+ self._cache.clear()
def add_resource_fn(
self,
@@ -388,6 +541,7 @@ class FastMCP(Generic[LifespanResultT]):
mime_type=mime_type,
tags=tags,
)
+ self._cache.clear()
def resource(
self,
@@ -443,7 +597,7 @@ class FastMCP(Generic[LifespanResultT]):
)
def decorator(fn: AnyFunction) -> AnyFunction:
- self._resource_manager.add_resource_or_template_from_fn(
+ self.add_resource_fn(
fn=fn,
uri=uri,
name=name,
@@ -473,6 +627,7 @@ class FastMCP(Generic[LifespanResultT]):
description=description,
tags=tags,
)
+ self._cache.clear()
def prompt(
self,
@@ -578,72 +733,72 @@ class FastMCP(Generic[LifespanResultT]):
],
)
- def list_prompts(self) -> list[Prompt]:
- """
- List all available prompts.
- """
- return self._prompt_manager.list_prompts()
-
- async def _mcp_list_prompts(self) -> list[MCPPrompt]:
- """
- List all available prompts, in the format expected by the low-level MCP
- server.
-
- See `list_prompts` for a more ergonomic way to list prompts.
- """
- return self._prompt_manager.list_mcp_prompts()
-
- async def get_prompt(
- self, name: str, arguments: dict[str, Any] | None = None
- ) -> list[Message]:
- """Get a prompt by name with arguments."""
- return await self._prompt_manager.render_prompt(name, arguments)
-
- async def _mcp_get_prompt(
- self, name: str, arguments: dict[str, Any] | None = None
- ) -> GetPromptResult:
- """
- Get a prompt by name with arguments, in the format expected by the low-level
- MCP server.
-
- See `get_prompt` for a more ergonomic way to get prompts.
- """
- try:
- messages = await self.get_prompt(name, arguments)
-
- return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
- except Exception as e:
- logger.error(f"Error getting prompt {name}: {e}")
- raise ValueError(str(e))
-
def mount(
self,
prefix: str,
- app: "FastMCP",
+ server: "FastMCP",
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,
) -> None:
- """Mount another FastMCP application with a given prefix.
+ """
+ Mount another FastMCP server on a given prefix.
+ """
+ mounted_server = MountedServer(
+ server=server,
+ prefix=prefix,
+ tool_separator=tool_separator,
+ resource_separator=resource_separator,
+ prompt_separator=prompt_separator,
+ )
+ self._mounted_servers[prefix] = mounted_server
+ self._cache.clear()
- When an application is mounted:
- - The tools are imported with prefixed names using the tool_separator
- Example: If app has a tool named "get_weather", it will be available as "weatherget_weather"
- - The resources are imported with prefixed URIs using the resource_separator
- Example: If app has a resource with URI "weather://forecast", it will be available as "weather+weather://forecast"
- - The templates are imported with prefixed URI templates using the resource_separator
- Example: If app has a template with URI "weather://location/{id}", it will be available as "weather+weather://location/{id}"
- - The prompts are imported with prefixed names using the prompt_separator
- Example: If app has a prompt named "weather_prompt", it will be available as "weather_weather_prompt"
- - The mounted app's lifespan will be executed when the parent app's lifespan runs,
- ensuring that any setup needed by the mounted app is performed
+ def unmount(self, prefix: str) -> None:
+ self._mounted_servers.pop(prefix)
+ self._cache.clear()
+
+ async def import_server(
+ self,
+ prefix: str,
+ server: "FastMCP",
+ tool_separator: str | None = None,
+ resource_separator: str | None = None,
+ prompt_separator: str | None = None,
+ ) -> None:
+ """
+ Import the MCP objects from another FastMCP server into this one,
+ optionally with a given prefix.
+
+ Note that when a server is *imported*, its objects are immediately
+ registered to the importing server. This is a one-time operation and
+ future changes to the imported server will not be reflected in the
+ importing server. Server-level configurations and lifespans are not imported.
+
+ When an server is mounted: - The tools are imported with prefixed names
+ using the tool_separator
+ Example: If server has a tool named "get_weather", it will be
+ available as "weatherget_weather"
+ - The resources are imported with prefixed URIs using the
+ resource_separator Example: If server has a resource with URI
+ "weather://forecast", it will be available as
+ "weather+weather://forecast"
+ - The templates are imported with prefixed URI templates using the
+ resource_separator Example: If server has a template with URI
+ "weather://location/{id}", it will be available as
+ "weather+weather://location/{id}"
+ - The prompts are imported with prefixed names using the
+ prompt_separator Example: If server has a prompt named
+ "weather_prompt", it will be available as "weather_weather_prompt"
+ - The mounted server's lifespan will be executed when the parent
+ server's lifespan runs, ensuring that any setup needed by the mounted
+ server is performed
Args:
- prefix: The prefix to use for the mounted application
- app: The FastMCP application to mount
- tool_separator: Separator for tool names (defaults to "_")
- resource_separator: Separator for resource URIs (defaults to "+")
- prompt_separator: Separator for prompt names (defaults to "_")
+ prefix: The prefix to use for the mounted server server: The FastMCP
+ server to mount tool_separator: Separator for tool names (defaults
+ to "_") resource_separator: Separator for resource URIs (defaults to
+ "+") prompt_separator: Separator for prompt names (defaults to "_")
"""
if tool_separator is None:
tool_separator = "_"
@@ -652,58 +807,30 @@ class FastMCP(Generic[LifespanResultT]):
if prompt_separator is None:
prompt_separator = "_"
- # Mount the app in the list of mounted apps
- self._mounted_apps[prefix] = app
-
- # Import tools from the mounted app
+ # Import tools from the mounted server
tool_prefix = f"{prefix}{tool_separator}"
- self._tool_manager.import_tools(app._tool_manager, tool_prefix)
+ for key, tool in (await server.get_tools()).items():
+ self._tool_manager.add_tool(tool, key=f"{tool_prefix}{key}")
- # Import resources and templates from the mounted app
+ # Import resources and templates from the mounted server
resource_prefix = f"{prefix}{resource_separator}"
- self._resource_manager.import_resources(app._resource_manager, resource_prefix)
- self._resource_manager.import_templates(app._resource_manager, resource_prefix)
+ for key, resource in (await server.get_resources()).items():
+ self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}")
+ for key, template in (await server.get_resource_templates()).items():
+ self._resource_manager.add_template(template, key=f"{resource_prefix}{key}")
- # Import prompts from the mounted app
+ # Import prompts from the mounted server
prompt_prefix = f"{prefix}{prompt_separator}"
- self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix)
+ for key, prompt in (await server.get_prompts()).items():
+ self._prompt_manager.add_prompt(prompt, key=f"{prompt_prefix}{key}")
- logger.info(f"Mounted app with prefix '{prefix}'")
+ logger.info(f"Imported server {server.name} with prefix '{prefix}'")
logger.debug(f"Imported tools with prefix '{tool_prefix}'")
logger.debug(f"Imported resources with prefix '{resource_prefix}'")
logger.debug(f"Imported templates with prefix '{resource_prefix}'")
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
- @classmethod
- async def as_proxy(
- cls, client: "Client | FastMCP", **settings: Any
- ) -> "FastMCPProxy":
- """
- Create a FastMCP proxy server from a client.
-
- This method creates a new FastMCP server instance that proxies requests to the provided client.
- It discovers the client's tools, resources, prompts, and templates, and creates corresponding
- components in the server that forward requests to the client.
-
- Args:
- client: The client to proxy requests to
- **settings: Additional settings for the FastMCP server
-
- Returns:
- A FastMCP server that proxies requests to the client
- """
- from fastmcp.client import Client
-
- from .proxy import FastMCPProxy
-
- if isinstance(client, Client):
- return await FastMCPProxy.from_client(client=client, **settings)
-
- elif isinstance(client, FastMCP):
- return await FastMCPProxy.from_server(server=client, **settings)
-
- else:
- raise ValueError(f"Unknown client type: {type(client)}")
+ self._cache.clear()
@classmethod
def from_openapi(
@@ -718,11 +845,12 @@ class FastMCP(Generic[LifespanResultT]):
@classmethod
def from_fastapi(
- cls, app: FastAPI, name: str | None = None, **settings: Any
+ cls, app: "Any", name: str | None = None, **settings: Any
) -> "FastMCPOpenAPI":
"""
Create a FastMCP server from a FastAPI application.
"""
+
from .openapi import FastMCPOpenAPI
client = httpx.AsyncClient(
@@ -735,47 +863,11 @@ class FastMCP(Generic[LifespanResultT]):
openapi_spec=app.openapi(), client=client, name=name, **settings
)
+ @classmethod
+ def from_client(cls, client: "Client", **settings: Any) -> "FastMCPProxy":
+ """
+ Create a FastMCP proxy server from a FastMCP client.
+ """
+ from fastmcp.server.proxy import FastMCPProxy
-def _convert_to_content(
- result: Any,
- _process_as_single_item: bool = False,
-) -> list[TextContent | ImageContent | EmbeddedResource]:
- """Convert a result to a sequence of content objects."""
- if result is None:
- return []
-
- if isinstance(result, TextContent | ImageContent | EmbeddedResource):
- return [result]
-
- if isinstance(result, Image):
- return [result.to_image_content()]
-
- if isinstance(result, list | tuple) and not _process_as_single_item:
- # if the result is a list, then it could either be a list of MCP types,
- # or a "regular" list that the tool is returning, or a mix of both.
- #
- # so we extract all the MCP types / images and convert them as individual content elements,
- # and aggregate the rest as a single content element
-
- mcp_types = []
- other_content = []
-
- for item in result:
- if isinstance(item, TextContent | ImageContent | EmbeddedResource | Image):
- mcp_types.append(_convert_to_content(item)[0])
- else:
- other_content.append(item)
- if other_content:
- other_content = _convert_to_content(
- other_content, _process_as_single_item=True
- )
-
- return other_content + mcp_types
-
- if not isinstance(result, str):
- try:
- result = json.dumps(pydantic_core.to_jsonable_python(result))
- except Exception:
- result = str(result)
-
- return [TextContent(type="text", text=result)]
+ return FastMCPProxy(client=client, **settings)
diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py
index 41b3fcb89..9d02b5d5a 100644
--- a/src/fastmcp/settings.py
+++ b/src/fastmcp/settings.py
@@ -42,7 +42,7 @@ class ServerSettings(BaseSettings):
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
# HTTP settings
- host: str = "0.0.0.0"
+ host: str = "127.0.0.1"
port: int = 8000
sse_path: str = "/sse"
message_path: str = "/messages/"
@@ -62,6 +62,9 @@ class ServerSettings(BaseSettings):
description="List of dependencies to install in the server environment",
)
+ # cache settings (for checking mounted servers)
+ cache_expiration_seconds: float = 0
+
class ClientSettings(BaseSettings):
"""FastMCP client settings."""
diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py
index c289150ea..cfdef7692 100644
--- a/src/fastmcp/tools/tool.py
+++ b/src/fastmcp/tools/tool.py
@@ -1,15 +1,18 @@
-from __future__ import annotations as _annotations
+from __future__ import annotations
import inspect
+import json
from collections.abc import Callable
from typing import TYPE_CHECKING, Annotated, Any
+import pydantic_core
+from mcp.types import EmbeddedResource, ImageContent, TextContent
from mcp.types import Tool as MCPTool
from pydantic import BaseModel, BeforeValidator, Field
from fastmcp.exceptions import ToolError
from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
-from fastmcp.utilities.types import _convert_set_defaults
+from fastmcp.utilities.types import Image, _convert_set_defaults
if TYPE_CHECKING:
from mcp.server.session import ServerSessionT
@@ -58,7 +61,7 @@ class Tool(BaseModel):
is_async = inspect.iscoroutinefunction(fn)
if context_kwarg is None:
- if isinstance(fn, classmethod):
+ if inspect.ismethod(fn) and hasattr(fn, "__func__"):
sig = inspect.signature(fn.__func__)
else:
sig = inspect.signature(fn)
@@ -67,14 +70,16 @@ class Tool(BaseModel):
context_kwarg = param_name
break
+ # Use callable typing to ensure fn is treated as a callable despite being a classmethod
+ fn_callable: Callable[..., Any] = fn
func_arg_metadata = func_metadata(
- fn,
+ fn_callable,
skip_names=[context_kwarg] if context_kwarg is not None else [],
)
parameters = func_arg_metadata.arg_model.model_json_schema()
return cls(
- fn=fn,
+ fn=fn_callable,
name=func_name,
description=func_doc,
parameters=parameters,
@@ -88,10 +93,10 @@ class Tool(BaseModel):
self,
arguments: dict[str, Any],
context: Context[ServerSessionT, LifespanContextT] | None = None,
- ) -> Any:
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Run the tool with arguments."""
try:
- return await self.fn_metadata.call_fn_with_arg_validation(
+ result = await self.fn_metadata.call_fn_with_arg_validation(
self.fn,
self.is_async,
arguments,
@@ -99,6 +104,7 @@ class Tool(BaseModel):
if self.context_kwarg is not None
else None,
)
+ return _convert_to_content(result)
except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") from e
@@ -114,3 +120,48 @@ class Tool(BaseModel):
if not isinstance(other, Tool):
return False
return self.model_dump() == other.model_dump()
+
+
+def _convert_to_content(
+ result: Any,
+ _process_as_single_item: bool = False,
+) -> list[TextContent | ImageContent | EmbeddedResource]:
+ """Convert a result to a sequence of content objects."""
+ if result is None:
+ return []
+
+ if isinstance(result, TextContent | ImageContent | EmbeddedResource):
+ return [result]
+
+ if isinstance(result, Image):
+ return [result.to_image_content()]
+
+ if isinstance(result, list | tuple) and not _process_as_single_item:
+ # if the result is a list, then it could either be a list of MCP types,
+ # or a "regular" list that the tool is returning, or a mix of both.
+ #
+ # so we extract all the MCP types / images and convert them as individual content elements,
+ # and aggregate the rest as a single content element
+
+ mcp_types = []
+ other_content = []
+
+ for item in result:
+ if isinstance(item, TextContent | ImageContent | EmbeddedResource | Image):
+ mcp_types.append(_convert_to_content(item)[0])
+ else:
+ other_content.append(item)
+ if other_content:
+ other_content = _convert_to_content(
+ other_content, _process_as_single_item=True
+ )
+
+ return other_content + mcp_types
+
+ if not isinstance(result, str):
+ try:
+ result = json.dumps(pydantic_core.to_jsonable_python(result))
+ except Exception:
+ result = str(result)
+
+ return [TextContent(type="text", text=result)]
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index f11d98e22..0675cfc65 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -4,10 +4,11 @@ from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from mcp.shared.context import LifespanContextT
+from mcp.types import EmbeddedResource, ImageContent, TextContent
-from fastmcp.exceptions import ToolError
+from fastmcp.exceptions import NotFoundError
from fastmcp.settings import DuplicateBehavior
-from fastmcp.tools.tool import MCPTool, Tool
+from fastmcp.tools.tool import Tool
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
@@ -36,9 +37,15 @@ class ToolManager:
self.duplicate_behavior = duplicate_behavior
- def get_tool(self, key: str) -> Tool | None:
+ def has_tool(self, key: str) -> bool:
+ """Check if a tool exists."""
+ return key in self._tools
+
+ def get_tool(self, key: str) -> Tool:
"""Get tool by key."""
- return self._tools.get(key)
+ if key in self._tools:
+ return self._tools[key]
+ raise NotFoundError(f"Unknown tool: {key}")
def get_tools(self) -> dict[str, Tool]:
"""Get all registered tools, indexed by registered key."""
@@ -48,10 +55,6 @@ class ToolManager:
"""List all registered tools."""
return list(self.get_tools().values())
- def list_mcp_tools(self) -> list[MCPTool]:
- """List all registered tools in the format expected by the low-level MCP server."""
- return [tool.to_mcp_tool(name=key) for key, tool in self._tools.items()]
-
def add_tool_from_fn(
self,
fn: Callable[..., Any],
@@ -86,29 +89,10 @@ class ToolManager:
key: str,
arguments: dict[str, Any],
context: Context[ServerSessionT, LifespanContextT] | None = None,
- ) -> Any:
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Call a tool by name with arguments."""
tool = self.get_tool(key)
if not tool:
- raise ToolError(f"Unknown tool: {key}")
+ raise NotFoundError(f"Unknown tool: {key}")
return await tool.run(arguments, context=context)
-
- def import_tools(
- self, tool_manager: ToolManager, prefix: str | None = None
- ) -> None:
- """
- Import all tools from another ToolManager with prefixed names.
-
- Args:
- tool_manager: Another ToolManager instance to import tools from
- prefix: Prefix to add to tool names, including the delimiter.
- The resulting tool name will be in the format "{prefix}{original_name}"
- if prefix is provided, otherwise the original name is used.
- For example, with prefix "weather/" and tool "forecast",
- the imported tool would be available as "weather/forecast"
- """
- for name, tool in tool_manager._tools.items():
- key = f"{prefix}{name}" if prefix else name
- self.add_tool(tool, key=key)
- logger.debug(f'Imported tool "{tool.name}" as "{key}"')
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
index 5121d95f5..17c299e8c 100644
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -67,36 +67,6 @@ def tagged_resources_server():
return server
-@pytest.fixture
-def mounted_resources_server():
- """Fixture that creates a FastMCP server with mounted resources."""
- # Create the main server
- main_server = FastMCP("MainServer")
-
- # Create sub-app with its own resources
- sub_app = FastMCP("SubAppServer")
-
- # Add a resource to the sub-app
- @sub_app.resource(uri="subapp://data", description="SubApp resource")
- async def get_subapp_data():
- return {"source": "subapp"}
-
- # Add a template to the sub-app
- @sub_app.resource(uri="subapp://{id}", description="SubApp template")
- async def get_subapp_item(id: str):
- return {"id": id, "source": "subapp"}
-
- # Mount the sub-app to the main server with a prefix
- main_server.mount("sub", sub_app)
-
- # Add a resource to the main server
- @main_server.resource(uri="main://data", description="Main resource")
- async def get_main_data():
- return {"source": "main"}
-
- return main_server
-
-
async def test_list_tools(fastmcp_server):
"""Test listing tools with InMemoryClient."""
client = Client(transport=FastMCPTransport(fastmcp_server))
@@ -296,61 +266,3 @@ async def test_tagged_template_functionality(tagged_resources_server):
content_str = str(result[0])
assert '"id": "123"' in content_str
assert '"type": "template_data"' in content_str
-
-
-async def test_mounted_resources(mounted_resources_server):
- """Test that resources from mounted apps are correctly prefixed."""
- client = Client(transport=FastMCPTransport(mounted_resources_server))
-
- async with client:
- resources = await client.list_resources()
-
- # Should have two resources (one from main, one from sub)
- assert len(resources) == 2
-
- # Find resources by URI
- main_resource = next(
- (r for r in resources if str(r.uri) == "main://data"), None
- )
- sub_resource = next(
- (r for r in resources if str(r.uri) == "sub+subapp://data"), None
- )
-
- # Both resources should exist
- assert main_resource is not None
- assert sub_resource is not None
-
- # Check descriptions
- assert main_resource.description == "Main resource"
- assert sub_resource.description == "SubApp resource"
-
-
-async def test_mounted_templates(mounted_resources_server):
- """Test that templates from mounted apps are correctly prefixed."""
- client = Client(transport=FastMCPTransport(mounted_resources_server))
-
- async with client:
- templates = await client.list_resource_templates()
-
- # Should have one template (from sub)
- assert len(templates) == 1
-
- # Check the template
- template = templates[0]
- assert "sub+subapp://{id}" in template.uriTemplate
- assert template.description == "SubApp template"
-
-
-async def test_mounted_template_functionality(mounted_resources_server):
- """Test that templates from mounted apps function correctly."""
- client = Client(transport=FastMCPTransport(mounted_resources_server))
-
- async with client:
- # Use the prefixed template
- uri = cast(AnyUrl, "sub+subapp://123")
- result = await client.read_resource(uri)
- content_str = str(result[0])
-
- # Check the content
- assert '"id": "123"' in content_str
- assert '"source": "subapp"' in content_str
diff --git a/tests/prompts/test_base.py b/tests/prompts/test_base.py
index 84455d068..24c05ccd8 100644
--- a/tests/prompts/test_base.py
+++ b/tests/prompts/test_base.py
@@ -12,7 +12,6 @@ from fastmcp.prompts.prompt import (
class TestRenderPrompt:
- @pytest.mark.anyio
async def test_basic_fn(self):
def fn() -> str:
return "Hello, world!"
@@ -22,7 +21,6 @@ class TestRenderPrompt:
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
- @pytest.mark.anyio
async def test_async_fn(self):
async def fn() -> str:
return "Hello, world!"
@@ -32,7 +30,6 @@ class TestRenderPrompt:
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
- @pytest.mark.anyio
async def test_fn_with_args(self):
async def fn(name: str, age: int = 30) -> str:
return f"Hello, {name}! You're {age} years old."
@@ -46,7 +43,6 @@ class TestRenderPrompt:
)
]
- @pytest.mark.anyio
async def test_fn_with_invalid_kwargs(self):
async def fn(name: str, age: int = 30) -> str:
return f"Hello, {name}! You're {age} years old."
@@ -55,7 +51,6 @@ class TestRenderPrompt:
with pytest.raises(ValueError):
await prompt.render(arguments=dict(age=40))
- @pytest.mark.anyio
async def test_fn_returns_message(self):
async def fn() -> Message:
return UserMessage(content="Hello, world!")
@@ -65,7 +60,6 @@ class TestRenderPrompt:
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
- @pytest.mark.anyio
async def test_fn_returns_assistant_message(self):
async def fn() -> Message:
return AssistantMessage(
@@ -77,7 +71,6 @@ class TestRenderPrompt:
AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
]
- @pytest.mark.anyio
async def test_fn_returns_multiple_messages(self):
expected = [
UserMessage("Hello, world!"),
@@ -91,7 +84,6 @@ class TestRenderPrompt:
prompt = Prompt.from_function(fn)
assert await prompt.render() == expected
- @pytest.mark.anyio
async def test_fn_returns_list_of_strings(self):
expected = [
"Hello, world!",
@@ -104,7 +96,6 @@ class TestRenderPrompt:
prompt = Prompt.from_function(fn)
assert await prompt.render() == [UserMessage(t) for t in expected]
- @pytest.mark.anyio
async def test_fn_returns_resource_content(self):
"""Test returning a message with resource content."""
@@ -134,7 +125,6 @@ class TestRenderPrompt:
)
]
- @pytest.mark.anyio
async def test_fn_returns_mixed_content(self):
"""Test returning messages with mixed content types."""
@@ -174,7 +164,6 @@ class TestRenderPrompt:
),
]
- @pytest.mark.anyio
async def test_fn_returns_dict_with_resource(self):
"""Test returning a dict with resource content."""
diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py
index e53148987..4b6063186 100644
--- a/tests/prompts/test_prompt_manager.py
+++ b/tests/prompts/test_prompt_manager.py
@@ -1,8 +1,8 @@
import pytest
-from fastmcp.exceptions import PromptError
+from fastmcp.exceptions import NotFoundError
from fastmcp.prompts import Prompt
-from fastmcp.prompts.prompt import PromptArgument, TextContent, UserMessage
+from fastmcp.prompts.prompt import TextContent, UserMessage
from fastmcp.prompts.prompt_manager import PromptManager
@@ -119,8 +119,8 @@ class TestPromptManager:
# Result should be the original prompt
assert result.fn.__name__ == "original_fn"
- def test_list_prompts(self):
- """Test listing all prompts."""
+ def test_get_prompts(self):
+ """Test retrieving all prompts."""
def fn1() -> str:
return "Hello, world!"
@@ -133,11 +133,11 @@ class TestPromptManager:
prompt2 = Prompt.from_function(fn2)
manager.add_prompt(prompt1)
manager.add_prompt(prompt2)
- prompts = manager.list_prompts()
+ prompts = manager.get_prompts()
assert len(prompts) == 2
- assert prompts == [prompt1, prompt2]
+ assert prompts["fn1"] == prompt1
+ assert prompts["fn2"] == prompt2
- @pytest.mark.anyio
async def test_render_prompt(self):
"""Test rendering a prompt."""
@@ -152,7 +152,6 @@ class TestPromptManager:
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
- @pytest.mark.anyio
async def test_render_prompt_with_args(self):
"""Test rendering a prompt with arguments."""
@@ -167,14 +166,12 @@ class TestPromptManager:
UserMessage(content=TextContent(type="text", text="Hello, World!"))
]
- @pytest.mark.anyio
async def test_render_unknown_prompt(self):
"""Test rendering a non-existent prompt."""
manager = PromptManager()
- with pytest.raises(PromptError, match="Unknown prompt: unknown"):
+ with pytest.raises(NotFoundError, match="Unknown prompt: unknown"):
await manager.render_prompt("unknown")
- @pytest.mark.anyio
async def test_render_prompt_with_missing_args(self):
"""Test rendering a prompt with missing required arguments."""
@@ -253,132 +250,12 @@ class TestPromptTags:
)
# Filter prompts by tags
- simple_prompts = [p for p in manager.list_prompts() if "simple" in p.tags]
+ simple_prompts = [
+ p for p in manager.get_prompts().values() if "simple" in p.tags
+ ]
assert len(simple_prompts) == 2
assert {p.name for p in simple_prompts} == {"greeting", "summary"}
- nlp_prompts = [p for p in manager.list_prompts() if "nlp" in p.tags]
+ nlp_prompts = [p for p in manager.get_prompts().values() if "nlp" in p.tags]
assert len(nlp_prompts) == 1
assert nlp_prompts[0].name == "summary"
-
- def test_import_prompts_preserves_tags(self):
- """Test that importing prompts preserves their tags."""
- source_manager = PromptManager()
-
- def sample_prompt() -> str:
- return "Sample prompt"
-
- source_manager.add_prompt(
- Prompt.from_function(sample_prompt, tags={"example", "test"})
- )
-
- target_manager = PromptManager()
- target_manager.import_prompts(source_manager, "imported/")
-
- imported_prompt = target_manager.get_prompt("imported/sample_prompt")
- assert imported_prompt is not None
- assert imported_prompt.tags == {"example", "test"}
-
-
-class TestImports:
- def test_import_prompts(self):
- """Test importing prompts from one manager to another with a prefix."""
- # Setup source manager with prompts
- source_manager = PromptManager()
-
- summary_prompt = Prompt(
- name="summary",
- description="Generate a summary of text",
- arguments=[PromptArgument(name="text", description="Text to summarize")],
- fn=lambda: None, # type: ignore
- )
- source_manager.add_prompt(summary_prompt)
-
- translate_prompt = Prompt(
- name="translate",
- description="Translate text to another language",
- arguments=[
- PromptArgument(name="text", description="Text to translate"),
- PromptArgument(name="language", description="Target language"),
- ],
- fn=lambda: None, # type: ignore
- )
- source_manager.add_prompt(translate_prompt)
-
- # Create target manager
- target_manager = PromptManager()
-
- # Import prompts from source to target
- prefix = "nlp/"
- target_manager.import_prompts(source_manager, prefix)
-
- # Verify prompts were imported with prefixes
- assert "nlp/summary" in target_manager._prompts
- assert "nlp/translate" in target_manager._prompts
-
- # Verify the original prompts still exist in source manager
- assert "summary" in source_manager._prompts
- assert "translate" in source_manager._prompts
-
- assert target_manager._prompts["nlp/summary"].fn == summary_prompt.fn
- assert target_manager._prompts["nlp/translate"].fn == translate_prompt.fn
-
- def test_import_prompts_with_duplicates(self):
- """Test handling of duplicate prompts during import."""
- # Setup source and target managers with same prompt names
- source_manager = PromptManager()
- target_manager = PromptManager()
-
- source_prompt = Prompt(
- name="common",
- description="Source description",
- arguments=None,
- fn=lambda: None, # type: ignore
- )
- source_manager._prompts["common"] = source_prompt
-
- target_prompt = Prompt(
- name="common",
- description="Target description",
- arguments=None,
- fn=lambda: None, # type: ignore
- )
- target_manager._prompts["common"] = target_prompt
-
- # Import prompts with prefix
- prefix = "external/"
- target_manager.import_prompts(source_manager, prefix)
-
- # Verify both prompts exist in target manager
- assert "common" in target_manager._prompts
- assert "external/common" in target_manager._prompts
-
- assert target_manager._prompts["common"].fn == target_prompt.fn
- assert target_manager._prompts["external/common"].fn == source_prompt.fn
-
- def test_import_prompts_with_nested_prefixes(self):
- """Test importing already prefixed prompts."""
- # Setup source manager with already prefixed prompts
- first_manager = PromptManager()
- second_manager = PromptManager()
- third_manager = PromptManager()
-
- original_prompt = Prompt(
- name="analyze",
- description="Analyze text",
- arguments=[PromptArgument(name="text", description="Text to analyze")],
- fn=lambda: None, # type: ignore
- )
- first_manager._prompts["analyze"] = original_prompt
-
- # Import to second manager with prefix
- second_manager.import_prompts(first_manager, "text/")
-
- # Import from second to third with another prefix
- third_manager.import_prompts(second_manager, "ai/")
-
- # Verify the nested prefixing
- assert "text/analyze" in second_manager._prompts
- assert "ai/text/analyze" in third_manager._prompts
-
- assert third_manager._prompts["ai/text/analyze"].fn == original_prompt.fn
diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py
index ef05e32f3..598e9d88d 100644
--- a/tests/resources/test_file_resources.py
+++ b/tests/resources/test_file_resources.py
@@ -53,7 +53,6 @@ class TestFileResource:
assert isinstance(resource.path, Path)
assert resource.path.is_absolute()
- @pytest.mark.anyio
async def test_read_text_file(self, temp_file: Path):
"""Test reading a text file."""
resource = FileResource(
@@ -65,7 +64,6 @@ class TestFileResource:
assert content == "test content"
assert resource.mime_type == "text/plain"
- @pytest.mark.anyio
async def test_read_binary_file(self, temp_file: Path):
"""Test reading a file as binary."""
resource = FileResource(
@@ -87,7 +85,6 @@ class TestFileResource:
path=Path("test.txt"),
)
- @pytest.mark.anyio
async def test_missing_file_error(self, temp_file: Path):
"""Test error when file doesn't exist."""
# Create path to non-existent file
@@ -103,7 +100,6 @@ class TestFileResource:
@pytest.mark.skipif(
os.name == "nt", reason="File permissions behave differently on Windows"
)
- @pytest.mark.anyio
async def test_permission_error(self, temp_file: Path):
"""Test reading a file without permissions."""
temp_file.chmod(0o000) # Remove all permissions
diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py
index 5faba0b88..c179fd26c 100644
--- a/tests/resources/test_function_resources.py
+++ b/tests/resources/test_function_resources.py
@@ -25,7 +25,6 @@ class TestFunctionResource:
assert resource.mime_type == "text/plain" # default
assert resource.fn == my_func
- @pytest.mark.anyio
async def test_read_text(self):
"""Test reading text from a FunctionResource."""
@@ -41,7 +40,6 @@ class TestFunctionResource:
assert content == "Hello, world!"
assert resource.mime_type == "text/plain"
- @pytest.mark.anyio
async def test_read_binary(self):
"""Test reading binary data from a FunctionResource."""
@@ -56,7 +54,6 @@ class TestFunctionResource:
content = await resource.read()
assert content == b"Hello, world!"
- @pytest.mark.anyio
async def test_json_conversion(self):
"""Test automatic JSON conversion of non-string results."""
@@ -72,7 +69,6 @@ class TestFunctionResource:
assert isinstance(content, str)
assert '"key": "value"' in content
- @pytest.mark.anyio
async def test_error_handling(self):
"""Test error handling in FunctionResource."""
@@ -87,7 +83,6 @@ class TestFunctionResource:
with pytest.raises(ValueError, match="Error reading resource function://test"):
await resource.read()
- @pytest.mark.anyio
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
@@ -102,7 +97,6 @@ class TestFunctionResource:
content = await resource.read()
assert content == '{"name": "test"}'
- @pytest.mark.anyio
async def test_custom_type_conversion(self):
"""Test handling of custom types."""
@@ -121,7 +115,6 @@ class TestFunctionResource:
content = await resource.read()
assert isinstance(content, str)
- @pytest.mark.anyio
async def test_async_read_text(self):
"""Test reading text from async FunctionResource."""
diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py
index 34f36ff14..293463115 100644
--- a/tests/resources/test_resource_manager.py
+++ b/tests/resources/test_resource_manager.py
@@ -4,7 +4,7 @@ from tempfile import NamedTemporaryFile
import pytest
from pydantic import AnyUrl, FileUrl
-from fastmcp.exceptions import ResourceError
+from fastmcp.exceptions import NotFoundError
from fastmcp.resources import (
FileResource,
FunctionResource,
@@ -36,34 +36,41 @@ class TestResourceManager:
def test_add_resource(self, temp_file: Path):
"""Test adding a resource."""
manager = ResourceManager()
+ file_url = "file://test-resource"
resource = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(file_url),
name="test",
path=temp_file,
)
added = manager.add_resource(resource)
assert added == resource
- assert manager.list_resources() == [resource]
+ # Get the actual key from the resource manager
+ assert len(manager.get_resources()) == 1
+ assert resource in manager.get_resources().values()
def test_add_duplicate_resource(self, temp_file: Path):
"""Test adding the same resource twice."""
manager = ResourceManager()
+ file_url = "file://test-resource"
resource = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(file_url),
name="test",
path=temp_file,
)
first = manager.add_resource(resource)
second = manager.add_resource(resource)
assert first == second
- assert manager.list_resources() == [resource]
+ # Check the resource is there
+ assert len(manager.get_resources()) == 1
+ assert resource in manager.get_resources().values()
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
"""Test warning on duplicate resources."""
manager = ResourceManager(duplicate_behavior="warn")
+ file_url = "file://test-resource"
resource = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(file_url),
name="test_resource",
path=temp_file,
)
@@ -73,13 +80,14 @@ class TestResourceManager:
assert "Resource already exists" in caplog.text
# Should have the resource
- assert len(manager.list_resources()) == 1
+ assert len(manager.get_resources()) == 1
+ assert resource in manager.get_resources().values()
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
"""Test disabling warning on duplicate resources."""
manager = ResourceManager(duplicate_behavior="ignore")
resource = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(f"file://{temp_file.name}"),
name="test",
path=temp_file,
)
@@ -92,7 +100,7 @@ class TestResourceManager:
manager = ResourceManager(duplicate_behavior="error")
resource = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(f"file://{temp_file.name}"),
name="test_resource",
path=temp_file,
)
@@ -106,14 +114,15 @@ class TestResourceManager:
"""Test replacing duplicate resources."""
manager = ResourceManager(duplicate_behavior="replace")
+ file_url = "file://test-resource"
resource1 = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(file_url),
name="original",
path=temp_file,
)
resource2 = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(file_url),
name="replacement",
path=temp_file,
)
@@ -122,7 +131,7 @@ class TestResourceManager:
manager.add_resource(resource2)
# Should have replaced with the new resource
- resources = manager.list_resources()
+ resources = list(manager.get_resources().values())
assert len(resources) == 1
assert resources[0].name == "replacement"
@@ -130,14 +139,15 @@ class TestResourceManager:
"""Test ignoring duplicate resources."""
manager = ResourceManager(duplicate_behavior="ignore")
+ file_url = "file://test-resource"
resource1 = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(file_url),
name="original",
path=temp_file,
)
resource2 = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(file_url),
name="replacement",
path=temp_file,
)
@@ -146,7 +156,7 @@ class TestResourceManager:
result = manager.add_resource(resource2)
# Should keep the original
- resources = manager.list_resources()
+ resources = list(manager.get_resources().values())
assert len(resources) == 1
assert resources[0].name == "original"
# Result should be the original resource
@@ -170,7 +180,7 @@ class TestResourceManager:
assert "Template already exists" in caplog.text
# Should have the template
- assert len(manager.list_templates()) == 1
+ assert manager.get_templates() == {"test://{id}": template}
def test_error_on_duplicate_templates(self):
"""Test error on duplicate templates."""
@@ -216,7 +226,7 @@ class TestResourceManager:
manager.add_template(template2)
# Should have replaced with the new template
- templates = manager.list_templates()
+ templates = list(manager.get_templates().values())
assert len(templates) == 1
assert templates[0].name == "replacement"
@@ -246,18 +256,17 @@ class TestResourceManager:
result = manager.add_template(template2)
# Should keep the original
- templates = manager.list_templates()
+ templates = list(manager.get_templates().values())
assert len(templates) == 1
assert templates[0].name == "original"
# Result should be the original template
assert result.name == "original"
- @pytest.mark.anyio
async def test_get_resource(self, temp_file: Path):
"""Test getting a resource by URI."""
manager = ResourceManager()
resource = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(f"file://{temp_file.name}"),
name="test",
path=temp_file,
)
@@ -265,7 +274,6 @@ class TestResourceManager:
retrieved = await manager.get_resource(resource.uri)
assert retrieved == resource
- @pytest.mark.anyio
async def test_get_resource_from_template(self):
"""Test getting a resource through a template."""
manager = ResourceManager()
@@ -285,31 +293,34 @@ class TestResourceManager:
content = await resource.read()
assert content == "Hello, world!"
- @pytest.mark.anyio
async def test_get_unknown_resource(self):
"""Test getting a non-existent resource."""
manager = ResourceManager()
- with pytest.raises(ResourceError, match="Unknown resource"):
+ with pytest.raises(NotFoundError, match="Unknown resource"):
await manager.get_resource(AnyUrl("unknown://test"))
- def test_list_resources(self, temp_file: Path):
- """Test listing all resources."""
+ def test_get_resources(self, temp_file: Path):
+ """Test retrieving all resources."""
manager = ResourceManager()
+ file_url1 = "file://test-resource1"
resource1 = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl(file_url1),
name="test1",
path=temp_file,
)
+ file_url2 = "file://test-resource2"
resource2 = FileResource(
- uri=FileUrl(f"file://{temp_file}2"),
+ uri=FileUrl(file_url2),
name="test2",
path=temp_file,
)
manager.add_resource(resource1)
manager.add_resource(resource2)
- resources = manager.list_resources()
+ resources = manager.get_resources()
assert len(resources) == 2
- assert resources == [resource1, resource2]
+ values = list(resources.values())
+ assert resource1 in values
+ assert resource2 in values
class TestResourceTags:
@@ -319,7 +330,7 @@ class TestResourceTags:
"""Test adding a resource with tags."""
manager = ResourceManager()
resource = FileResource(
- uri=FileUrl(f"file://{temp_file}"),
+ uri=FileUrl("file://weather-data"),
name="weather_data",
path=temp_file,
tags={"weather", "data"},
@@ -327,7 +338,7 @@ class TestResourceTags:
manager.add_resource(resource)
# Check that tags are preserved
- resources = manager.list_resources()
+ resources = list(manager.get_resources().values())
assert len(resources) == 1
assert resources[0].tags == {"weather", "data"}
@@ -348,7 +359,7 @@ class TestResourceTags:
)
manager.add_resource(resource)
- resources = manager.list_resources()
+ resources = list(manager.get_resources().values())
assert len(resources) == 1
assert resources[0].tags == {"sample", "test", "data"}
@@ -368,7 +379,7 @@ class TestResourceTags:
)
manager.add_template(template)
- templates = manager.list_templates()
+ templates = list(manager.get_templates().values())
assert len(templates) == 1
assert templates[0].tags == {"users", "template", "data"}
@@ -378,7 +389,7 @@ class TestResourceTags:
# Create multiple resources with different tags
resource1 = FileResource(
- uri=FileUrl(f"file://{temp_file}1"),
+ uri=FileUrl("file://weather-data"),
name="weather_data",
path=temp_file,
tags={"weather", "external"},
@@ -410,285 +421,17 @@ class TestResourceTags:
# Filter resources by tags
internal_resources = [
- r for r in manager.list_resources() if "internal" in r.tags
+ r for r in manager.get_resources().values() if "internal" in r.tags
]
assert len(internal_resources) == 2
assert {r.name for r in internal_resources} == {"user_data", "system_data"}
external_resources = [
- r for r in manager.list_resources() if "external" in r.tags
+ r for r in manager.get_resources().values() if "external" in r.tags
]
assert len(external_resources) == 1
assert external_resources[0].name == "weather_data"
- def test_import_resources_preserves_tags(self):
- """Test that importing resources preserves their tags."""
- source_manager = ResourceManager()
-
- async def get_data():
- return "Tagged data"
-
- resource = FunctionResource(
- uri=AnyUrl("data://tagged"),
- name="tagged_data",
- fn=get_data,
- tags={"test", "example", "data"},
- )
-
- source_manager.add_resource(resource)
-
- target_manager = ResourceManager()
- target_manager.import_resources(source_manager, "imported+")
-
- imported_resources = target_manager.list_resources()
- assert len(imported_resources) == 1
- assert imported_resources[0].tags == {"test", "example", "data"}
-
- def test_import_templates_preserves_tags(self):
- """Test that importing templates preserves their tags."""
- source_manager = ResourceManager()
-
- def user_template(user_id: str) -> str:
- return f"User {user_id}"
-
- template = ResourceTemplate.from_function(
- fn=user_template,
- uri_template="users://{user_id}",
- name="user_template",
- tags={"users", "template", "test"},
- )
-
- source_manager.add_template(template)
-
- target_manager = ResourceManager()
- target_manager.import_templates(source_manager, "imported+")
-
- imported_templates = target_manager.list_templates()
- assert len(imported_templates) == 1
- assert imported_templates[0].tags == {"users", "template", "test"}
-
-
-class TestImports:
- def test_import_resources(self):
- """Test importing resources from one manager to another with a prefix."""
- # Setup source manager with resources
- source_manager = ResourceManager()
-
- # Create mock resource functions
- async def weather_fn():
- return "Weather data"
-
- async def traffic_fn():
- return "Traffic data"
-
- # Add resources to source manager
- weather_resource = FunctionResource(
- uri=AnyUrl("weather://forecast"),
- name="weather_forecast",
- description="Get weather forecast",
- mime_type="application/json",
- fn=weather_fn,
- )
- source_manager._resources["weather://forecast"] = weather_resource
-
- traffic_resource = FunctionResource(
- uri=AnyUrl("traffic://status"),
- name="traffic_status",
- description="Get traffic status",
- mime_type="application/json",
- fn=traffic_fn,
- )
- source_manager._resources["traffic://status"] = traffic_resource
-
- # Create target manager
- target_manager = ResourceManager()
-
- # Import resources from source to target
- prefix = "data+"
- target_manager.import_resources(source_manager, prefix)
-
- # Verify resources were imported with prefixes
- assert "data+weather://forecast" in target_manager._resources
- assert "data+traffic://status" in target_manager._resources
-
- # Verify the original resources still exist in source manager
- assert "weather://forecast" in source_manager._resources
- assert "traffic://status" in source_manager._resources
-
- # Verify the imported resources have the correct properties
- assert (
- target_manager._resources["data+weather://forecast"].name
- == "weather_forecast"
- )
- assert (
- target_manager._resources["data+weather://forecast"].description
- == "Get weather forecast"
- )
- assert (
- target_manager._resources["data+weather://forecast"].mime_type
- == "application/json"
- )
-
- assert (
- target_manager._resources["data+traffic://status"].name == "traffic_status"
- )
- assert (
- target_manager._resources["data+traffic://status"].description
- == "Get traffic status"
- )
- assert (
- target_manager._resources["data+traffic://status"].mime_type
- == "application/json"
- )
-
- # Since we're dealing with FunctionResource type, we can safely check function attributes
- assert isinstance(
- target_manager._resources["data+weather://forecast"], FunctionResource
- )
- assert isinstance(
- target_manager._resources["data+traffic://status"], FunctionResource
- )
-
- weather_resource = target_manager._resources["data+weather://forecast"]
- traffic_resource = target_manager._resources["data+traffic://status"]
-
- if hasattr(weather_resource, "fn") and hasattr(traffic_resource, "fn"):
- assert weather_resource.fn.__name__ == weather_fn.__name__
- assert traffic_resource.fn.__name__ == traffic_fn.__name__
-
- def test_import_templates(self):
- """Test importing resource templates from one manager to another with a prefix."""
- # Setup source manager with templates
- source_manager = ResourceManager()
-
- # Create mock template functions
- async def user_fn(**params):
- return f"User data for id {params.get('id')}"
-
- async def product_fn(**params):
- return f"Product data for id {params.get('id')}"
-
- # Add templates to source manager
- user_template = ResourceTemplate(
- uri_template="api://users/{id}",
- name="user_template",
- description="Get user by ID",
- mime_type="application/json",
- fn=user_fn,
- parameters={"id": {"type": "string", "description": "User ID"}},
- )
- source_manager._templates["api://users/{id}"] = user_template
-
- product_template = ResourceTemplate(
- uri_template="api://products/{id}",
- name="product_template",
- description="Get product by ID",
- mime_type="application/json",
- fn=product_fn,
- parameters={"id": {"type": "string", "description": "Product ID"}},
- )
- source_manager._templates["api://products/{id}"] = product_template
-
- # Create target manager
- target_manager = ResourceManager()
-
- # Import templates from source to target
- prefix = "shop+"
- target_manager.import_templates(source_manager, prefix)
-
- # Verify templates were imported with prefixes
- assert "shop+api://users/{id}" in target_manager._templates
- assert "shop+api://products/{id}" in target_manager._templates
-
- # Verify the original templates still exist in source manager
- assert "api://users/{id}" in source_manager._templates
- assert "api://products/{id}" in source_manager._templates
-
- # Verify the imported templates have the correct properties
- assert (
- target_manager._templates["shop+api://users/{id}"].name == "user_template"
- )
- assert (
- target_manager._templates["shop+api://users/{id}"].description
- == "Get user by ID"
- )
- assert (
- target_manager._templates["shop+api://users/{id}"].mime_type
- == "application/json"
- )
- assert target_manager._templates["shop+api://users/{id}"].parameters == {
- "id": {"type": "string", "description": "User ID"}
- }
-
- assert (
- target_manager._templates["shop+api://products/{id}"].name
- == "product_template"
- )
- assert (
- target_manager._templates["shop+api://products/{id}"].description
- == "Get product by ID"
- )
- assert (
- target_manager._templates["shop+api://products/{id}"].mime_type
- == "application/json"
- )
- assert target_manager._templates["shop+api://products/{id}"].parameters == {
- "id": {"type": "string", "description": "Product ID"}
- }
-
- # Verify the template functions were properly copied (only if the fn attribute exists)
- user_template = target_manager._templates["shop+api://users/{id}"]
- product_template = target_manager._templates["shop+api://products/{id}"]
-
- if hasattr(user_template, "fn") and hasattr(product_template, "fn"):
- assert user_template.fn.__name__ == user_fn.__name__
- assert product_template.fn.__name__ == product_fn.__name__
-
- def test_import_multiple_resource_types(self):
- """Test importing both resources and templates with the same prefix."""
- # Setup source manager with both resources and templates
- source_manager = ResourceManager()
-
- # Create mock functions
- async def resource_fn():
- return "Resource data"
-
- async def template_fn(**params):
- return f"Template data for id {params.get('id')}"
-
- # Add a resource to source manager
- resource = FunctionResource(
- uri=AnyUrl("data://resource"),
- name="test_resource",
- description="Test resource",
- mime_type="application/json",
- fn=resource_fn,
- )
- source_manager._resources["data://resource"] = resource
-
- # Add a template to source manager
- template = ResourceTemplate(
- uri_template="data://template/{id}",
- name="test_template",
- description="Test template",
- mime_type="application/json",
- fn=template_fn,
- parameters={"id": {"type": "string", "description": "ID parameter"}},
- )
- source_manager._templates["data://template/{id}"] = template
-
- # Create target manager
- target_manager = ResourceManager()
-
- # Import both resources and templates
- prefix = "test+"
- target_manager.import_resources(source_manager, prefix)
- target_manager.import_templates(source_manager, prefix)
-
- # Verify both resource types were imported with prefixes
- assert "test+data://resource" in target_manager._resources
- assert "test+data://template/{id}" in target_manager._templates
-
class TestCustomResourceKeys:
"""Test adding resources and templates with custom keys."""
@@ -743,7 +486,6 @@ class TestCustomResourceKeys:
# The template's internal URI template remains unchanged
assert str(manager._templates[custom_key].uri_template) == original_uri_template
- @pytest.mark.anyio
async def test_get_resource_with_custom_key(self, temp_file: Path):
"""Test that get_resource works with resources added with custom keys."""
manager = ResourceManager()
@@ -768,10 +510,9 @@ class TestCustomResourceKeys:
assert str(retrieved.uri) == original_uri
# Should NOT be retrievable by the original URI
- with pytest.raises(ResourceError, match="Unknown resource"):
+ with pytest.raises(NotFoundError, match="Unknown resource"):
await manager.get_resource(original_uri)
- @pytest.mark.anyio
async def test_get_resource_from_template_with_custom_key(self):
"""Test that templates with custom keys can create resources."""
manager = ResourceManager()
@@ -797,63 +538,5 @@ class TestCustomResourceKeys:
assert content == "Hello, world!"
# Shouldn't work with the original template pattern
- with pytest.raises(ResourceError, match="Unknown resource"):
+ with pytest.raises(NotFoundError, match="Unknown resource"):
await manager.get_resource("greet://world")
-
- def test_import_resources_with_custom_keys(self):
- """Test that import_resources properly uses custom keys."""
- source_manager = ResourceManager()
- target_manager = ResourceManager()
-
- # Add a resource to source manager
- async def resource_fn():
- return "Resource data"
-
- original_uri = "data://original"
- resource = FunctionResource(
- uri=AnyUrl(original_uri),
- name="original_resource",
- fn=resource_fn,
- )
- source_manager.add_resource(resource)
-
- # Import with prefix which creates a new key
- prefix = "imported+"
- target_manager.import_resources(source_manager, prefix)
-
- # Resource should be in target manager with prefixed URI as key
- prefixed_uri = f"{prefix}{original_uri}"
- assert prefixed_uri in target_manager._resources
-
- # The resource's internal URI should remain unchanged
- stored_resource = target_manager._resources[prefixed_uri]
- assert str(stored_resource.uri) == original_uri
-
- def test_import_templates_with_custom_keys(self):
- """Test that import_templates properly uses custom keys."""
- source_manager = ResourceManager()
- target_manager = ResourceManager()
-
- # Add a template to source manager
- async def template_fn(id: str):
- return f"Template {id}"
-
- original_template = "template://{id}"
- template = ResourceTemplate.from_function(
- fn=template_fn,
- uri_template=original_template,
- name="original_template",
- )
- source_manager.add_template(template)
-
- # Import with prefix which creates a new key
- prefix = "imported+"
- target_manager.import_templates(source_manager, prefix)
-
- # Template should be in target manager with prefixed URI template as key
- prefixed_template = f"{prefix}{original_template}"
- assert prefixed_template in target_manager._templates
-
- # The template's internal URI template should remain unchanged
- stored_template = target_manager._templates[prefixed_template]
- assert str(stored_template.uri_template) == original_template
diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py
index 301a48297..a8b01c11c 100644
--- a/tests/resources/test_resource_template.py
+++ b/tests/resources/test_resource_template.py
@@ -162,7 +162,6 @@ class TestResourceTemplate:
name="test",
)
- @pytest.mark.anyio
async def test_create_resource(self):
"""Test creating a resource from a template."""
@@ -186,7 +185,6 @@ class TestResourceTemplate:
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
- @pytest.mark.anyio
async def test_template_error(self):
"""Test error handling in template resource creation."""
@@ -202,7 +200,6 @@ class TestResourceTemplate:
with pytest.raises(ValueError, match="Error creating resource from template"):
await template.create_resource("fail://test", {"x": "test"})
- @pytest.mark.anyio
async def test_async_text_resource(self):
"""Test creating a text resource from async function."""
@@ -224,7 +221,6 @@ class TestResourceTemplate:
content = await resource.read()
assert content == "Hello, world!"
- @pytest.mark.anyio
async def test_async_binary_resource(self):
"""Test creating a binary resource from async function."""
@@ -246,7 +242,6 @@ class TestResourceTemplate:
content = await resource.read()
assert content == b"test"
- @pytest.mark.anyio
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
@@ -274,7 +269,6 @@ class TestResourceTemplate:
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
- @pytest.mark.anyio
async def test_custom_type_conversion(self):
"""Test handling of custom types."""
diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py
index 870002c35..9eb3d3721 100644
--- a/tests/resources/test_resources.py
+++ b/tests/resources/test_resources.py
@@ -90,7 +90,6 @@ class TestResourceValidation:
)
assert resource.mime_type == "application/json"
- @pytest.mark.anyio
async def test_resource_read_abstract(self):
"""Test that Resource.read() is abstract."""
diff --git a/tests/server/test_file_server.py b/tests/server/test_file_server.py
index 6971c5ebd..b483ac110 100644
--- a/tests/server/test_file_server.py
+++ b/tests/server/test_file_server.py
@@ -73,7 +73,6 @@ def tools(mcp: FastMCP, test_dir: Path) -> FastMCP:
return mcp
-@pytest.mark.anyio
async def test_list_resources(mcp: FastMCP):
resources = await mcp._mcp_list_resources()
assert len(resources) == 4
@@ -86,7 +85,6 @@ async def test_list_resources(mcp: FastMCP):
]
-@pytest.mark.anyio
async def test_read_resource_dir(mcp: FastMCP):
res_iter = await mcp._mcp_read_resource("dir://test_dir")
res_list = list(res_iter)
@@ -103,7 +101,6 @@ async def test_read_resource_dir(mcp: FastMCP):
]
-@pytest.mark.anyio
async def test_read_resource_file(mcp: FastMCP):
res_iter = await mcp._mcp_read_resource("file://test_dir/example.py")
res_list = list(res_iter)
@@ -112,17 +109,15 @@ async def test_read_resource_file(mcp: FastMCP):
assert res.content == "print('hello world')"
-@pytest.mark.anyio
async def test_delete_file(mcp: FastMCP, test_dir: Path):
- await mcp.call_tool(
+ await mcp._mcp_call_tool(
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
)
assert not (test_dir / "example.py").exists()
-@pytest.mark.anyio
async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
- await mcp.call_tool(
+ await mcp._mcp_call_tool(
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
)
res_iter = await mcp._mcp_read_resource("file://test_dir/example.py")
diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py
new file mode 100644
index 000000000..c9160e4b1
--- /dev/null
+++ b/tests/server/test_import_server.py
@@ -0,0 +1,391 @@
+import json
+from urllib.parse import quote
+
+from mcp.types import TextContent, TextResourceContents
+
+from fastmcp.client.client import Client
+from fastmcp.server.server import FastMCP
+
+
+async def test_import_basic_functionality():
+ """Test that the import method properly imports tools and other resources."""
+ # Create main app and sub-app
+ main_app = FastMCP("MainApp")
+ sub_app = FastMCP("SubApp")
+
+ # Add a tool to the sub-app
+ @sub_app.tool()
+ def sub_tool() -> str:
+ return "This is from the sub app"
+
+ # Import the sub-app to the main app
+ await main_app.import_server("sub", sub_app)
+
+ # Verify the tool was imported with the prefix
+ assert "sub_sub_tool" in main_app._tool_manager._tools
+ assert "sub_tool" in sub_app._tool_manager._tools
+
+ # Verify the original tool still exists in the sub-app
+ tool = main_app._tool_manager.get_tool("sub_sub_tool")
+ assert tool is not None
+ assert tool.name == "sub_tool"
+ assert callable(tool.fn)
+
+
+async def test_import_multiple_apps():
+ """Test importing multiple apps to a main app."""
+ # Create main app and multiple sub-apps
+ main_app = FastMCP("MainApp")
+ weather_app = FastMCP("WeatherApp")
+ news_app = FastMCP("NewsApp")
+
+ # Add tools to each sub-app
+ @weather_app.tool()
+ def get_forecast() -> str:
+ return "Weather forecast"
+
+ @news_app.tool()
+ def get_headlines() -> str:
+ return "News headlines"
+
+ # Import both sub-apps to the main app
+ await main_app.import_server("weather", weather_app)
+ await main_app.import_server("news", news_app)
+
+ # Verify tools were imported with the correct prefixes
+ assert "weather_get_forecast" in main_app._tool_manager._tools
+ assert "news_get_headlines" in main_app._tool_manager._tools
+
+
+async def test_import_combines_tools():
+ """Test that importing preserves existing tools with the same prefix."""
+ # Create apps
+ main_app = FastMCP("MainApp")
+ first_app = FastMCP("FirstApp")
+ second_app = FastMCP("SecondApp")
+
+ # Add tools to each sub-app
+ @first_app.tool()
+ def first_tool() -> str:
+ return "First app tool"
+
+ @second_app.tool()
+ def second_tool() -> str:
+ return "Second app tool"
+
+ # Import first app
+ await main_app.import_server("api", first_app)
+ assert "api_first_tool" in main_app._tool_manager._tools
+
+ # Import second app to same prefix
+ await main_app.import_server("api", second_app)
+
+ # Verify second tool is there
+ assert "api_second_tool" in main_app._tool_manager._tools
+
+ # Tools from both imports are combined
+ assert "api_first_tool" in main_app._tool_manager._tools
+
+
+async def test_import_with_resources():
+ """Test importing with resources."""
+ # Create apps
+ main_app = FastMCP("MainApp")
+ data_app = FastMCP("DataApp")
+
+ # Add a resource to the data app
+ @data_app.resource(uri="data://users")
+ async def get_users():
+ return ["user1", "user2"]
+
+ # Import the data app
+ await main_app.import_server("data", data_app)
+
+ # Verify the resource was imported with the prefix
+ assert "data+data://users" in main_app._resource_manager._resources
+
+
+async def test_import_with_resource_templates():
+ """Test importing with resource templates."""
+ # Create apps
+ main_app = FastMCP("MainApp")
+ user_app = FastMCP("UserApp")
+
+ # Add a resource template to the user app
+ @user_app.resource(uri="users://{user_id}/profile")
+ def get_user_profile(user_id: str) -> dict:
+ return {"id": user_id, "name": f"User {user_id}"}
+
+ # Import the user app
+ await main_app.import_server("api", user_app)
+
+ # Verify the template was imported with the prefix
+ assert "api+users://{user_id}/profile" in main_app._resource_manager._templates
+
+
+async def test_import_with_prompts():
+ """Test importing with prompts."""
+ # Create apps
+ main_app = FastMCP("MainApp")
+ assistant_app = FastMCP("AssistantApp")
+
+ # Add a prompt to the assistant app
+ @assistant_app.prompt()
+ def greeting(name: str) -> str:
+ return f"Hello, {name}!"
+
+ # Import the assistant app
+ await main_app.import_server("assistant", assistant_app)
+
+ # Verify the prompt was imported with the prefix
+ assert "assistant_greeting" in main_app._prompt_manager._prompts
+
+
+async def test_import_multiple_resource_templates():
+ """Test importing multiple apps with resource templates."""
+ # Create apps
+ main_app = FastMCP("MainApp")
+ weather_app = FastMCP("WeatherApp")
+ news_app = FastMCP("NewsApp")
+
+ # Add templates to each app
+ @weather_app.resource(uri="weather://{city}")
+ def get_weather(city: str) -> str:
+ return f"Weather for {city}"
+
+ @news_app.resource(uri="news://{category}")
+ def get_news(category: str) -> str:
+ return f"News for {category}"
+
+ # Import both apps
+ await main_app.import_server("data", weather_app)
+ await main_app.import_server("content", news_app)
+
+ # Verify templates were imported with correct prefixes
+ assert "data+weather://{city}" in main_app._resource_manager._templates
+ assert "content+news://{category}" in main_app._resource_manager._templates
+
+
+async def test_import_multiple_prompts():
+ """Test importing multiple apps with prompts."""
+ # Create apps
+ main_app = FastMCP("MainApp")
+ python_app = FastMCP("PythonApp")
+ sql_app = FastMCP("SQLApp")
+
+ # Add prompts to each app
+ @python_app.prompt()
+ def review_python(code: str) -> str:
+ return f"Reviewing Python code:\n{code}"
+
+ @sql_app.prompt()
+ def explain_sql(query: str) -> str:
+ return f"Explaining SQL query:\n{query}"
+
+ # Import both apps
+ await main_app.import_server("python", python_app)
+ await main_app.import_server("sql", sql_app)
+
+ # Verify prompts were imported with correct prefixes
+ assert "python_review_python" in main_app._prompt_manager._prompts
+ assert "sql_explain_sql" in main_app._prompt_manager._prompts
+
+
+async def test_tool_custom_name_preserved_when_imported():
+ """Test that a tool's custom name is preserved when imported."""
+ main_app = FastMCP("MainApp")
+ api_app = FastMCP("APIApp")
+
+ def fetch_data(query: str) -> str:
+ return f"Data for query: {query}"
+
+ api_app.add_tool(fetch_data, name="get_data")
+ await main_app.import_server("api", api_app)
+
+ # Check that the tool is accessible by its prefixed name
+ tool = main_app._tool_manager.get_tool("api_get_data")
+ assert tool is not None
+
+ # Check that the function name is preserved
+ assert tool.fn.__name__ == "fetch_data"
+
+
+async def test_call_imported_custom_named_tool():
+ """Test calling an imported tool with a custom name."""
+ main_app = FastMCP("MainApp")
+ api_app = FastMCP("APIApp")
+
+ def fetch_data(query: str) -> str:
+ return f"Data for query: {query}"
+
+ api_app.add_tool(fetch_data, name="get_data")
+ await main_app.import_server("api", api_app)
+
+ async with Client(main_app) as client:
+ result = await client.call_tool("api_get_data", {"query": "test"})
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "Data for query: test"
+
+
+async def test_first_level_importing_with_custom_name():
+ """Test that a tool with a custom name is correctly imported at the first level."""
+ service_app = FastMCP("ServiceApp")
+ provider_app = FastMCP("ProviderApp")
+
+ def calculate_value(input: int) -> int:
+ return input * 2
+
+ provider_app.add_tool(calculate_value, name="compute")
+ await service_app.import_server("provider", provider_app)
+
+ # Tool is accessible in the service app with the first prefix
+ tool = service_app._tool_manager.get_tool("provider_compute")
+ assert tool is not None
+ assert tool.fn.__name__ == "calculate_value"
+
+
+async def test_nested_importing_preserves_prefixes():
+ """Test that importing a previously imported app preserves prefixes."""
+ main_app = FastMCP("MainApp")
+ service_app = FastMCP("ServiceApp")
+ provider_app = FastMCP("ProviderApp")
+
+ def calculate_value(input: int) -> int:
+ return input * 2
+
+ provider_app.add_tool(calculate_value, name="compute")
+ await service_app.import_server("provider", provider_app)
+ await main_app.import_server("service", service_app)
+
+ # Tool is accessible in the main app with both prefixes
+ tool = main_app._tool_manager.get_tool("service_provider_compute")
+ assert tool is not None
+
+
+async def test_call_nested_imported_tool():
+ """Test calling a tool through multiple levels of importing."""
+ main_app = FastMCP("MainApp")
+ service_app = FastMCP("ServiceApp")
+ provider_app = FastMCP("ProviderApp")
+
+ def calculate_value(input: int) -> int:
+ return input * 2
+
+ provider_app.add_tool(calculate_value, name="compute")
+ await service_app.import_server("provider", provider_app)
+ await main_app.import_server("service", service_app)
+
+ result = await main_app._tool_manager.call_tool(
+ "service_provider_compute", {"input": 21}
+ )
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "42"
+
+
+async def test_import_with_proxy_tools():
+ """
+ Test importing with tools that have custom names (proxy tools).
+
+ This tests that the tool's name doesn't change even though the registered
+ name does, which is important because we need to forward that name to the
+ proxy server correctly.
+ """
+ # Create apps
+ main_app = FastMCP("MainApp")
+ api_app = FastMCP("APIApp")
+
+ @api_app.tool()
+ def get_data(query: str) -> str:
+ return f"Data for query: {query}"
+
+ proxy_app = FastMCP.from_client(Client(api_app))
+ await main_app.import_server("api", proxy_app)
+
+ result = await main_app._mcp_call_tool("api_get_data", {"query": "test"})
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "Data for query: test"
+
+
+async def test_import_with_proxy_prompts():
+ """
+ Test importing with prompts that have custom keys.
+
+ This tests that the prompt's name doesn't change even though the registered
+ key does, which is important for correct rendering.
+ """
+ # Create apps
+ main_app = FastMCP("MainApp")
+ api_app = FastMCP("APIApp")
+
+ @api_app.prompt()
+ def greeting(name: str) -> str:
+ return f"Hello, {name} from API!"
+
+ proxy_app = FastMCP.from_client(Client(api_app))
+ await main_app.import_server("api", proxy_app)
+
+ result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"})
+ assert result.messages is not None
+ # Check that the message contains our greeting
+
+
+async def test_import_with_proxy_resources():
+ """
+ Test importing with resources that have custom keys.
+
+ This tests that the resource's name doesn't change even though the registered
+ key does, which is important for correct access.
+ """
+ # Create apps
+ main_app = FastMCP("MainApp")
+ api_app = FastMCP("APIApp")
+
+ # Create a resource in the API app
+ @api_app.resource(uri="config://settings")
+ def get_config():
+ return {
+ "api_key": "12345",
+ "base_url": "https://api.example.com",
+ }
+
+ proxy_app = FastMCP.from_client(Client(api_app))
+ await main_app.import_server("api", proxy_app)
+
+ # Access the resource through the main app with the prefixed key
+ async with Client(main_app) as client:
+ result = await client.read_resource("api+config://settings")
+ assert isinstance(result[0], TextResourceContents)
+ config_data = json.loads(result[0].text)
+ assert config_data["api_key"] == "12345"
+ assert config_data["base_url"] == "https://api.example.com"
+
+
+async def test_import_with_proxy_resource_templates():
+ """
+ Test importing with resource templates that have custom keys.
+
+ This tests that the template's name doesn't change even though the registered
+ key does, which is important for correct instantiation.
+ """
+ # Create apps
+ main_app = FastMCP("MainApp")
+ api_app = FastMCP("APIApp")
+
+ # Create a resource template in the API app
+ @api_app.resource(uri="user://{name}/{email}")
+ def create_user(name: str, email: str):
+ return {"name": name, "email": email}
+
+ proxy_app = FastMCP.from_client(Client(api_app))
+ await main_app.import_server("api", proxy_app)
+
+ # Instantiate the template through the main app with the prefixed key
+
+ quoted_name = quote("John Doe", safe="")
+ quoted_email = quote("john@example.com", safe="")
+ async with Client(main_app) as client:
+ result = await client.read_resource(f"api+user://{quoted_name}/{quoted_email}")
+ assert isinstance(result[0], TextResourceContents)
+ user_data = json.loads(result[0].text)
+ assert user_data["name"] == "John Doe"
+ assert user_data["email"] == "john@example.com"
diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py
index 30598d4ad..aa1aef466 100644
--- a/tests/server/test_lifespan.py
+++ b/tests/server/test_lifespan.py
@@ -4,7 +4,6 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import anyio
-import pytest
from mcp.types import (
ClientCapabilities,
Implementation,
@@ -18,7 +17,6 @@ from pydantic import TypeAdapter
from fastmcp import Context, FastMCP
-@pytest.mark.anyio
async def test_fastmcp_server_lifespan():
"""Test that lifespan works in FastMCP server."""
diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py
index e66e65c1b..61d958868 100644
--- a/tests/server/test_mount.py
+++ b/tests/server/test_mount.py
@@ -1,428 +1,429 @@
-import contextlib
import json
-from urllib.parse import quote
-from mcp.types import TextContent
+import pytest
+from mcp.server.lowlevel.helper_types import ReadResourceContents
+from mcp.types import TextContent, TextResourceContents
-from fastmcp.server.server import FastMCP
+from fastmcp import FastMCP
+from fastmcp.client import Client
+from fastmcp.client.transports import FastMCPTransport
+from fastmcp.exceptions import NotFoundError
-async def test_mount_basic_functionality():
- """Test that the mount method properly imports tools and other resources."""
- # Create main app and sub-app
- main_app = FastMCP("MainApp")
- sub_app = FastMCP("SubApp")
+class TestBasicMount:
+ """Test basic mounting functionality."""
- # Add a tool to the sub-app
- @sub_app.tool()
- def sub_tool() -> str:
- return "This is from the sub app"
+ async def test_mount_simple_server(self):
+ """Test mounting a simple server and accessing its tool."""
+ # Create main app and sub-app
+ main_app = FastMCP("MainApp")
+ sub_app = FastMCP("SubApp")
- # Mount the sub-app to the main app
- main_app.mount("sub", sub_app)
+ # Add a tool to the sub-app
+ @sub_app.tool()
+ def sub_tool() -> str:
+ return "This is from the sub app"
- # Verify the tool was imported with the prefix
- assert "sub_sub_tool" in main_app._tool_manager._tools
- assert "sub_tool" in sub_app._tool_manager._tools
+ # Mount the sub-app to the main app
+ main_app.mount("sub", sub_app)
- # Verify the original tool still exists in the sub-app
- tool = main_app._tool_manager.get_tool("sub_sub_tool")
- assert tool is not None
- assert tool.name == "sub_tool"
- assert callable(tool.fn)
+ # Get tools from main app, should include sub_app's tools
+ tools = await main_app.get_tools()
+ assert "sub_sub_tool" in tools
+
+ async with Client(main_app) as client:
+ result = await client.call_tool("sub_sub_tool", {})
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "This is from the sub app"
+
+ async def test_mount_with_custom_separator(self):
+ """Test mounting with a custom tool separator."""
+ main_app = FastMCP("MainApp")
+ sub_app = FastMCP("SubApp")
+
+ @sub_app.tool()
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+ # Mount with custom separator
+ main_app.mount("sub", sub_app, tool_separator="-")
+
+ # Tool should be accessible with custom separator
+ tools = await main_app.get_tools()
+ assert "sub-greet" in tools
+
+ # Call the tool
+ result = await main_app._mcp_call_tool("sub-greet", {"name": "World"})
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "Hello, World!"
+
+ async def test_unmount_server(self):
+ """Test unmounting a server removes access to its tools."""
+ main_app = FastMCP("MainApp")
+ sub_app = FastMCP("SubApp")
+
+ @sub_app.tool()
+ def sub_tool() -> str:
+ return "This is from the sub app"
+
+ # Mount the sub-app
+ main_app.mount("sub", sub_app)
+
+ # Verify it was mounted
+ tools = await main_app.get_tools()
+ assert "sub_sub_tool" in tools
+
+ # Unmount the sub-app
+ main_app.unmount("sub")
+
+ # Verify it was unmounted
+ tools = await main_app.get_tools()
+ assert "sub_sub_tool" not in tools
+
+ # Calling the tool should fail
+ with pytest.raises(NotFoundError, match="Unknown tool: sub_sub_tool"):
+ await main_app._mcp_call_tool("sub_sub_tool", {})
-async def test_mount_multiple_apps():
- """Test mounting multiple apps to a main app."""
- # Create main app and multiple sub-apps
- main_app = FastMCP("MainApp")
- weather_app = FastMCP("WeatherApp")
- news_app = FastMCP("NewsApp")
+class TestMultipleServerMount:
+ """Test mounting multiple servers simultaneously."""
- # Add tools to each sub-app
- @weather_app.tool()
- def get_forecast() -> str:
- return "Weather forecast"
+ async def test_mount_multiple_servers(self):
+ """Test mounting multiple servers with different prefixes."""
+ main_app = FastMCP("MainApp")
+ weather_app = FastMCP("WeatherApp")
+ news_app = FastMCP("NewsApp")
- @news_app.tool()
- def get_headlines() -> str:
- return "News headlines"
+ @weather_app.tool()
+ def get_forecast() -> str:
+ return "Weather forecast"
- # Mount both sub-apps to the main app
- main_app.mount("weather", weather_app)
- main_app.mount("news", news_app)
+ @news_app.tool()
+ def get_headlines() -> str:
+ return "News headlines"
- # Verify tools were imported with the correct prefixes
- assert "weather_get_forecast" in main_app._tool_manager._tools
- assert "news_get_headlines" in main_app._tool_manager._tools
+ # Mount both apps
+ main_app.mount("weather", weather_app)
+ main_app.mount("news", news_app)
+
+ # Check both are accessible
+ tools = await main_app.get_tools()
+ assert "weather_get_forecast" in tools
+ assert "news_get_headlines" in tools
+
+ # Call tools from both mounted servers
+ result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
+ assert isinstance(result1[0], TextContent)
+ assert result1[0].text == "Weather forecast"
+
+ result2 = await main_app._mcp_call_tool("news_get_headlines", {})
+ assert isinstance(result2[0], TextContent)
+ assert result2[0].text == "News headlines"
+
+ async def test_mount_same_prefix(self):
+ """Test that mounting with the same prefix replaces the previous mount."""
+ main_app = FastMCP("MainApp")
+ first_app = FastMCP("FirstApp")
+ second_app = FastMCP("SecondApp")
+
+ @first_app.tool()
+ def first_tool() -> str:
+ return "First app tool"
+
+ @second_app.tool()
+ def second_tool() -> str:
+ return "Second app tool"
+
+ # Mount first app
+ main_app.mount("api", first_app)
+ tools = await main_app.get_tools()
+ assert "api_first_tool" in tools
+
+ # Mount second app with same prefix
+ main_app.mount("api", second_app)
+ tools = await main_app.get_tools()
+
+ # First app's tool should no longer be accessible
+ assert "api_first_tool" not in tools
+
+ # Second app's tool should be accessible
+ assert "api_second_tool" in tools
-async def test_mount_combines_tools():
- """Test that mounting preserves existing tools with the same prefix."""
- # Create apps
- main_app = FastMCP("MainApp")
- first_app = FastMCP("FirstApp")
- second_app = FastMCP("SecondApp")
+class TestDynamicChanges:
+ """Test that changes to mounted servers are reflected dynamically."""
- # Add tools to each sub-app
- @first_app.tool()
- def first_tool() -> str:
- return "First app tool"
+ async def test_adding_tool_after_mounting(self):
+ """Test that tools added after mounting are accessible."""
+ main_app = FastMCP("MainApp")
+ sub_app = FastMCP("SubApp")
- @second_app.tool()
- def second_tool() -> str:
- return "Second app tool"
+ # Mount the sub-app before adding any tools
+ main_app.mount("sub", sub_app)
- # Mount first app
- main_app.mount("api", first_app)
- assert "api_first_tool" in main_app._tool_manager._tools
+ # Initially, there should be no tools from sub_app
+ tools = await main_app.get_tools()
+ assert not any(key.startswith("sub_") for key in tools)
- # Mount second app to same prefix
- main_app.mount("api", second_app)
+ # Add a tool to the sub-app after mounting
+ @sub_app.tool()
+ def dynamic_tool() -> str:
+ return "Added after mounting"
- # Verify second tool is there
- assert "api_second_tool" in main_app._tool_manager._tools
+ # The tool should be accessible through the main app
+ tools = await main_app.get_tools()
+ assert "sub_dynamic_tool" in tools
- # Tools from both mounts are combined
- assert "api_first_tool" in main_app._tool_manager._tools
+ # Call the dynamically added tool
+ result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "Added after mounting"
+
+ async def test_removing_tool_after_mounting(self):
+ """Test that tools removed from mounted servers are no longer accessible."""
+ main_app = FastMCP("MainApp")
+ sub_app = FastMCP("SubApp")
+
+ @sub_app.tool()
+ def temp_tool() -> str:
+ return "Temporary tool"
+
+ # Mount the sub-app
+ main_app.mount("sub", sub_app)
+
+ # Initially, the tool should be accessible
+ tools = await main_app.get_tools()
+ assert "sub_temp_tool" in tools
+
+ # Remove the tool from sub_app
+ sub_app._tool_manager._tools.pop("temp_tool")
+
+ # The tool should no longer be accessible
+ # Refresh the cache by clearing it
+ main_app._cache.cache.clear()
+ tools = await main_app.get_tools()
+ assert "sub_temp_tool" not in tools
-async def test_mount_with_resources():
- """Test mounting with resources."""
- # Create apps
- main_app = FastMCP("MainApp")
- data_app = FastMCP("DataApp")
+class TestResourcesAndTemplates:
+ """Test mounting with resources and resource templates."""
- # Add a resource to the data app
- @data_app.resource(uri="data://users")
- async def get_users():
- return ["user1", "user2"]
+ async def test_mount_with_resources(self):
+ """Test mounting a server with resources."""
+ main_app = FastMCP("MainApp")
+ data_app = FastMCP("DataApp")
- # Mount the data app
- main_app.mount("data", data_app)
+ @data_app.resource(uri="data://users")
+ async def get_users():
+ return ["user1", "user2"]
- # Verify the resource was imported with the prefix
- assert "data+data://users" in main_app._resource_manager._resources
+ # Mount the data app
+ main_app.mount("data", data_app)
+
+ # Resource should be accessible through main app
+ resources = await main_app.get_resources()
+ assert any("data+data://users" in str(uri) for uri in resources)
+
+ async with Client(main_app) as client:
+ resource = await client.read_resource("data+data://users")
+ assert isinstance(resource[0], TextResourceContents)
+ assert resource[0].text == '["user1", "user2"]'
+
+ async def test_mount_with_resource_templates(self):
+ """Test mounting a server with resource templates."""
+ main_app = FastMCP("MainApp")
+ user_app = FastMCP("UserApp")
+
+ @user_app.resource(uri="users://{user_id}/profile")
+ def get_user_profile(user_id: str) -> dict:
+ return {"id": user_id, "name": f"User {user_id}"}
+
+ # Mount the user app
+ main_app.mount("api", user_app)
+
+ # Template should be accessible through main app
+ templates = await main_app.get_resource_templates()
+ assert any("api+users://{user_id}/profile" in str(t) for t in templates)
+
+ # Read from the template
+ result = await main_app._mcp_read_resource("api+users://123/profile")
+ assert isinstance(result[0], ReadResourceContents)
+ profile = json.loads(result[0].content)
+ assert profile["id"] == "123"
+ assert profile["name"] == "User 123"
+
+ async def test_adding_resource_after_mounting(self):
+ """Test adding a resource after mounting."""
+ main_app = FastMCP("MainApp")
+ data_app = FastMCP("DataApp")
+
+ # Mount the data app before adding resources
+ main_app.mount("data", data_app)
+
+ # Add a resource after mounting
+ @data_app.resource(uri="data://config")
+ def get_config():
+ return {"version": "1.0"}
+
+ # Resource should be accessible through main app
+ resources = await main_app.get_resources()
+ assert any("data+data://config" in str(uri) for uri in resources)
+
+ # Read the resource
+ result = await main_app._mcp_read_resource("data+data://config")
+ assert isinstance(result[0], ReadResourceContents)
+ config = json.loads(result[0].content)
+ assert config["version"] == "1.0"
-async def test_mount_with_resource_templates():
- """Test mounting with resource templates."""
- # Create apps
- main_app = FastMCP("MainApp")
- user_app = FastMCP("UserApp")
-
- # Add a resource template to the user app
- @user_app.resource(uri="users://{user_id}/profile")
- def get_user_profile(user_id: str) -> dict:
- return {"id": user_id, "name": f"User {user_id}"}
-
- # Mount the user app
- main_app.mount("api", user_app)
-
- # Verify the template was imported with the prefix
- assert "api+users://{user_id}/profile" in main_app._resource_manager._templates
-
-
-async def test_mount_with_prompts():
+class TestPrompts:
"""Test mounting with prompts."""
- # Create apps
- main_app = FastMCP("MainApp")
- assistant_app = FastMCP("AssistantApp")
-
- # Add a prompt to the assistant app
- @assistant_app.prompt()
- def greeting(name: str) -> str:
- return f"Hello, {name}!"
-
- # Mount the assistant app
- main_app.mount("assistant", assistant_app)
-
- # Verify the prompt was imported with the prefix
- assert "assistant_greeting" in main_app._prompt_manager._prompts
-
-
-async def test_mount_multiple_resource_templates():
- """Test mounting multiple apps with resource templates."""
- # Create apps
- main_app = FastMCP("MainApp")
- weather_app = FastMCP("WeatherApp")
- news_app = FastMCP("NewsApp")
-
- # Add templates to each app
- @weather_app.resource(uri="weather://{city}")
- def get_weather(city: str) -> str:
- return f"Weather for {city}"
-
- @news_app.resource(uri="news://{category}")
- def get_news(category: str) -> str:
- return f"News for {category}"
-
- # Mount both apps
- main_app.mount("data", weather_app)
- main_app.mount("content", news_app)
-
- # Verify templates were imported with correct prefixes
- assert "data+weather://{city}" in main_app._resource_manager._templates
- assert "content+news://{category}" in main_app._resource_manager._templates
-
-
-async def test_mount_multiple_prompts():
- """Test mounting multiple apps with prompts."""
- # Create apps
- main_app = FastMCP("MainApp")
- python_app = FastMCP("PythonApp")
- sql_app = FastMCP("SQLApp")
-
- # Add prompts to each app
- @python_app.prompt()
- def review_python(code: str) -> str:
- return f"Reviewing Python code:\n{code}"
-
- @sql_app.prompt()
- def explain_sql(query: str) -> str:
- return f"Explaining SQL query:\n{query}"
-
- # Mount both apps
- main_app.mount("python", python_app)
- main_app.mount("sql", sql_app)
-
- # Verify prompts were imported with correct prefixes
- assert "python_review_python" in main_app._prompt_manager._prompts
- assert "sql_explain_sql" in main_app._prompt_manager._prompts
-
-
-async def test_mount_lifespan():
- """Test that the lifespan of a mounted app is properly handled."""
- # Create apps
-
- lifespan_checkpoints = []
-
- @contextlib.asynccontextmanager
- async def lifespan(app: FastMCP):
- lifespan_checkpoints.append(f"enter {app.name}")
- try:
- yield
- finally:
- lifespan_checkpoints.append(f"exit {app.name}")
-
- main_app = FastMCP("MainApp", lifespan=lifespan)
- sub_app = FastMCP("SubApp", lifespan=lifespan)
- sub_app_2 = FastMCP("SubApp2", lifespan=lifespan)
-
- main_app.mount("sub", sub_app)
- main_app.mount("sub2", sub_app_2)
- low_level_server = main_app._mcp_server
- async with contextlib.AsyncExitStack() as stack:
- # Note: this imitates the way that lifespans are entered for mounted
- # apps It is presently difficult to stop a running server
- # programmatically without error in order to test the exit conditions,
- # so this is the next best thing
- await stack.enter_async_context(low_level_server.lifespan(low_level_server))
- assert lifespan_checkpoints == [
- "enter MainApp",
- "enter SubApp",
- "enter SubApp2",
- ]
- assert lifespan_checkpoints == [
- "enter MainApp",
- "enter SubApp",
- "enter SubApp2",
- "exit SubApp2",
- "exit SubApp",
- "exit MainApp",
- ]
-
-
-async def test_tool_custom_name_preserved_when_mounted():
- """Test that a tool's custom name is preserved when mounted."""
- main_app = FastMCP("MainApp")
- api_app = FastMCP("APIApp")
-
- def fetch_data(query: str) -> str:
- return f"Data for query: {query}"
-
- api_app.add_tool(fetch_data, name="get_data")
- main_app.mount("api", api_app)
-
- # Check that the tool is accessible by its prefixed name
- tool = main_app._tool_manager.get_tool("api_get_data")
- assert tool is not None
-
- # Check that the function name is preserved
- assert tool.fn.__name__ == "fetch_data"
-
-
-async def test_call_mounted_custom_named_tool():
- """Test calling a mounted tool with a custom name."""
- main_app = FastMCP("MainApp")
- api_app = FastMCP("APIApp")
-
- def fetch_data(query: str) -> str:
- return f"Data for query: {query}"
-
- api_app.add_tool(fetch_data, name="get_data")
- main_app.mount("api", api_app)
-
- context = main_app.get_context()
- result = await main_app._tool_manager.call_tool(
- "api_get_data", {"query": "test"}, context=context
- )
- assert result == "Data for query: test"
-
-
-async def test_first_level_mounting_with_custom_name():
- """Test that a tool with a custom name is correctly mounted at the first level."""
- service_app = FastMCP("ServiceApp")
- provider_app = FastMCP("ProviderApp")
-
- def calculate_value(input: int) -> int:
- return input * 2
-
- provider_app.add_tool(calculate_value, name="compute")
- service_app.mount("provider", provider_app)
-
- # Tool is accessible in the service app with the first prefix
- tool = service_app._tool_manager.get_tool("provider_compute")
- assert tool is not None
- assert tool.fn.__name__ == "calculate_value"
-
-
-async def test_nested_mounting_preserves_prefixes():
- """Test that mounting a previously mounted app preserves prefixes."""
- main_app = FastMCP("MainApp")
- service_app = FastMCP("ServiceApp")
- provider_app = FastMCP("ProviderApp")
-
- def calculate_value(input: int) -> int:
- return input * 2
-
- provider_app.add_tool(calculate_value, name="compute")
- service_app.mount("provider", provider_app)
- main_app.mount("service", service_app)
-
- # Tool is accessible in the main app with both prefixes
- tool = main_app._tool_manager.get_tool("service_provider_compute")
- assert tool is not None
-
-
-async def test_call_nested_mounted_tool():
- """Test calling a tool through multiple levels of mounting."""
- main_app = FastMCP("MainApp")
- service_app = FastMCP("ServiceApp")
- provider_app = FastMCP("ProviderApp")
-
- def calculate_value(input: int) -> int:
- return input * 2
-
- provider_app.add_tool(calculate_value, name="compute")
- service_app.mount("provider", provider_app)
- main_app.mount("service", service_app)
-
- result = await main_app._tool_manager.call_tool(
- "service_provider_compute", {"input": 21}
- )
- assert result == 42
-
-
-async def test_mount_with_proxy_tools():
- """
- Test mounting with tools that have custom names (proxy tools).
-
- This tests that the tool's name doesn't change even though the registered
- name does, which is important because we need to forward that name to the
- proxy server correctly.
- """
- # Create apps
- main_app = FastMCP("MainApp")
- api_app = FastMCP("APIApp")
-
- @api_app.tool()
- def get_data(query: str) -> str:
- return f"Data for query: {query}"
-
- main_app.mount("api", await FastMCP.as_proxy(api_app))
-
- result = await main_app.call_tool("api_get_data", {"query": "test"})
- assert isinstance(result[0], TextContent)
- assert result[0].text == "Data for query: test"
-
-
-async def test_mount_with_proxy_prompts():
- """
- Test mounting with prompts that have custom keys.
-
- This tests that the prompt's name doesn't change even though the registered
- key does, which is important for correct rendering.
- """
- # Create apps
- main_app = FastMCP("MainApp")
- api_app = FastMCP("APIApp")
-
- @api_app.prompt()
- def greeting(name: str) -> str:
- return f"Hello, {name} from API!"
-
- main_app.mount("api", await FastMCP.as_proxy(api_app))
-
- result = await main_app.get_prompt("api_greeting", {"name": "World"})
- assert len(result) > 0
- assert isinstance(result[0].content, TextContent)
- assert result[0].content.text == "Hello, World from API!"
-
-
-async def test_mount_with_proxy_resources():
- """
- Test mounting with resources that have custom keys.
-
- This tests that the resource's name doesn't change even though the registered
- key does, which is important for correct access.
- """
- # Create apps
- main_app = FastMCP("MainApp")
- api_app = FastMCP("APIApp")
-
- # Create a resource in the API app
- @api_app.resource(uri="config://settings")
- def get_config():
- return {
- "api_key": "12345",
- "base_url": "https://api.example.com",
- }
-
- main_app.mount("api", await FastMCP.as_proxy(api_app))
-
- # Access the resource through the main app with the prefixed key
- resource = await main_app.read_resource("api+config://settings")
- assert resource is not None
- resource = json.loads(resource)
- assert resource["api_key"] == "12345"
- assert resource["base_url"] == "https://api.example.com"
-
-
-async def test_mount_with_proxy_resource_templates():
- """
- Test mounting with resource templates that have custom keys.
-
- This tests that the template's name doesn't change even though the registered
- key does, which is important for correct instantiation.
- """
- # Create apps
- main_app = FastMCP("MainApp")
- api_app = FastMCP("APIApp")
-
- # Create a resource template in the API app
- @api_app.resource(uri="user://{name}/{email}")
- def create_user(name: str, email: str):
- return {"name": name, "email": email}
-
- main_app.mount("api", await FastMCP.as_proxy(api_app))
-
- # Instantiate the template through the main app with the prefixed key
- quoted_name = quote("John Doe", safe="")
- quoted_email = quote("john@example.com", safe="")
- user = await main_app.read_resource(f"api+user://{quoted_name}/{quoted_email}")
- assert user is not None
- user = json.loads(user)
- assert user["name"] == "John Doe"
- assert user["email"] == "john@example.com"
+ async def test_mount_with_prompts(self):
+ """Test mounting a server with prompts."""
+ main_app = FastMCP("MainApp")
+ assistant_app = FastMCP("AssistantApp")
+
+ @assistant_app.prompt()
+ def greeting(name: str) -> str:
+ return f"Hello, {name}!"
+
+ # Mount the assistant app
+ main_app.mount("assistant", assistant_app)
+
+ # Prompt should be accessible through main app
+ prompts = await main_app.get_prompts()
+ assert "assistant_greeting" in prompts
+
+ # Render the prompt
+ result = await main_app._mcp_get_prompt("assistant_greeting", {"name": "World"})
+ assert result.messages is not None
+ # The message should contain our greeting text
+
+ async def test_adding_prompt_after_mounting(self):
+ """Test adding a prompt after mounting."""
+ main_app = FastMCP("MainApp")
+ assistant_app = FastMCP("AssistantApp")
+
+ # Mount the assistant app before adding prompts
+ main_app.mount("assistant", assistant_app)
+
+ # Add a prompt after mounting
+ @assistant_app.prompt()
+ def farewell(name: str) -> str:
+ return f"Goodbye, {name}!"
+
+ # Prompt should be accessible through main app
+ prompts = await main_app.get_prompts()
+ assert "assistant_farewell" in prompts
+
+ # Render the prompt
+ result = await main_app._mcp_get_prompt("assistant_farewell", {"name": "World"})
+ assert result.messages is not None
+ # The message should contain our farewell text
+
+
+class TestProxyServer:
+ """Test mounting a proxy server."""
+
+ async def test_mount_proxy_server(self):
+ """Test mounting a proxy server."""
+ # Create original server
+ original_server = FastMCP("OriginalServer")
+
+ @original_server.tool()
+ def get_data(query: str) -> str:
+ return f"Data for {query}"
+
+ # Create proxy server
+ proxy_server = FastMCP.from_client(
+ Client(transport=FastMCPTransport(original_server))
+ )
+
+ # Mount proxy server
+ main_app = FastMCP("MainApp")
+ main_app.mount("proxy", proxy_server)
+
+ # Tool should be accessible through main app
+ tools = await main_app.get_tools()
+ assert "proxy_get_data" in tools
+
+ # Call the tool
+ result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "Data for test"
+
+ async def test_dynamically_adding_to_proxied_server(self):
+ """Test that changes to the original server are reflected in the mounted proxy."""
+ # Create original server
+ original_server = FastMCP("OriginalServer")
+
+ # Create proxy server
+ proxy_server = FastMCP.from_client(
+ Client(transport=FastMCPTransport(original_server))
+ )
+
+ # Mount proxy server
+ main_app = FastMCP("MainApp")
+ main_app.mount("proxy", proxy_server)
+
+ # Add a tool to the original server
+ @original_server.tool()
+ def dynamic_data() -> str:
+ return "Dynamic data"
+
+ # Tool should be accessible through main app via proxy
+ tools = await main_app.get_tools()
+ assert "proxy_dynamic_data" in tools
+
+ # Call the tool
+ result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "Dynamic data"
+
+ async def test_proxy_server_with_resources(self):
+ """Test mounting a proxy server with resources."""
+ # Create original server
+ original_server = FastMCP("OriginalServer")
+
+ @original_server.resource(uri="config://settings")
+ def get_config():
+ return {"api_key": "12345"}
+
+ # Create proxy server
+ proxy_server = FastMCP.from_client(
+ Client(transport=FastMCPTransport(original_server))
+ )
+
+ # Mount proxy server
+ main_app = FastMCP("MainApp")
+ main_app.mount("proxy", proxy_server)
+
+ # Resource should be accessible through main app
+ result = await main_app._mcp_read_resource("proxy+config://settings")
+ assert isinstance(result[0], ReadResourceContents)
+ config = json.loads(result[0].content)
+ assert config["api_key"] == "12345"
+
+ async def test_proxy_server_with_prompts(self):
+ """Test mounting a proxy server with prompts."""
+ # Create original server
+ original_server = FastMCP("OriginalServer")
+
+ @original_server.prompt()
+ def welcome(name: str) -> str:
+ return f"Welcome, {name}!"
+
+ # Create proxy server
+ proxy_server = FastMCP.from_client(
+ Client(transport=FastMCPTransport(original_server))
+ )
+
+ # Mount proxy server
+ main_app = FastMCP("MainApp")
+ main_app.mount("proxy", proxy_server)
+
+ # Prompt should be accessible through main app
+ result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"})
+ assert result.messages is not None
+ # The message should contain our welcome text
diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py
index d1c7c91b4..afad3b669 100644
--- a/tests/server/test_openapi.py
+++ b/tests/server/test_openapi.py
@@ -1,3 +1,4 @@
+import json
import re
import httpx
@@ -5,10 +6,12 @@ import pytest
from dirty_equals import IsStr
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
+from mcp.types import TextContent
from pydantic import BaseModel, TypeAdapter
from pydantic.networks import AnyUrl
from fastmcp import FastMCP
+from fastmcp.client import Client
from fastmcp.server.openapi import FastMCPOpenAPI
@@ -153,13 +156,19 @@ class TestTools:
"""
The tool created by the OpenAPI server should be the same as the original
"""
- tool_response = await fastmcp_openapi_server.call_tool(
+ tool_response = await fastmcp_openapi_server._mcp_call_tool(
"create_user_users_post", {"name": "David", "active": False}
)
- assert tool_response == User(id=4, name="David", active=False)
+
+ # Convert TextContent to dict for comparison
+ assert isinstance(tool_response, list) and len(tool_response) == 1
+ assert isinstance(tool_response[0], TextContent)
+
+ response_data = json.loads(tool_response[0].text)
+ expected_user = User(id=4, name="David", active=False).model_dump()
+ assert response_data == expected_user
# Check that the user was created via API
-
response = await api_client.get("/users")
assert len(response.json()) == 4
@@ -168,7 +177,7 @@ class TestTools:
"resource://openapi/get_user_users__user_id__get/4"
)
user = user_response[0].content
- assert user == tool_response.model_dump()
+ assert user == expected_user
async def test_call_update_user_name_tool(
self, fastmcp_openapi_server: FastMCPOpenAPI, api_client
@@ -176,21 +185,28 @@ class TestTools:
"""
The tool created by the OpenAPI server should be the same as the original
"""
- tool_response = await fastmcp_openapi_server.call_tool(
+ tool_response = await fastmcp_openapi_server._mcp_call_tool(
"update_user_name_users__user_id__name_patch", {"user_id": 1, "name": "XYZ"}
)
- assert tool_response == dict(id=1, name="XYZ", active=True)
+
+ # Convert TextContent to dict for comparison
+ assert isinstance(tool_response, list) and len(tool_response) == 1
+ assert isinstance(tool_response[0], TextContent)
+
+ response_data = json.loads(tool_response[0].text)
+ expected_data = dict(id=1, name="XYZ", active=True)
+ assert response_data == expected_data
# Check that the user was updated via API
response = await api_client.get("/users")
- assert dict(id=1, name="XYZ", active=True) in response.json()
+ assert expected_data in response.json()
# Check that the user was updated via MCP
user_response = await fastmcp_openapi_server._mcp_read_resource(
"resource://openapi/get_user_users__user_id__get/1"
)
user = user_response[0].content
- assert user == tool_response
+ assert user == expected_data
class TestResources:
@@ -308,7 +324,9 @@ class TestTagTransfer:
):
"""Test that tags from OpenAPI routes are correctly transferred to Resources."""
# Get internal resources directly
- resources = fastmcp_openapi_server._resource_manager.list_resources()
+ resources = list(
+ fastmcp_openapi_server._resource_manager.get_resources().values()
+ )
# Find the get_users resource
get_users_resource = next(
@@ -327,7 +345,9 @@ class TestTagTransfer:
):
"""Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
# Get internal resource templates directly
- templates = fastmcp_openapi_server._resource_manager.list_templates()
+ templates = list(
+ fastmcp_openapi_server._resource_manager.get_templates().values()
+ )
# Find the get_user template
get_user_template = next(
@@ -346,7 +366,9 @@ class TestTagTransfer:
):
"""Test that tags are preserved when creating resources from templates."""
# Get internal resource templates directly
- templates = fastmcp_openapi_server._resource_manager.list_templates()
+ templates = list(
+ fastmcp_openapi_server._resource_manager.get_templates().values()
+ )
# Find the get_user template
get_user_template = next(
@@ -513,12 +535,17 @@ class TestOpenAPI30Compatibility:
async def test_tool_execution(self, openapi_30_server):
"""Test executing a tool from an OpenAPI 3.0 server."""
- tool_response = await openapi_30_server.call_tool(
- "createProduct", {"name": "New Product", "price": 39.99}
- )
- assert tool_response["id"] == "p3"
- assert tool_response["name"] == "New Product"
- assert tool_response["price"] == 39.99
+ async with Client(openapi_30_server) as client:
+ result = await client.call_tool(
+ "createProduct", {"name": "New Product", "price": 39.99}
+ )
+ # Result should be a text content
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ product = json.loads(result[0].text)
+ assert product["id"] == "p3"
+ assert product["name"] == "New Product"
+ assert product["price"] == 39.99
class TestOpenAPI31Compatibility:
@@ -679,12 +706,17 @@ class TestOpenAPI31Compatibility:
async def test_tool_execution(self, openapi_31_server):
"""Test executing a tool from an OpenAPI 3.1 server."""
- tool_response = await openapi_31_server.call_tool(
- "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
- )
- assert tool_response["id"] == "o3"
- assert tool_response["customer"] == "Charlie"
- assert tool_response["items"] == ["item4", "item5"]
+ async with Client(openapi_31_server) as client:
+ result = await client.call_tool(
+ "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
+ )
+ # Result should be a text content
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ order = json.loads(result[0].text)
+ assert order["id"] == "o3"
+ assert order["customer"] == "Charlie"
+ assert order["items"] == ["item4", "item5"]
class TestMountFastMCP:
@@ -694,7 +726,7 @@ class TestMountFastMCP:
"""Test mounting an OpenAPI server."""
mcp = FastMCP("MainApp")
- mcp.mount("fastapi", fastmcp_openapi_server)
+ await mcp.import_server("fastapi", fastmcp_openapi_server)
# Check that resources are available with prefixed URIs
resources = await mcp._mcp_list_resources()
diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py
index 1c1e23cd4..882977527 100644
--- a/tests/server/test_proxy.py
+++ b/tests/server/test_proxy.py
@@ -1,13 +1,14 @@
import json
from typing import Any
+import mcp.types
import pytest
from dirty_equals import Contains
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport
-from fastmcp.exceptions import ResourceError
+from fastmcp.exceptions import ClientError
from fastmcp.server.proxy import FastMCPProxy
USERS = [
@@ -64,7 +65,7 @@ def fastmcp_server():
@pytest.fixture
async def proxy_server(fastmcp_server):
"""Fixture that creates a FastMCP proxy server."""
- return await FastMCP.as_proxy(Client(transport=FastMCPTransport(fastmcp_server)))
+ return FastMCP.from_client(Client(transport=FastMCPTransport(fastmcp_server)))
async def test_create_proxy(fastmcp_server):
@@ -72,7 +73,7 @@ async def test_create_proxy(fastmcp_server):
# Create a client
client = Client(transport=FastMCPTransport(fastmcp_server))
- server = await FastMCPProxy.from_client(client)
+ server = FastMCPProxy.from_client(client)
assert isinstance(server, FastMCPProxy)
assert isinstance(server, FastMCP)
@@ -80,9 +81,11 @@ async def test_create_proxy(fastmcp_server):
class TestTools:
- async def test_list_tools(self, proxy_server):
- tools = proxy_server.list_tools()
- assert [t.name for t in tools] == Contains("greet", "add", "error_tool")
+ async def test_get_tools(self, proxy_server):
+ tools = await proxy_server.get_tools()
+ assert "greet" in tools
+ assert "add" in tools
+ assert "error_tool" in tools
async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
assert (
@@ -93,25 +96,28 @@ class TestTools:
async def test_call_tool_result_same_as_original(
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
):
- result = await fastmcp_server.call_tool("greet", {"name": "Alice"})
- proxy_result = await proxy_server.call_tool("greet", {"name": "Alice"})
+ result = await fastmcp_server._mcp_call_tool("greet", {"name": "Alice"})
+ proxy_result = await proxy_server._mcp_call_tool("greet", {"name": "Alice"})
assert result == proxy_result
async def test_call_tool_calls_tool(self, proxy_server):
- proxy_result = await proxy_server.call_tool("add", {"a": 1, "b": 2})
+ async with Client(proxy_server) as client:
+ proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
+ assert isinstance(proxy_result[0], mcp.types.TextContent)
assert proxy_result[0].text == "3"
async def test_error_tool_raises_error(self, proxy_server):
- with pytest.raises(ValueError, match="This is a test error"):
- await proxy_server.call_tool("error_tool", {})
+ with pytest.raises(ClientError, match=""):
+ async with Client(proxy_server) as client:
+ await client.call_tool("error_tool", {})
class TestResources:
- async def test_list_resources(self, proxy_server):
- resources = proxy_server.list_resources()
- assert [r.name for r in resources] == Contains(
+ async def test_get_resources(self, proxy_server):
+ resources = await proxy_server.get_resources()
+ assert [r.name for r in resources.values()] == Contains(
"data://users", "resource://wave"
)
@@ -122,29 +128,36 @@ class TestResources:
)
async def test_read_resource(self, proxy_server: FastMCPProxy):
- result = await proxy_server.read_resource("resource://wave")
- assert result == "👋"
+ async with Client(proxy_server) as client:
+ result = await client.read_resource("resource://wave")
+ assert isinstance(result[0], mcp.types.TextResourceContents)
+ assert result[0].text == "👋"
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
- result = await fastmcp_server.read_resource("resource://wave")
- proxy_result = await proxy_server.read_resource("resource://wave")
+ async with Client(fastmcp_server) as client:
+ result = await client.read_resource("resource://wave")
+ async with Client(proxy_server) as client:
+ proxy_result = await client.read_resource("resource://wave")
assert proxy_result == result
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
- result = await proxy_server.read_resource("data://users")
- assert json.loads(result) == USERS
+ async with Client(proxy_server) as client:
+ result = await client.read_resource("data://users")
+ assert isinstance(result[0], mcp.types.TextResourceContents)
+ assert json.loads(result[0].text) == USERS
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
with pytest.raises(
- ResourceError, match="Unknown resource: resource://nonexistent"
+ ClientError, match="Unknown resource: resource://nonexistent"
):
- await proxy_server.read_resource("resource://nonexistent")
+ async with Client(proxy_server) as client:
+ await client.read_resource("resource://nonexistent")
class TestResourceTemplates:
- async def test_list_resource_templates(self, proxy_server):
- templates = proxy_server.list_resource_templates()
- assert [t.name for t in templates] == Contains("get_user")
+ async def test_get_resource_templates(self, proxy_server):
+ templates = await proxy_server.get_resource_templates()
+ assert [t.name for t in templates.values()] == Contains("get_user")
async def test_list_resource_templates_same_as_original(
self, fastmcp_server, proxy_server
@@ -155,35 +168,46 @@ class TestResourceTemplates:
@pytest.mark.parametrize("id", [1, 2, 3])
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
- result = await proxy_server.read_resource(f"data://user/{id}")
- assert json.loads(result) == USERS[id - 1]
+ async with Client(proxy_server) as client:
+ result = await client.read_resource(f"data://user/{id}")
+ assert isinstance(result[0], mcp.types.TextResourceContents)
+ assert json.loads(result[0].text) == USERS[id - 1]
async def test_read_resource_template_same_as_original(
self, fastmcp_server, proxy_server
):
- result = await fastmcp_server.read_resource("data://user/1")
- proxy_result = await proxy_server.read_resource("data://user/1")
+ async with Client(fastmcp_server) as client:
+ result = await client.read_resource("data://user/1")
+ async with Client(proxy_server) as client:
+ proxy_result = await client.read_resource("data://user/1")
assert proxy_result == result
class TestPrompts:
- async def test_list_prompts(self, proxy_server):
- prompts = proxy_server.list_prompts()
- assert [p.name for p in prompts] == Contains("welcome")
+ async def test_get_prompts_server_method(self, proxy_server: FastMCPProxy):
+ prompts = await proxy_server.get_prompts()
+ assert [p.name for p in prompts.values()] == Contains("welcome")
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
- assert (
- await proxy_server._mcp_list_prompts()
- == await fastmcp_server._mcp_list_prompts()
- )
+ async with Client(fastmcp_server) as client:
+ result = await client.list_prompts()
+ async with Client(proxy_server) as client:
+ proxy_result = await client.list_prompts()
+ assert proxy_result == result
async def test_render_prompt_same_as_original(
- self, fastmcp_server: FastMCP, proxy_server
+ self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
):
- result = await fastmcp_server.get_prompt("welcome", {"name": "Alice"})
- proxy_result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
+ async with Client(fastmcp_server) as client:
+ result = await client.get_prompt("welcome", {"name": "Alice"})
+ async with Client(proxy_server) as client:
+ proxy_result = await client.get_prompt("welcome", {"name": "Alice"})
assert proxy_result == result
async def test_render_prompt_calls_prompt(self, proxy_server):
- result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
+ async with Client(proxy_server) as client:
+ result = await client.get_prompt("welcome", {"name": "Alice"})
+ assert isinstance(result[0], mcp.types.PromptMessage)
+ assert result[0].role == "user"
+ assert isinstance(result[0].content, mcp.types.TextContent)
assert result[0].content.text == "Welcome to FastMCP, Alice!"
diff --git a/tests/server/test_run_server.py b/tests/server/test_run_server.py
index fb936b578..5109a27bc 100644
--- a/tests/server/test_run_server.py
+++ b/tests/server/test_run_server.py
@@ -93,6 +93,6 @@
# class TestRunServerSSE:
-# @pytest.mark.anyio
+#
# async def test_run_server_sse(self, fastmcp_server: FastMCP):
# pass
diff --git a/tests/server/test_server.py b/tests/server/test_server.py
index 219c249f1..42a47ae65 100644
--- a/tests/server/test_server.py
+++ b/tests/server/test_server.py
@@ -4,7 +4,6 @@ from pathlib import Path
from typing import TYPE_CHECKING
import pytest
-from mcp.shared.exceptions import McpError
from mcp.types import (
BlobResourceContents,
ImageContent,
@@ -14,7 +13,7 @@ from mcp.types import (
from pydantic import AnyUrl, Field
from fastmcp import Client, Context, FastMCP
-from fastmcp.exceptions import ResourceError, ToolError
+from fastmcp.exceptions import ClientError, NotFoundError, ToolError
from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
from fastmcp.resources import FileResource, FunctionResource
from fastmcp.utilities.types import Image
@@ -57,12 +56,40 @@ class TestCreateServer:
assert "¡Hola, 世界! 👋" == content.text
+class TestTools:
+ async def test_mcp_tool_name(self):
+ """Test MCPTool name for add_tool (key != tool.name)."""
+
+ mcp = FastMCP()
+
+ @mcp.tool()
+ def fn(x: int) -> int:
+ return x + 1
+
+ mcp_tools = await mcp._mcp_list_tools()
+ assert len(mcp_tools) == 1
+ assert mcp_tools[0].name == "fn"
+
+ async def test_mcp_tool_custom_name(self):
+ """Test MCPTool name for add_tool (key != tool.name)."""
+
+ mcp = FastMCP()
+
+ @mcp.tool(name="custom_name")
+ def fn(x: int) -> int:
+ return x + 1
+
+ mcp_tools = await mcp._mcp_list_tools()
+ assert len(mcp_tools) == 1
+ assert mcp_tools[0].name == "custom_name"
+
+
class TestToolDecorator:
async def test_no_tools_before_decorator(self):
mcp = FastMCP()
- with pytest.raises(ToolError, match="Unknown tool: add"):
- await mcp.call_tool("add", {"x": 1, "y": 2})
+ with pytest.raises(NotFoundError, match="Unknown tool: add"):
+ await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
async def test_tool_decorator(self):
mcp = FastMCP()
@@ -71,7 +98,7 @@ class TestToolDecorator:
def add(x: int, y: int) -> int:
return x + y
- result = await mcp.call_tool("add", {"x": 1, "y": 2})
+ result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@@ -91,7 +118,7 @@ class TestToolDecorator:
def add(x: int, y: int) -> int:
return x + y
- result = await mcp.call_tool("custom-add", {"x": 1, "y": 2})
+ result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@@ -120,7 +147,7 @@ class TestToolDecorator:
obj = MyClass(10)
mcp.add_tool(obj.add)
- result = await mcp.call_tool("add", {"y": 2})
+ result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
@@ -135,7 +162,7 @@ class TestToolDecorator:
return cls.x + y
mcp.add_tool(MyClass.add)
- result = await mcp.call_tool("add", {"y": 2})
+ result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
@@ -148,7 +175,7 @@ class TestToolDecorator:
def add(x: int, y: int) -> int:
return x + y
- result = await mcp.call_tool("add", {"x": 1, "y": 2})
+ result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@@ -159,7 +186,7 @@ class TestToolDecorator:
async def add(x: int, y: int) -> int:
return x + y
- result = await mcp.call_tool("add", {"x": 1, "y": 2})
+ result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@@ -174,7 +201,7 @@ class TestToolDecorator:
return cls.x + y
mcp.add_tool(MyClass.add)
- result = await mcp.call_tool("add", {"y": 2})
+ result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
@@ -187,7 +214,7 @@ class TestToolDecorator:
return x + y
mcp.add_tool(MyClass.add)
- result = await mcp.call_tool("add", {"x": 1, "y": 2})
+ result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@@ -212,29 +239,28 @@ class TestToolDecorator:
"""Multiply two numbers."""
return a * b
- # Add the tool with a custom name
mcp.add_tool(multiply, name="custom_multiply")
# Check that the tool is registered with the custom name
- tools = mcp.list_tools()
- tool_names = [t.name for t in tools]
- assert "custom_multiply" in tool_names
+ tools = await mcp.get_tools()
+ assert "custom_multiply" in tools
# Call the tool by its custom name
- result = await mcp.call_tool("custom_multiply", {"a": 5, "b": 3})
+ result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
assert isinstance(result[0], TextContent)
assert result[0].text == "15"
# Original name should not be registered
- assert "multiply" not in tool_names
+ assert "multiply" not in tools
class TestResourceDecorator:
async def test_no_resources_before_decorator(self):
mcp = FastMCP()
- with pytest.raises(ResourceError, match="Unknown resource"):
- await mcp.read_resource("resource://data")
+ with pytest.raises(ClientError, match="Unknown resource"):
+ async with Client(mcp) as client:
+ await client.read_resource("resource://data")
async def test_resource_decorator(self):
mcp = FastMCP()
@@ -243,8 +269,10 @@ class TestResourceDecorator:
def get_data() -> str:
return "Hello, world!"
- result = await mcp.read_resource("resource://data")
- assert result == "Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Hello, world!"
async def test_resource_decorator_incorrect_usage(self):
mcp = FastMCP()
@@ -264,12 +292,15 @@ class TestResourceDecorator:
def get_data() -> str:
return "Hello, world!"
- resources = mcp.list_resources()
+ resources_dict = await mcp.get_resources()
+ resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].name == "custom-data"
- result = await mcp.read_resource("resource://data")
- assert result == "Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Hello, world!"
async def test_resource_decorator_with_description(self):
mcp = FastMCP()
@@ -278,10 +309,24 @@ class TestResourceDecorator:
def get_data() -> str:
return "Hello, world!"
- resources = mcp.list_resources()
+ resources_dict = await mcp.get_resources()
+ resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].description == "Data resource"
+ async def test_resource_decorator_with_tags(self):
+ """Test that the resource decorator properly sets tags."""
+ mcp = FastMCP()
+
+ @mcp.resource("resource://data", tags={"example", "test-tag"})
+ def get_data() -> str:
+ return "Hello, world!"
+
+ resources_dict = await mcp.get_resources()
+ resources = list(resources_dict.values())
+ assert len(resources) == 1
+ assert resources[0].tags == {"example", "test-tag"}
+
async def test_resource_decorator_instance_method(self):
mcp = FastMCP()
@@ -297,8 +342,10 @@ class TestResourceDecorator:
obj.get_data, uri="resource://data", name="instance-resource"
)
- result = await mcp.read_resource("resource://data")
- assert result == "My prefix: Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "My prefix: Hello, world!"
async def test_resource_decorator_classmethod(self):
mcp = FastMCP()
@@ -314,8 +361,10 @@ class TestResourceDecorator:
MyClass.get_data, uri="resource://data", name="class-resource"
)
- result = await mcp.read_resource("resource://data")
- assert result == "Class prefix: Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Class prefix: Hello, world!"
async def test_resource_decorator_staticmethod(self):
mcp = FastMCP()
@@ -326,8 +375,10 @@ class TestResourceDecorator:
def get_data() -> str:
return "Static Hello, world!"
- result = await mcp.read_resource("resource://data")
- assert result == "Static Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Static Hello, world!"
async def test_resource_decorator_async_function(self):
mcp = FastMCP()
@@ -336,19 +387,10 @@ class TestResourceDecorator:
async def get_data() -> str:
return "Async Hello, world!"
- result = await mcp.read_resource("resource://data")
- assert result == "Async Hello, world!"
-
- async def test_resource_decorator_with_tags(self):
- mcp = FastMCP()
-
- @mcp.resource("resource://data", tags={"example", "test-tag"})
- def get_data() -> str:
- return "Hello, world!"
-
- resources = mcp.list_resources()
- assert len(resources) == 1
- assert resources[0].tags == {"example", "test-tag"}
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Async Hello, world!"
class TestTemplateDecorator:
@@ -359,12 +401,16 @@ class TestTemplateDecorator:
def get_data(name: str) -> str:
return f"Data for {name}"
- templates = mcp.list_resource_templates()
+ templates_dict = await mcp.get_resource_templates()
+ templates = list(templates_dict.values())
assert len(templates) == 1
+ assert templates[0].name == "get_data"
assert templates[0].uri_template == "resource://{name}/data"
- result = await mcp.read_resource("resource://test/data")
- assert result == "Data for test"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://test/data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Data for test"
async def test_template_decorator_incorrect_usage(self):
mcp = FastMCP()
@@ -384,12 +430,15 @@ class TestTemplateDecorator:
def get_data(name: str) -> str:
return f"Data for {name}"
- templates = mcp.list_resource_templates()
+ templates_dict = await mcp.get_resource_templates()
+ templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].name == "custom-template"
- result = await mcp.read_resource("resource://test/data")
- assert result == "Data for test"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://test/data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Data for test"
async def test_template_decorator_with_description(self):
mcp = FastMCP()
@@ -398,7 +447,8 @@ class TestTemplateDecorator:
def get_data(name: str) -> str:
return f"Data for {name}"
- templates = mcp.list_resource_templates()
+ templates_dict = await mcp.get_resource_templates()
+ templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].description == "Template description"
@@ -413,13 +463,14 @@ class TestTemplateDecorator:
return f"{self.prefix} Data for {name}"
obj = MyClass("My prefix:")
-
mcp.add_resource_fn(
obj.get_data, uri="resource://{name}/data", name="instance-template"
)
- result = await mcp.read_resource("resource://test/data")
- assert result == "My prefix: Data for test"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://test/data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "My prefix: Data for test"
async def test_template_decorator_classmethod(self):
mcp = FastMCP()
@@ -432,11 +483,15 @@ class TestTemplateDecorator:
return f"{cls.prefix} Data for {name}"
mcp.add_resource_fn(
- MyClass.get_data, uri="resource://{name}/data", name="class-template"
+ MyClass.get_data,
+ uri="resource://{name}/data",
+ name="class-template",
)
- result = await mcp.read_resource("resource://test/data")
- assert result == "Class prefix: Data for test"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://test/data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Class prefix: Data for test"
async def test_template_decorator_staticmethod(self):
mcp = FastMCP()
@@ -447,8 +502,10 @@ class TestTemplateDecorator:
def get_data(name: str) -> str:
return f"Static Data for {name}"
- result = await mcp.read_resource("resource://test/data")
- assert result == "Static Data for test"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://test/data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Static Data for test"
async def test_template_decorator_async_function(self):
mcp = FastMCP()
@@ -457,19 +514,22 @@ class TestTemplateDecorator:
async def get_data(name: str) -> str:
return f"Async Data for {name}"
- result = await mcp.read_resource("resource://test/data")
- assert result == "Async Data for test"
+ async with Client(mcp) as client:
+ result = await client.read_resource("resource://test/data")
+ assert isinstance(result[0], TextResourceContents)
+ assert result[0].text == "Async Data for test"
async def test_template_decorator_with_tags(self):
+ """Test that the template decorator properly sets tags."""
mcp = FastMCP()
- @mcp.resource("resource://{name}/data", tags={"template", "test-tag"})
- def get_data(name: str) -> str:
- return f"Data for {name}"
+ @mcp.resource("resource://{param}", tags={"template", "test-tag"})
+ def template_resource(param: str) -> str:
+ return f"Template resource: {param}"
- templates = mcp.list_resource_templates()
- assert len(templates) == 1
- assert templates[0].tags == {"template", "test-tag"}
+ templates_dict = await mcp.get_resource_templates()
+ template = templates_dict["resource://{param}"]
+ assert template.tags == {"template", "test-tag"}
class TestPromptDecorator:
@@ -477,18 +537,17 @@ class TestPromptDecorator:
mcp = FastMCP()
@mcp.prompt()
- def test_prompt() -> str:
+ def fn() -> str:
return "Hello, world!"
- prompts = mcp.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].name == "test_prompt"
-
- result = await mcp.get_prompt("test_prompt")
- assert len(result) == 1
- message = result[0]
- assert isinstance(message.content, TextContent)
- assert message.content.text == "Hello, world!"
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["fn"]
+ assert prompt.name == "fn"
+ # Don't compare functions directly since validate_call wraps them
+ content = await prompt.render()
+ assert isinstance(content[0].content, TextContent)
+ assert content[0].content.text == "Hello, world!"
async def test_prompt_decorator_incorrect_usage(self):
mcp = FastMCP()
@@ -498,36 +557,38 @@ class TestPromptDecorator:
):
@mcp.prompt # Missing parentheses #type: ignore
- def test_prompt() -> str:
+ def fn() -> str:
return "Hello, world!"
async def test_prompt_decorator_with_name(self):
mcp = FastMCP()
- @mcp.prompt(name="custom-prompt")
- def test_prompt() -> str:
+ @mcp.prompt(name="custom_name")
+ def fn() -> str:
return "Hello, world!"
- prompts = mcp.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].name == "custom-prompt"
-
- result = await mcp.get_prompt("custom-prompt")
- assert len(result) == 1
- message = result[0]
- assert isinstance(message.content, TextContent)
- assert message.content.text == "Hello, world!"
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["custom_name"]
+ assert prompt.name == "custom_name"
+ content = await prompt.render()
+ assert isinstance(content[0].content, TextContent)
+ assert content[0].content.text == "Hello, world!"
async def test_prompt_decorator_with_description(self):
mcp = FastMCP()
- @mcp.prompt(description="Test prompt description")
- def test_prompt() -> str:
+ @mcp.prompt(description="A custom description")
+ def fn() -> str:
return "Hello, world!"
- prompts = mcp.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].description == "Test prompt description"
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["fn"]
+ assert prompt.description == "A custom description"
+ content = await prompt.render()
+ assert isinstance(content[0].content, TextContent)
+ assert content[0].content.text == "Hello, world!"
async def test_prompt_decorator_with_parameters(self):
mcp = FastMCP()
@@ -536,28 +597,30 @@ class TestPromptDecorator:
def test_prompt(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
- prompts = mcp.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].arguments is not None
- assert len(prompts[0].arguments) == 2
- assert prompts[0].arguments[0].name == "name"
- assert prompts[0].arguments[0].required is True
- assert prompts[0].arguments[1].name == "greeting"
- assert prompts[0].arguments[1].required is False
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["test_prompt"]
+ assert prompt.arguments is not None
+ assert len(prompt.arguments) == 2
+ assert prompt.arguments[0].name == "name"
+ assert prompt.arguments[0].required is True
+ assert prompt.arguments[1].name == "greeting"
+ assert prompt.arguments[1].required is False
- result = await mcp.get_prompt("test_prompt", {"name": "World"})
- assert len(result) == 1
- message = result[0]
- assert isinstance(message.content, TextContent)
- assert message.content.text == "Hello, World!"
+ async with Client(mcp) as client:
+ result = await client.get_prompt("test_prompt", {"name": "World"})
+ assert len(result) == 1
+ message = result[0]
+ assert isinstance(message.content, TextContent)
+ assert message.content.text == "Hello, World!"
- result = await mcp.get_prompt(
- "test_prompt", {"name": "World", "greeting": "Hi"}
- )
- assert len(result) == 1
- message = result[0]
- assert isinstance(message.content, TextContent)
- assert message.content.text == "Hi, World!"
+ result = await client.get_prompt(
+ "test_prompt", {"name": "World", "greeting": "Hi"}
+ )
+ assert len(result) == 1
+ message = result[0]
+ assert isinstance(message.content, TextContent)
+ assert message.content.text == "Hi, World!"
async def test_prompt_decorator_instance_method(self):
mcp = FastMCP()
@@ -572,11 +635,12 @@ class TestPromptDecorator:
obj = MyClass("My prefix:")
mcp.add_prompt(obj.test_prompt, name="test_prompt")
- result = await mcp.get_prompt("test_prompt")
- assert len(result) == 1
- message = result[0]
- assert isinstance(message.content, TextContent)
- assert message.content.text == "My prefix: Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.get_prompt("test_prompt")
+ assert len(result) == 1
+ message = result[0]
+ assert isinstance(message.content, TextContent)
+ assert message.content.text == "My prefix: Hello, world!"
async def test_prompt_decorator_classmethod(self):
mcp = FastMCP()
@@ -590,11 +654,12 @@ class TestPromptDecorator:
mcp.add_prompt(MyClass.test_prompt, name="test_prompt")
- result = await mcp.get_prompt("test_prompt")
- assert len(result) == 1
- message = result[0]
- assert isinstance(message.content, TextContent)
- assert message.content.text == "Class prefix: Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.get_prompt("test_prompt")
+ assert len(result) == 1
+ message = result[0]
+ assert isinstance(message.content, TextContent)
+ assert message.content.text == "Class prefix: Hello, world!"
async def test_prompt_decorator_staticmethod(self):
mcp = FastMCP()
@@ -605,11 +670,12 @@ class TestPromptDecorator:
def test_prompt() -> str:
return "Static Hello, world!"
- result = await mcp.get_prompt("test_prompt")
- assert len(result) == 1
- message = result[0]
- assert isinstance(message.content, TextContent)
- assert message.content.text == "Static Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.get_prompt("test_prompt")
+ assert len(result) == 1
+ message = result[0]
+ assert isinstance(message.content, TextContent)
+ assert message.content.text == "Static Hello, world!"
async def test_prompt_decorator_async_function(self):
mcp = FastMCP()
@@ -618,22 +684,25 @@ class TestPromptDecorator:
async def test_prompt() -> str:
return "Async Hello, world!"
- result = await mcp.get_prompt("test_prompt")
- assert len(result) == 1
- message = result[0]
- assert isinstance(message.content, TextContent)
- assert message.content.text == "Async Hello, world!"
+ async with Client(mcp) as client:
+ result = await client.get_prompt("test_prompt")
+ assert len(result) == 1
+ message = result[0]
+ assert isinstance(message.content, TextContent)
+ assert message.content.text == "Async Hello, world!"
async def test_prompt_decorator_with_tags(self):
+ """Test that the prompt decorator properly sets tags."""
mcp = FastMCP()
@mcp.prompt(tags={"example", "test-tag"})
- def test_prompt() -> str:
+ def sample_prompt() -> str:
return "Hello, world!"
- prompts = mcp.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].tags == {"example", "test-tag"}
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["sample_prompt"]
+ assert prompt.tags == {"example", "test-tag"}
@pytest.fixture
@@ -683,7 +752,7 @@ class TestServerTools:
assert len(await tool_server._mcp_list_tools()) == 6
async def test_call_tool(self, tool_server: FastMCP):
- result = await tool_server.call_tool("add", {"x": 1, "y": 2})
+ result = await tool_server._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@@ -695,7 +764,7 @@ class TestServerTools:
async def test_call_tool_error(self, tool_server: FastMCP):
with pytest.raises(ToolError):
- await tool_server.call_tool("error_tool", {})
+ await tool_server._mcp_call_tool("error_tool", {})
async def test_call_tool_error_as_client(self, tool_server: FastMCP):
async with Client(tool_server) as client:
@@ -710,7 +779,7 @@ class TestServerTools:
assert "Test error" in result.content[0].text
async def test_tool_returns_list(self, tool_server: FastMCP):
- result = await tool_server.call_tool("list_tool", {})
+ result = await tool_server._mcp_call_tool("list_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == '["x", 2]'
@@ -719,7 +788,9 @@ class TestServerTools:
image_path = tmp_path / "test.png"
image_path.write_bytes(b"fake png data")
- result = await tool_server.call_tool("image_tool", {"path": str(image_path)})
+ result = await tool_server._mcp_call_tool(
+ "image_tool", {"path": str(image_path)}
+ )
content = result[0]
assert isinstance(content, ImageContent)
assert content.type == "image"
@@ -729,7 +800,7 @@ class TestServerTools:
assert decoded == b"fake png data"
async def test_tool_mixed_content(self, tool_server: FastMCP):
- result = await tool_server.call_tool("mixed_content_tool", {})
+ result = await tool_server._mcp_call_tool("mixed_content_tool", {})
assert len(result) == 2
content1 = result[0]
content2 = result[1]
@@ -748,7 +819,7 @@ class TestServerTools:
image_path = tmp_path / "test.png"
image_path.write_bytes(b"test image data")
- result = await tool_server.call_tool(
+ result = await tool_server._mcp_call_tool(
"mixed_list_fn", {"image_path": str(image_path)}
)
assert len(result) == 3
@@ -966,8 +1037,7 @@ class TestServerResourceTemplates:
assert result[0].text == "Static data"
async def test_template_with_default_params(self):
- """Test that a template with default function parameters works when those parameters
- are not in the URI template"""
+ """Test that a template can have default parameters."""
mcp = FastMCP()
@mcp.resource("math://add/{x}")
@@ -975,7 +1045,8 @@ class TestServerResourceTemplates:
return x + y
# Verify it's registered as a template
- templates = mcp.list_resource_templates()
+ templates_dict = await mcp.get_resource_templates()
+ templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].uri_template == "math://add/{x}"
@@ -992,16 +1063,18 @@ class TestServerResourceTemplates:
assert result == "17" # 7 + default 10
async def test_template_to_resource_conversion(self):
- """Test that templates are properly converted to resources when accessed"""
+ """Test that a template can be converted to a resource."""
mcp = FastMCP()
@mcp.resource("resource://{name}/data")
def get_data(name: str) -> str:
return f"Data for {name}"
- # Should be registered as a template
- assert len(mcp._resource_manager._templates) == 1
- assert len(await mcp._mcp_list_resources()) == 0
+ # Verify it's registered as a template
+ templates_dict = await mcp.get_resource_templates()
+ templates = list(templates_dict.values())
+ assert len(templates) == 1
+ assert templates[0].uri_template == "resource://{name}/data"
# When accessed, should create a concrete resource
resource = await mcp._resource_manager.get_resource("resource://test/data")
@@ -1010,30 +1083,32 @@ class TestServerResourceTemplates:
assert result == "Data for test"
async def test_stacked_resource_template_decorators(self):
- """Test that multiple resource decorators can be stacked on the same function."""
+ """Test that resource template decorators can be stacked."""
mcp = FastMCP()
- # Define a function with multiple stacked resource decorators
@mcp.resource("users://email/{email}")
@mcp.resource("users://name/{name}")
def lookup_user(name: str | None = None, email: str | None = None) -> dict:
- """Look up a user by either name or email."""
- # In a real implementation, this would query a database
- if email:
+ if name:
return {
- "found_by": "email",
- "name": f"User for {email}",
+ "id": "123",
+ "name": name,
+ "email": "dummy@example.com",
+ "lookup": "name",
+ }
+ elif email:
+ return {
+ "id": "123",
+ "name": "Test User",
"email": email,
+ "lookup": "email",
}
else:
- return {
- "found_by": "name",
- "name": name,
- "email": f"{name.lower()}@example.com" if name else None,
- }
+ raise ValueError("Either name or email must be provided")
# Verify both templates are registered
- templates = mcp.list_resource_templates()
+ templates_dict = await mcp.get_resource_templates()
+ templates = list(templates_dict.values())
assert len(templates) == 2
template_uris = {t.uri_template for t in templates}
assert "users://email/{email}" in template_uris
@@ -1046,16 +1121,27 @@ class TestServerResourceTemplates:
)
assert isinstance(email_result[0], TextResourceContents)
email_data = json.loads(email_result[0].text)
- assert email_data["found_by"] == "email"
+ assert email_data["lookup"] == "email"
assert email_data["email"] == "user@example.com"
# Test lookup by name
name_result = await client.read_resource(AnyUrl("users://name/John"))
assert isinstance(name_result[0], TextResourceContents)
name_data = json.loads(name_result[0].text)
- assert name_data["found_by"] == "name"
+ assert name_data["lookup"] == "name"
assert name_data["name"] == "John"
- assert name_data["email"] == "john@example.com"
+ assert name_data["email"] == "dummy@example.com"
+
+ async def test_template_decorator_with_tags(self):
+ mcp = FastMCP()
+
+ @mcp.resource("resource://{param}", tags={"template", "test-tag"})
+ def template_resource(param: str) -> str:
+ return f"Template resource: {param}"
+
+ templates_dict = await mcp.get_resource_templates()
+ template = templates_dict["resource://{param}"]
+ assert template.tags == {"template", "test-tag"}
class TestContextInjection:
@@ -1192,11 +1278,12 @@ class TestServerPrompts:
def fn() -> str:
return "Hello, world!"
- prompts = mcp._prompt_manager.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].name == "fn"
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["fn"]
+ assert prompt.name == "fn"
# Don't compare functions directly since validate_call wraps them
- content = await prompts[0].render()
+ content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@@ -1208,10 +1295,11 @@ class TestServerPrompts:
def fn() -> str:
return "Hello, world!"
- prompts = mcp._prompt_manager.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].name == "custom_name"
- content = await prompts[0].render()
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["custom_name"]
+ assert prompt.name == "custom_name"
+ content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@@ -1223,10 +1311,11 @@ class TestServerPrompts:
def fn() -> str:
return "Hello, world!"
- prompts = mcp._prompt_manager.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].description == "A custom description"
- content = await prompts[0].render()
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["fn"]
+ assert prompt.description == "A custom description"
+ content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@@ -1245,20 +1334,22 @@ class TestServerPrompts:
@mcp.prompt()
def fn(name: str, optional: str = "default") -> str:
- return f"Hello, {name}!"
+ return f"Hello, {name}! {optional}"
+
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
async with Client(mcp) as client:
- result = await client.list_prompts()
- assert result is not None
- assert len(result) == 1
- prompt = result[0]
- assert prompt.name == "fn"
- assert prompt.arguments is not None
- assert len(prompt.arguments) == 2
- assert prompt.arguments[0].name == "name"
- assert prompt.arguments[0].required is True
- assert prompt.arguments[1].name == "optional"
- assert prompt.arguments[1].required is False
+ prompts = await client.list_prompts()
+ assert len(prompts) == 1
+ assert prompts[0].name == "fn"
+ assert prompts[0].description is None
+ assert prompts[0].arguments is not None
+ assert len(prompts[0].arguments) == 2
+ assert prompts[0].arguments[0].name == "name"
+ assert prompts[0].arguments[0].required is True
+ assert prompts[0].arguments[1].name == "optional"
+ assert prompts[0].arguments[1].required is False
async def test_get_prompt(self):
"""Test getting a prompt through MCP protocol."""
@@ -1270,8 +1361,8 @@ class TestServerPrompts:
async with Client(mcp) as client:
result = await client.get_prompt("fn", {"name": "World"})
- assert len(result.messages) == 1
- message = result.messages[0]
+ assert len(result) == 1
+ message = result[0]
assert message.role == "user"
content = message.content
assert isinstance(content, TextContent)
@@ -1296,8 +1387,8 @@ class TestServerPrompts:
async with Client(mcp) as client:
result = await client.get_prompt("fn")
- assert result.messages[0].role == "user"
- content = result.messages[0].content
+ assert result[0].role == "user"
+ content = result[0].content
assert isinstance(content, EmbeddedResource)
resource = content.resource
assert isinstance(resource, TextResourceContents)
@@ -1307,8 +1398,8 @@ class TestServerPrompts:
async def test_get_unknown_prompt(self):
"""Test error when getting unknown prompt."""
mcp = FastMCP()
- async with Client(mcp) as client:
- with pytest.raises(McpError, match="Unknown prompt"):
+ with pytest.raises(ClientError, match="Unknown prompt"):
+ async with Client(mcp) as client:
await client.get_prompt("unknown")
async def test_get_prompt_missing_args(self):
@@ -1319,8 +1410,8 @@ class TestServerPrompts:
def prompt_fn(name: str) -> str:
return f"Hello, {name}!"
- async with Client(mcp) as client:
- with pytest.raises(McpError, match="Missing required arguments"):
+ with pytest.raises(ClientError, match="Missing required arguments"):
+ async with Client(mcp) as client:
await client.get_prompt("prompt_fn")
async def test_tool_decorator_with_tags(self):
@@ -1337,15 +1428,15 @@ class TestServerPrompts:
assert tools[0].tags == {"example", "test-tag"}
async def test_resource_decorator_with_tags(self):
- """Test that the resource decorator properly sets tags."""
+ """Test that the resource decorator supports tags."""
mcp = FastMCP()
- @mcp.resource("resource://sample", tags={"example", "test-tag"})
- def sample_resource() -> str:
- return "Sample resource"
+ @mcp.resource("resource://data", tags={"example", "test-tag"})
+ def get_data() -> str:
+ return "Hello, world!"
- # Verify the tags were set correctly
- resources = mcp._resource_manager.list_resources()
+ resources_dict = await mcp.get_resources()
+ resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].tags == {"example", "test-tag"}
@@ -1357,9 +1448,9 @@ class TestServerPrompts:
def template_resource(param: str) -> str:
return f"Template resource: {param}"
- templates = mcp._resource_manager.list_templates()
- assert len(templates) == 1
- assert templates[0].tags == {"template", "test-tag"}
+ templates_dict = await mcp.get_resource_templates()
+ template = templates_dict["resource://{param}"]
+ assert template.tags == {"template", "test-tag"}
async def test_prompt_decorator_with_tags(self):
"""Test that the prompt decorator properly sets tags."""
@@ -1367,9 +1458,9 @@ class TestServerPrompts:
@mcp.prompt(tags={"example", "test-tag"})
def sample_prompt() -> str:
- return "Sample prompt"
+ return "Hello, world!"
- # Verify the tags were set correctly
- prompts = mcp._prompt_manager.list_prompts()
- assert len(prompts) == 1
- assert prompts[0].tags == {"example", "test-tag"}
+ prompts_dict = await mcp.get_prompts()
+ assert len(prompts_dict) == 1
+ prompt = prompts_dict["sample_prompt"]
+ assert prompt.tags == {"example", "test-tag"}
diff --git a/tests/server/test_servers/fastmcp_server.py b/tests/test_servers/fastmcp_server.py
similarity index 100%
rename from tests/server/test_servers/fastmcp_server.py
rename to tests/test_servers/fastmcp_server.py
diff --git a/tests/server/test_servers/sse.py b/tests/test_servers/sse.py
similarity index 100%
rename from tests/server/test_servers/sse.py
rename to tests/test_servers/sse.py
diff --git a/tests/server/test_servers/stdio.py b/tests/test_servers/stdio.py
similarity index 100%
rename from tests/server/test_servers/stdio.py
rename to tests/test_servers/stdio.py
diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py
index 5b8d79e7c..8206fd645 100644
--- a/tests/tools/test_tool_manager.py
+++ b/tests/tools/test_tool_manager.py
@@ -4,7 +4,7 @@ import logging
import pytest
from pydantic import BaseModel
-from fastmcp.exceptions import ToolError
+from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.tools import ToolManager
from fastmcp.tools.tool import Tool
@@ -235,23 +235,6 @@ class TestToolTags:
assert len(utility_tools) == 2
assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"}
- def test_import_tools_preserves_tags(self):
- """Test that importing tools preserves their tags."""
-
- def tagged_tool(x: int) -> int:
- """A tool with tags."""
- return x
-
- source_manager = ToolManager()
- source_manager.add_tool_from_fn(tagged_tool, tags={"test", "example"})
-
- target_manager = ToolManager()
- target_manager.import_tools(source_manager, "source/")
-
- imported_tool = target_manager.get_tool("source/tagged_tool")
- assert imported_tool is not None
- assert imported_tool.tags == {"test", "example"}
-
class TestCallTools:
async def test_call_tool(self):
@@ -262,7 +245,13 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(add)
result = await manager.call_tool("add", {"a": 1, "b": 2})
- assert result == 3
+ assert isinstance(result, list)
+ assert len(result) == 1
+ from mcp.types import TextContent
+
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "3"
+ assert json.loads(result[0].text) == 3
async def test_call_async_tool(self):
async def double(n: int) -> int:
@@ -272,7 +261,13 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(double)
result = await manager.call_tool("double", {"n": 5})
- assert result == 10
+ assert isinstance(result, list)
+ assert len(result) == 1
+ from mcp.types import TextContent
+
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "10"
+ assert json.loads(result[0].text) == 10
async def test_call_tool_with_default_args(self):
def add(a: int, b: int = 1) -> int:
@@ -282,7 +277,13 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(add)
result = await manager.call_tool("add", {"a": 1})
- assert result == 2
+ assert isinstance(result, list)
+ assert len(result) == 1
+ from mcp.types import TextContent
+
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "2"
+ assert json.loads(result[0].text) == 2
async def test_call_tool_with_missing_args(self):
def add(a: int, b: int) -> int:
@@ -296,7 +297,7 @@ class TestCallTools:
async def test_call_unknown_tool(self):
manager = ToolManager()
- with pytest.raises(ToolError):
+ with pytest.raises(NotFoundError, match="Unknown tool: unknown"):
await manager.call_tool("unknown", {"a": 1})
async def test_call_tool_with_list_int_input(self):
@@ -306,10 +307,21 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(sum_vals)
# Try both with plain list and with JSON list
+ from mcp.types import TextContent
+
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
- assert result == 6
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "6"
+ assert json.loads(result[0].text) == 6
+
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
- assert result == 6
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "6"
+ assert json.loads(result[0].text) == 6
async def test_call_tool_with_list_str_or_str_input(self):
def concat_strs(vals: list[str] | str) -> str:
@@ -317,17 +329,36 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(concat_strs)
+ from mcp.types import TextContent
+
# Try both with plain python object and with JSON list
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
- assert result == "abc"
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "abc"
+
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
- assert result == "abc"
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "abc"
+
result = await manager.call_tool("concat_strs", {"vals": "a"})
- assert result == "a"
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "a"
+
result = await manager.call_tool("concat_strs", {"vals": '"a"'})
- assert result == '"a"'
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == '"a"'
async def test_call_tool_with_complex_model(self):
+ from mcp.types import TextContent
+
from fastmcp import Context
class MyShrimpTank(BaseModel):
@@ -342,16 +373,26 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(name_shrimp)
+
result = await manager.call_tool(
"name_shrimp",
{"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
)
- assert result == ["rex", "gertrude"]
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == '["rex", "gertrude"]'
+ assert json.loads(result[0].text) == ["rex", "gertrude"]
+
result = await manager.call_tool(
"name_shrimp",
{"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
)
- assert result == ["rex", "gertrude"]
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == '["rex", "gertrude"]'
+ assert json.loads(result[0].text) == ["rex", "gertrude"]
class TestToolSchema:
@@ -391,6 +432,8 @@ class TestContextHandling:
async def test_context_injection(self):
"""Test that context is properly injected during tool execution."""
+ from mcp.types import TextContent
+
from fastmcp import Context, FastMCP
def tool_with_context(x: int, ctx: Context) -> str:
@@ -403,10 +446,15 @@ class TestContextHandling:
mcp = FastMCP()
ctx = mcp.get_context()
result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
- assert result == "42"
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "42"
async def test_context_injection_async(self):
"""Test that context is properly injected in async tools."""
+ from mcp.types import TextContent
+
from fastmcp import Context, FastMCP
async def async_tool(x: int, ctx: Context) -> str:
@@ -419,10 +467,15 @@ class TestContextHandling:
mcp = FastMCP()
ctx = mcp.get_context()
result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
- assert result == "42"
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "42"
async def test_context_optional(self):
"""Test that context is optional when calling tools."""
+ from mcp.types import TextContent
+
from fastmcp import Context
def tool_with_context(x: int, ctx: Context | None = None) -> str:
@@ -432,7 +485,10 @@ class TestContextHandling:
manager.add_tool_from_fn(tool_with_context)
# Should not raise an error when context is not provided
result = await manager.call_tool("tool_with_context", {"x": 42})
- assert result == "42"
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "42"
async def test_context_error_handling(self):
"""Test error handling when context injection fails."""
@@ -450,112 +506,6 @@ class TestContextHandling:
await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
-class TestImportTools:
- def test_import_tools(self):
- """Test importing tools from one manager to another with a prefix."""
- # Setup source manager with tools
- source_manager = ToolManager()
-
- # Create some test tools
- def tool1_fn():
- return "Tool 1 result"
-
- def tool2_fn():
- return "Tool 2 result"
-
- # Add tools to source manager
- source_manager.add_tool_from_fn(
- tool1_fn, name="get_data", description="Get some data"
- )
- source_manager.add_tool_from_fn(
- tool2_fn, name="process_data", description="Process the data"
- )
-
- # Create target manager
- target_manager = ToolManager()
-
- # Import tools from source to target
- prefix = "source/"
- target_manager.import_tools(source_manager, prefix)
-
- # Verify tools were imported with prefixes
- assert "source/get_data" in target_manager._tools
- assert "source/process_data" in target_manager._tools
-
- # Verify the original tools still exist in source manager
- assert "get_data" in source_manager._tools
- assert "process_data" in source_manager._tools
-
- # Verify the imported tools have the correct descriptions
- assert target_manager._tools["source/get_data"].description == "Get some data"
- assert (
- target_manager._tools["source/process_data"].description
- == "Process the data"
- )
-
- # Verify the tool functions were properly copied
- # We can't directly compare functions, so we'll check their __name__ attribute
- assert target_manager._tools["source/get_data"].fn == tool1_fn
- assert target_manager._tools["source/process_data"].fn == tool2_fn
-
- def test_tool_duplicate_behavior(self):
- """Test the behavior when importing tools with duplicate names."""
- # Setup source and target managers
- source_manager = ToolManager()
- target_manager = ToolManager()
-
- # Add the same tool name to both managers
- def source_fn():
- return "Source result"
-
- def target_fn():
- return "Target result"
-
- source_manager.add_tool_from_fn(source_fn, name="common_tool")
- target_manager.add_tool_from_fn(
- target_fn, name="source/common_tool"
- ) # Pre-create with the prefixed name
-
- # Import tools from source to target
- target_manager.import_tools(source_manager, "source/")
-
- # The original tool in the target manager is replaced by the imported one
- assert target_manager._tools["source/common_tool"].fn == source_fn
-
- def test_import_tools_with_multiple_prefixes(self):
- """Test importing tools from multiple managers with different prefixes."""
- # Setup source managers
- weather_manager = ToolManager()
- news_manager = ToolManager()
-
- # Add tools to source managers
- def forecast_fn():
- return "Weather forecast"
-
- def headlines_fn():
- return "News headlines"
-
- weather_manager.add_tool_from_fn(forecast_fn, name="forecast")
- news_manager.add_tool_from_fn(headlines_fn, name="headlines")
-
- # Create target manager and import from both sources
- main_manager = ToolManager()
- main_manager.import_tools(weather_manager, "weather/")
- main_manager.import_tools(news_manager, "news/")
-
- # Verify tools were imported with correct prefixes
- assert "weather/forecast" in main_manager._tools
- assert "news/headlines" in main_manager._tools
-
- # Verify the tools are accessible and functioning
- assert (
- main_manager._tools["weather/forecast"].fn.__name__ == forecast_fn.__name__
- )
- assert (
- main_manager._tools["news/headlines"].fn.__name__ == headlines_fn.__name__
- )
-
-
class TestCustomToolNames:
"""Test adding tools with custom names that differ from their function names."""
@@ -573,7 +523,8 @@ class TestCustomToolNames:
assert tool.name == "custom_name"
assert tool.fn.__name__ == "original_fn"
# The tool should not be accessible via its original function name
- assert manager.get_tool("original_fn") is None
+ with pytest.raises(NotFoundError, match="Unknown tool: original_fn"):
+ manager.get_tool("original_fn")
def test_add_tool_object_with_custom_key(self):
"""Test adding a Tool object with a custom key using add_tool()."""
@@ -592,10 +543,12 @@ class TestCustomToolNames:
# But the tool's .name is unchanged
assert stored.name == "my_tool"
# The tool is not accessible under its original name
- assert manager.get_tool("my_tool") is None
+ with pytest.raises(NotFoundError, match="Unknown tool: my_tool"):
+ manager.get_tool("my_tool")
async def test_call_tool_with_custom_name(self):
"""Test calling a tool added with a custom name."""
+ from mcp.types import TextContent
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
@@ -606,59 +559,16 @@ class TestCustomToolNames:
# Tool should be callable by its custom name
result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
- assert result == 15
+ assert isinstance(result, list)
+ assert len(result) == 1
+ assert isinstance(result[0], TextContent)
+ assert result[0].text == "15"
+ assert json.loads(result[0].text) == 15
# Original name should not be registered
- with pytest.raises(ToolError):
+ with pytest.raises(NotFoundError, match="Unknown tool: multiply"):
await manager.call_tool("multiply", {"a": 5, "b": 3})
- def test_tool_to_mcp_tool(self):
- """Test that to_mcp_tool uses the key, not the internal name."""
-
- def some_function(x: int) -> int:
- return x
-
- manager = ToolManager()
- tool = Tool.from_function(some_function, name="api_function")
- manager.add_tool(tool)
-
- mcp_tools = manager.list_mcp_tools()
- assert len(mcp_tools) == 1
- assert mcp_tools[0].name == "api_function"
-
- def test_tool_to_mcp_tool_with_custom_key(self):
- """Test that to_mcp_tool uses the key, not the internal name."""
-
- def some_function(x: int) -> int:
- return x
-
- manager = ToolManager()
- tool = Tool.from_function(some_function, name="api_function")
- manager.add_tool(tool, key="custom-key")
-
- # When listing tools for MCP, the key should be used
- mcp_tools = manager.list_mcp_tools()
- assert len(mcp_tools) == 1
- assert mcp_tools[0].name == "custom-key"
-
- def test_import_tools_with_custom_names(self):
- """Test importing tools with custom names."""
-
- def source_fn(x: int) -> int:
- return x * 2
-
- # Create a source manager with a tool using custom name
- source_manager = ToolManager()
- source_manager.add_tool_from_fn(source_fn, name="custom_source")
-
- # Import the tools to a target manager with a prefix
- target_manager = ToolManager()
- target_manager.import_tools(source_manager, "prefix/")
-
- # The tool should be imported with the prefixed custom name
- assert target_manager.get_tool("prefix/custom_source") is not None
- assert target_manager.get_tool("prefix/source_fn") is None
-
def test_replace_tool_keeps_original_name(self):
"""Test that replacing a tool with "replace" keeps the original name."""
@@ -688,28 +598,3 @@ class TestCustomToolNames:
# But the function is different
assert stored_tool.fn.__name__ == "replacement_fn"
-
- def test_mcp_tool_name_for_add_tool(self):
- """Test MCPTool name for add_tool (key != tool.name)."""
-
- def fn(x: int) -> int:
- return x + 1
-
- tool = Tool.from_function(fn, name="my_tool")
- manager = ToolManager()
- manager.add_tool(tool, key="proxy_tool")
- mcp_tools = manager.list_mcp_tools()
- assert len(mcp_tools) == 1
- assert mcp_tools[0].name == "proxy_tool"
-
- def test_mcp_tool_name_for_add_tool_from_fn(self):
- """Test MCPTool name for add_tool_from_fn (key == tool.name)."""
-
- def fn(x: int) -> int:
- return x + 1
-
- manager = ToolManager()
- manager.add_tool_from_fn(fn, name="custom_fn")
- mcp_tools = manager.list_mcp_tools()
- assert len(mcp_tools) == 1
- assert mcp_tools[0].name == "custom_fn"
diff --git a/tests/utilities/test_func_metadata.py b/tests/utilities/test_func_metadata.py
index ee8037c7f..96d8c6f3b 100644
--- a/tests/utilities/test_func_metadata.py
+++ b/tests/utilities/test_func_metadata.py
@@ -85,7 +85,6 @@ def complex_arguments_fn(
return "ok!"
-@pytest.mark.anyio
async def test_complex_function_runtime_arg_validation_non_json():
"""Test that basic non-JSON arguments are validated correctly"""
meta = func_metadata(complex_arguments_fn)
@@ -122,7 +121,6 @@ async def test_complex_function_runtime_arg_validation_non_json():
)
-@pytest.mark.anyio
async def test_complex_function_runtime_arg_validation_with_json():
"""Test that JSON string arguments are parsed and validated correctly"""
meta = func_metadata(complex_arguments_fn)
@@ -199,7 +197,6 @@ def test_skip_names():
assert model.also_keep == 2.5 # type: ignore
-@pytest.mark.anyio
async def test_lambda_function():
"""Test lambda function schema and validation"""
fn = lambda x, y=5: x # noqa: E731
diff --git a/uv.lock b/uv.lock
index 7f89175dd..a12924537 100644
--- a/uv.lock
+++ b/uv.lock
@@ -254,11 +254,12 @@ wheels = [
[[package]]
name = "fastmcp"
-version = "2.1.2.dev9+55f3666"
+version = "2.1.3.dev42+be2ccc6"
source = { editable = "." }
dependencies = [
{ name = "dotenv" },
- { name = "fastapi" },
+ { name = "exceptiongroup" },
+ { name = "httpx" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "rich" },
@@ -270,6 +271,7 @@ dependencies = [
dev = [
{ name = "copychat" },
{ name = "dirty-equals" },
+ { name = "fastapi" },
{ name = "ipython" },
{ name = "pdbpp" },
{ name = "pre-commit" },
@@ -284,7 +286,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "dotenv", specifier = ">=0.9.9" },
- { name = "fastapi", specifier = ">=0.115.12" },
+ { name = "exceptiongroup", specifier = ">=1.2.2" },
+ { name = "httpx", specifier = ">=0.28.1" },
{ name = "mcp", specifier = ">=1.6.0,<2.0.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "rich", specifier = ">=13.9.4" },
@@ -296,6 +299,7 @@ requires-dist = [
dev = [
{ name = "copychat", specifier = ">=0.5.2" },
{ name = "dirty-equals", specifier = ">=0.9.0" },
+ { name = "fastapi", specifier = ">=0.115.12" },
{ name = "ipython", specifier = ">=8.12.3" },
{ name = "pdbpp", specifier = ">=0.10.3" },
{ name = "pre-commit" },