mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Merge branch 'main' into mask-errors
This commit is contained in:
commit
4e6b1bf611
27 changed files with 1196 additions and 661 deletions
|
|
@ -37,7 +37,7 @@ Clients must be initialized with a `transport`. You can either provide an alread
|
|||
The following inference rules are used to determine the appropriate `ClientTransport` based on the input type:
|
||||
|
||||
1. **`ClientTransport` Instance**: If you provide an already instantiated transport object, it's used directly.
|
||||
2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing).
|
||||
2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing). This also works with a **FastMCP 1.0 server** created via `mcp.server.fastmcp.FastMCP`.
|
||||
3. **`Path` or `str` pointing to an existing file**:
|
||||
* If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`.
|
||||
* If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`.
|
||||
|
|
@ -91,7 +91,7 @@ For more control over connection details (like headers for SSE, environment vari
|
|||
|
||||
### Multi-Server Clients
|
||||
|
||||
<VersionBadge version="2.3.6" />
|
||||
<VersionBadge version="2.4.0" />
|
||||
|
||||
FastMCP supports creating clients that connect to multiple MCP servers through a single client interface using a standard MCP configuration format (`MCPConfig`). This configuration approach makes it easy to connect to multiple specialized servers or create composable systems with a simple, declarative syntax.
|
||||
|
||||
|
|
|
|||
|
|
@ -290,8 +290,8 @@ asyncio.run(main())
|
|||
### FastMCP Transport
|
||||
|
||||
- **Class:** `fastmcp.client.transports.FastMCPTransport`
|
||||
- **Inferred From:** An instance of `fastmcp.server.FastMCP`
|
||||
- **Use Case:** Connecting directly to a `FastMCP` server instance in the same Python process
|
||||
- **Inferred From:** An instance of `fastmcp.server.FastMCP` or a **FastMCP 1.0 server** (`mcp.server.fastmcp.FastMCP`)
|
||||
- **Use Case:** Connecting directly to a FastMCP server instance in the same Python process
|
||||
|
||||
This is extremely useful for testing your FastMCP servers.
|
||||
|
||||
|
|
@ -323,7 +323,7 @@ Communication happens through efficient in-memory queues, making it very fast an
|
|||
|
||||
### MCPConfig Transport
|
||||
|
||||
<VersionBadge version="2.3.6" />
|
||||
<VersionBadge version="2.4.0" />
|
||||
|
||||
- **Class:** `fastmcp.client.transports.MCPConfigTransport`
|
||||
- **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
"servers/resources",
|
||||
"servers/prompts",
|
||||
"servers/context",
|
||||
"servers/openapi",
|
||||
"servers/proxy",
|
||||
"servers/composition"
|
||||
]
|
||||
|
|
@ -76,8 +77,6 @@
|
|||
"pages": [
|
||||
"patterns/decorating-methods",
|
||||
"patterns/http-requests",
|
||||
"patterns/openapi",
|
||||
"patterns/fastapi",
|
||||
"patterns/contrib",
|
||||
"patterns/testing"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -60,6 +60,20 @@ mcp = FastMCP("My MCP Server")
|
|||
Prior to `fastmcp==2.3.0` and `mcp==1.8.0`, the 2.x API always mirrored the 1.0 API. However, as the projects diverge, this can not be guaranteed. You may see deprecation warnings if you attempt to use 1.0 APIs in FastMCP 2.x. Please refer to this documentation for details on new capabilities.
|
||||
</Warning>
|
||||
|
||||
## Versioning and Breaking Changes
|
||||
|
||||
While we make every effort not to introduce backwards-incompatible changes to our public APIs and behavior, FastMCP exists in a rapidly evolving MCP landscape. We're committed to bringing the most cutting-edge features to our users, which occasionally necessitates changes to existing functionality.
|
||||
|
||||
As a practice, breaking changes will only occur on minor version changes (e.g., 2.3.x to 2.4.0). A minor version change indicates either:
|
||||
- A significant new feature set that warrants a new minor version
|
||||
- Introducing breaking changes that may affect behavior on upgrade
|
||||
|
||||
For users concerned about stability in production environments, we recommend pinning FastMCP to a specific version in your dependencies.
|
||||
|
||||
Whenever possible, FastMCP will issue deprecation warnings when users attempt to use APIs that are either deprecated or destined for future removal. These warnings will be maintained for at least 1 minor version release, and may be maintained longer.
|
||||
|
||||
Note that the "public API" includes the core functionality of the `FastMCP` server and its methods. It does not include private methods or objects that are stored as private attributes, as we do not expect users to rely on those implementation details.
|
||||
|
||||
## Installing for Development
|
||||
|
||||
If you plan to contribute to FastMCP, you should begin by cloning the repository and using uv to install all dependencies (development dependencies are installed automatically):
|
||||
|
|
|
|||
|
|
@ -8,19 +8,18 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|||
|
||||
<VersionBadge version="2.0.0" />
|
||||
|
||||
<Note>
|
||||
**Documentation Moved**: The comprehensive FastAPI integration documentation has been moved to the [OpenAPI Integration](/patterns/openapi#fastapi-integration) page, where it's covered alongside all other OpenAPI features including route mapping and tags support.
|
||||
</Note>
|
||||
|
||||
FastMCP can automatically convert FastAPI applications into MCP servers.
|
||||
## Quick Start
|
||||
|
||||
<Tip>
|
||||
FastMCP does *not* include FastAPI as a dependency; you must install it separately to run these examples.
|
||||
</Tip>
|
||||
FastMCP can automatically convert FastAPI applications into MCP servers:
|
||||
|
||||
|
||||
```python {2, 22, 25}
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
# A FastAPI app
|
||||
app = FastAPI()
|
||||
|
||||
|
|
@ -36,7 +35,6 @@ def get_item(item_id: int):
|
|||
def create_item(name: str):
|
||||
return {"id": 3, "name": name}
|
||||
|
||||
|
||||
# Create an MCP server from your FastAPI app
|
||||
mcp = FastMCP.from_fastapi(app=app)
|
||||
|
||||
|
|
@ -44,101 +42,6 @@ if __name__ == "__main__":
|
|||
mcp.run() # Start the MCP server
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Timeout
|
||||
|
||||
You can set a timeout for all API requests:
|
||||
|
||||
```python
|
||||
# Set a 5 second timeout for all requests
|
||||
mcp = FastMCP.from_fastapi(app=app, timeout=5.0)
|
||||
```
|
||||
|
||||
This timeout is applied to all requests made by tools, resources, and resource templates.
|
||||
|
||||
## 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 [expandable]
|
||||
import asyncio
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastmcp import FastMCP, Client
|
||||
|
||||
# Define your Pydantic model
|
||||
class Item(BaseModel):
|
||||
name: str
|
||||
price: float
|
||||
|
||||
# Create your FastAPI app
|
||||
app = FastAPI()
|
||||
items = {} # In-memory database
|
||||
|
||||
@app.get("/items")
|
||||
def list_items():
|
||||
"""List all items"""
|
||||
return list(items.values())
|
||||
|
||||
@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):
|
||||
"""Create a new item"""
|
||||
item_id = len(items) + 1
|
||||
items[item_id] = {"id": item_id, **item.model_dump()}
|
||||
return items[item_id]
|
||||
|
||||
# Test your MCP server with a client
|
||||
async def check_mcp(mcp: FastMCP):
|
||||
# List the components that were created
|
||||
tools = await mcp.get_tools()
|
||||
resources = await mcp.get_resources()
|
||||
templates = await mcp.get_resource_templates()
|
||||
|
||||
print(
|
||||
f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}"
|
||||
)
|
||||
print(
|
||||
f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}"
|
||||
)
|
||||
print(
|
||||
f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}"
|
||||
)
|
||||
|
||||
return mcp
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create MCP server from FastAPI app
|
||||
mcp = FastMCP.from_fastapi(app=app)
|
||||
|
||||
asyncio.run(check_mcp(mcp))
|
||||
|
||||
# In a real scenario, you would run the server:
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **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
|
||||
<Tip>
|
||||
For complete documentation including tag-based routing, route mapping configuration, timeout settings, authentication examples, and advanced configuration options, see the comprehensive [OpenAPI Integration documentation](/patterns/openapi#fastapi-integration).
|
||||
</Tip>
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
---
|
||||
title: OpenAPI Integration
|
||||
sidebarTitle: OpenAPI
|
||||
description: Generate MCP servers from OpenAPI specs
|
||||
icon: code-branch
|
||||
---
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
||||
<VersionBadge version="2.0.0" />
|
||||
|
||||
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.
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# Create a client for your API
|
||||
api_client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
|
||||
# Load your OpenAPI spec
|
||||
spec = {...}
|
||||
|
||||
# Create an MCP server from your OpenAPI spec
|
||||
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Timeout
|
||||
|
||||
You can set a timeout for all API requests:
|
||||
|
||||
```python
|
||||
# Set a 5 second timeout for all requests
|
||||
mcp = FastMCP.from_openapi(
|
||||
openapi_spec=spec,
|
||||
client=api_client,
|
||||
timeout=5.0
|
||||
)
|
||||
```
|
||||
|
||||
This timeout is applied to all requests made by tools, resources, and resource templates.
|
||||
|
||||
## Route Mapping
|
||||
|
||||
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 |
|
||||
|
||||
|
||||
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="*",
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
### All Routes as Tools
|
||||
|
||||
When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `all_routes_as_tools` parameter to automatically map every route to a Tool:
|
||||
|
||||
```python
|
||||
# Make all endpoints tools, regardless of HTTP method
|
||||
mcp = FastMCP.from_openapi(
|
||||
openapi_spec=spec,
|
||||
client=api_client,
|
||||
all_routes_as_tools=True
|
||||
)
|
||||
```
|
||||
|
||||
This is equivalent to defining a single route map that matches all routes:
|
||||
|
||||
```python
|
||||
# Same effect as all_routes_as_tools=True
|
||||
mcp = FastMCP.from_openapi(
|
||||
openapi_spec=spec,
|
||||
client=api_client,
|
||||
route_maps=[
|
||||
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead.
|
||||
|
||||
## 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
|
||||
|
||||
### Request Parameter Handling
|
||||
|
||||
FastMCP carefully handles different types of parameters in OpenAPI requests:
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues.
|
||||
|
||||
For example, if you call a tool with these parameters:
|
||||
```python
|
||||
await client.call_tool("search_products", {
|
||||
"category": "electronics", # Will be included
|
||||
"min_price": 100, # Will be included
|
||||
"max_price": None, # Will be excluded
|
||||
"brand": "", # Will be excluded
|
||||
})
|
||||
```
|
||||
|
||||
The resulting HTTP request will only include `category=electronics&min_price=100`.
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised.
|
||||
|
||||
```python
|
||||
# This will work
|
||||
await client.call_tool("get_product", {"product_id": 123})
|
||||
|
||||
# This will raise ValueError: "Missing required path parameters: {'product_id'}"
|
||||
await client.call_tool("get_product", {"product_id": None})
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```python [expandable]
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# Sample OpenAPI spec for a Pet Store API
|
||||
petstore_spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "Pet Store API",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample API for managing pets",
|
||||
},
|
||||
"paths": {
|
||||
"/pets": {
|
||||
"get": {
|
||||
"operationId": "listPets",
|
||||
"summary": "List all pets",
|
||||
"responses": {"200": {"description": "A list of pets"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createPet",
|
||||
"summary": "Create a new pet",
|
||||
"responses": {"201": {"description": "Pet created successfully"}},
|
||||
},
|
||||
},
|
||||
"/pets/{petId}": {
|
||||
"get": {
|
||||
"operationId": "getPet",
|
||||
"summary": "Get a pet by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "petId",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Pet details"},
|
||||
"404": {"description": "Pet not found"},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def check_mcp(mcp: FastMCP):
|
||||
# List what components were created
|
||||
tools = await mcp.get_tools()
|
||||
resources = await mcp.get_resources()
|
||||
templates = await mcp.get_resource_templates()
|
||||
|
||||
print(
|
||||
f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}"
|
||||
) # Should include createPet
|
||||
print(
|
||||
f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}"
|
||||
) # Should include listPets
|
||||
print(
|
||||
f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}"
|
||||
) # Should include getPet
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Client for the Pet Store API
|
||||
client = httpx.AsyncClient(base_url="https://petstore.example.com/api")
|
||||
|
||||
# Create the MCP server
|
||||
mcp = FastMCP.from_openapi(
|
||||
openapi_spec=petstore_spec, client=client, name="PetStore"
|
||||
)
|
||||
|
||||
asyncio.run(check_mcp(mcp))
|
||||
|
||||
# Start the MCP server
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ The choice of importing or mounting depends on your use case and requirements.
|
|||
|
||||
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.
|
||||
|
||||
<VersionBadge version="2.3.6" />
|
||||
<VersionBadge version="2.4.0" />
|
||||
|
||||
You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time.
|
||||
|
||||
|
|
|
|||
|
|
@ -228,8 +228,8 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
|
|||
# Create a sampling prompt asking for sentiment analysis
|
||||
prompt = f"Analyze the sentiment of the following text as positive, negative, or neutral. Just output a single word - 'positive', 'negative', or 'neutral'. Text to analyze: {text}"
|
||||
|
||||
# Send the sampling request to the client's LLM
|
||||
response = await ctx.sample(prompt)
|
||||
# Send the sampling request to the client's LLM (provide a hint for the model you want to use)
|
||||
response = await ctx.sample(prompt, model_preferences="claude-3-sonnet")
|
||||
|
||||
# Process the LLM's response
|
||||
sentiment = response.text.strip().lower()
|
||||
|
|
@ -247,11 +247,12 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
|
|||
|
||||
**Method signature:**
|
||||
|
||||
- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None) -> TextContent | ImageContent`**
|
||||
- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent`**
|
||||
- `messages`: A string or list of strings/message objects to send to the LLM
|
||||
- `system_prompt`: Optional system prompt to guide the LLM's behavior
|
||||
- `temperature`: Optional sampling temperature (controls randomness)
|
||||
- `max_tokens`: Optional maximum number of tokens to generate (defaults to 512)
|
||||
- `model_preferences`: Optional model selection preferences (e.g., a model hint string, list of hints, or a ModelPreferences object)
|
||||
- Returns the LLM's response as TextContent or ImageContent
|
||||
|
||||
When providing a simple string, it's treated as a user message. For more complex scenarios, you can provide a list of messages with different roles.
|
||||
|
|
|
|||
250
docs/servers/openapi.mdx
Normal file
250
docs/servers/openapi.mdx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
---
|
||||
title: OpenAPI Integration
|
||||
sidebarTitle: OpenAPI Integration
|
||||
description: Generate MCP servers from OpenAPI specs
|
||||
icon: code-branch
|
||||
---
|
||||
import { VersionBadge } from '/snippets/version-badge.mdx'
|
||||
|
||||
<VersionBadge version="2.0.0" />
|
||||
|
||||
FastMCP can automatically generate an MCP server from an OpenAPI specification or FastAPI app. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client, or their FastAPI app.
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# Create a client for your API
|
||||
api_client = httpx.AsyncClient(base_url="https://api.example.com")
|
||||
|
||||
# Load your OpenAPI spec
|
||||
spec = {...}
|
||||
|
||||
# Create an MCP server from your OpenAPI spec
|
||||
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
## Route Mapping
|
||||
|
||||
<VersionBadge version="2.5.0" />
|
||||
|
||||
By default, OpenAPI routes are mapped to MCP components based on these rules:
|
||||
|
||||
| OpenAPI Route | Example |MCP Component |
|
||||
| - | - | - |
|
||||
| `GET` with path params | `GET /users/{id}` | Resource Template |
|
||||
| `GET` without path params | `GET /stats` | Resource |
|
||||
| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool |
|
||||
|
||||
|
||||
Internally, FastMCP uses a priority-ordered list of `RouteMap` objects to determine the component type for each route. Each `RouteMap` specifies:
|
||||
|
||||
- **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all)
|
||||
- **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all)
|
||||
- **Tags**: A set of OpenAPI tags that must all be present. An empty set (`{}`) means no tag filtering, so the route matches regardless of its tags.
|
||||
- **MCP type**: What MCP component type to create (the options are `TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, `PROMPT`, or `EXCLUDE` to exclude the route from the MCP server)
|
||||
|
||||
Each OpenAPI route is matched against `RouteMap` objects in order, and the **first match wins** to determine the MCP component type. For example, here are the default route mappings, expressed as `RouteMap` objects in priority order:
|
||||
|
||||
```python
|
||||
from fastmcp.server.openapi import RouteMap, MCPType
|
||||
|
||||
# Default route mappings
|
||||
DEFAULT_ROUTE_MAPPINGS = [
|
||||
# GET with path parameters -> ResourceTemplate
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r".*\{.*\}.*",
|
||||
tags={},
|
||||
mcp_type=MCPType.RESOURCE_TEMPLATE
|
||||
),
|
||||
# GET without path parameters -> Resource
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r".*",
|
||||
tags={},
|
||||
mcp_type=MCPType.RESOURCE
|
||||
),
|
||||
# All other methods -> Tool
|
||||
RouteMap(
|
||||
methods="*",
|
||||
pattern=r".*",
|
||||
tags={},
|
||||
mcp_type=MCPType.TOOL
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
### Custom Route Maps
|
||||
|
||||
You can override the default behavior by providing custom route maps when creating your MCP server. Custom maps are processed **before** the default maps, so they take priority. Each OpenAPI route will be matched against your custom route maps in order, and the first match will determine the MCP component type (or exclusion!).
|
||||
|
||||
```python {1, 6-18}
|
||||
from fastmcp.server.openapi import RouteMap, MCPType
|
||||
|
||||
mcp = FastMCP.from_openapi(
|
||||
openapi_spec=spec,
|
||||
client=api_client,
|
||||
route_maps=[
|
||||
# All GET analytics endpoints should be tools
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r"^/analytics/.*",
|
||||
mcp_type=MCPType.TOOL,
|
||||
),
|
||||
# Exclude all admin endpoints
|
||||
RouteMap(
|
||||
pattern=r"^/admin/.*",
|
||||
mcp_type=MCPType.EXCLUDE,
|
||||
)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### Treat All Routes as Tools
|
||||
|
||||
To treat all routes as tools, use `RouteMap(mcp_type=MCPType.TOOL)` as your only route map. It will match all routes and create a tool for each.
|
||||
|
||||
### Prevent Default Mappings
|
||||
|
||||
To prevent the default mappings from being applied, add a catch-all exclusion routemap at the end of your custom route maps: `RouteMap(mcp_type=MCPType.EXCLUDE)`. Since it will match all routes, it will exclude any that weren't match by your previous rules and short-circuit the default mappings.
|
||||
|
||||
### Tag-Based Routing
|
||||
|
||||
<VersionBadge version="2.5.0" />
|
||||
|
||||
To filter routes by OpenAPI tags, use `RouteMap(tags={...})`. The route must have ALL of the specified tags to be matched. If no tags are specified, all routes will be matched.
|
||||
|
||||
|
||||
## Request Parameter Handling
|
||||
|
||||
FastMCP carefully handles different types of parameters in OpenAPI requests:
|
||||
|
||||
### Query Parameters
|
||||
|
||||
By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues.
|
||||
|
||||
For example, if you call a tool with these parameters:
|
||||
```python
|
||||
await client.call_tool("search_products", {
|
||||
"category": "electronics", # Will be included
|
||||
"min_price": 100, # Will be included
|
||||
"max_price": None, # Will be excluded
|
||||
"brand": "", # Will be excluded
|
||||
})
|
||||
```
|
||||
|
||||
The resulting HTTP request will only include `category=electronics&min_price=100`.
|
||||
|
||||
### Path Parameters
|
||||
|
||||
For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised.
|
||||
|
||||
```python
|
||||
# This will work
|
||||
await client.call_tool("get_product", {"product_id": 123})
|
||||
|
||||
# This will raise ValueError: "Missing required path parameters: {'product_id'}"
|
||||
await client.call_tool("get_product", {"product_id": None})
|
||||
```
|
||||
|
||||
## Authorization
|
||||
|
||||
If your API requires authentication, set headers on the client before creating the MCP server.
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# Create a client with authentication
|
||||
api_client = httpx.AsyncClient(
|
||||
base_url="https://api.example.com",
|
||||
headers={"Authorization": "Bearer YOUR_TOKEN"}
|
||||
)
|
||||
|
||||
# Create an MCP server from your OpenAPI spec
|
||||
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
|
||||
```
|
||||
|
||||
## Timeouts
|
||||
|
||||
You can set a timeout for all requests by providing a `timeout` parameter (in seconds):
|
||||
|
||||
```python
|
||||
mcp = FastMCP.from_openapi(
|
||||
openapi_spec=spec,
|
||||
client=api_client,
|
||||
timeout=30.0 # 30 second timeout
|
||||
)
|
||||
```
|
||||
|
||||
## FastAPI Integration
|
||||
|
||||
<VersionBadge version="2.0.0" />
|
||||
|
||||
FastMCP can automatically convert FastAPI applications into MCP servers by extracting their OpenAPI specifications. A special client will be created that uses an in-memory ASGI transport to avoid network calls to your FastAPI app. Note that the resulting MCP server is *not* a FastAPI app itself, but can be added to one (see [ASGI integration](/deployment/asgi)).
|
||||
|
||||
<Tip>
|
||||
FastMCP does *not* include FastAPI as a dependency; you must install it separately to use this integration.
|
||||
</Tip>
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# A FastAPI app
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/items", tags=["items"])
|
||||
def list_items():
|
||||
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
|
||||
|
||||
@app.get("/items/{item_id}", tags=["items", "detail"])
|
||||
def get_item(item_id: int):
|
||||
return {"id": item_id, "name": f"Item {item_id}"}
|
||||
|
||||
@app.post("/items", tags=["items", "create"])
|
||||
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
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
**Timeout**: You can set a timeout for all API requests:
|
||||
|
||||
```python
|
||||
# Set a 5 second timeout for all requests
|
||||
mcp = FastMCP.from_fastapi(app=app, timeout=5.0)
|
||||
```
|
||||
|
||||
**Route Mapping**: All the route mapping features (including tags) work with FastAPI apps:
|
||||
|
||||
```python
|
||||
from fastmcp.server.openapi import RouteMap, MCPType
|
||||
|
||||
# Use tag-based routing with FastAPI
|
||||
mcp = FastMCP.from_fastapi(
|
||||
app=app,
|
||||
route_maps=[
|
||||
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
|
||||
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
- **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
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ proxy = FastMCP.as_proxy(
|
|||
|
||||
### Configuration-Based Proxies
|
||||
|
||||
<VersionBadge version="2.3.6" />
|
||||
<VersionBadge version="2.4.0" />
|
||||
|
||||
You can create a proxy directly from a configuration dictionary that follows the MCPConfig schema. This is useful for quickly setting up proxies to remote servers without manually configuring each connection detail.
|
||||
|
||||
|
|
|
|||
141
examples/tags_example.py
Normal file
141
examples/tags_example.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
Example demonstrating RouteMap tags functionality.
|
||||
|
||||
This example shows how to use the tags parameter in RouteMap
|
||||
to selectively route OpenAPI endpoints based on their tags.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.openapi import MCPType, RouteMap
|
||||
|
||||
# Create a FastAPI app with tagged endpoints
|
||||
app = FastAPI(title="Tagged API Example")
|
||||
|
||||
|
||||
@app.get("/users", tags=["users", "public"])
|
||||
async def get_users():
|
||||
"""Get all users - public endpoint"""
|
||||
return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
||||
|
||||
|
||||
@app.post("/users", tags=["users", "admin"])
|
||||
async def create_user(name: str):
|
||||
"""Create a user - admin only"""
|
||||
return {"id": 3, "name": name}
|
||||
|
||||
|
||||
@app.get("/admin/stats", tags=["admin", "internal"])
|
||||
async def get_admin_stats():
|
||||
"""Get admin statistics - internal use"""
|
||||
return {"total_users": 100, "active_sessions": 25}
|
||||
|
||||
|
||||
@app.get("/health", tags=["public"])
|
||||
async def health_check():
|
||||
"""Public health check"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/metrics")
|
||||
async def get_metrics():
|
||||
"""Metrics endpoint with no tags"""
|
||||
return {"requests": 1000, "errors": 5}
|
||||
|
||||
|
||||
async def main():
|
||||
"""Demonstrate different tag-based routing strategies."""
|
||||
|
||||
print("=== Example 1: Make admin-tagged routes tools ===")
|
||||
|
||||
# Strategy 1: Convert admin-tagged routes to tools
|
||||
mcp1 = FastMCP.from_fastapi(
|
||||
app=app,
|
||||
route_maps=[
|
||||
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
],
|
||||
)
|
||||
|
||||
tools = await mcp1.get_tools()
|
||||
resources = await mcp1.get_resources()
|
||||
|
||||
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
|
||||
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
|
||||
|
||||
print("\n=== Example 2: Exclude internal routes ===")
|
||||
|
||||
# Strategy 2: Exclude internal routes entirely
|
||||
mcp2 = FastMCP.from_fastapi(
|
||||
app=app,
|
||||
route_maps=[
|
||||
RouteMap(
|
||||
methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}
|
||||
),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
|
||||
],
|
||||
)
|
||||
|
||||
tools = await mcp2.get_tools()
|
||||
resources = await mcp2.get_resources()
|
||||
|
||||
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
|
||||
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
|
||||
|
||||
print("\n=== Example 3: Pattern + Tags combination ===")
|
||||
|
||||
# Strategy 3: Routes matching both pattern AND tags
|
||||
mcp3 = FastMCP.from_fastapi(
|
||||
app=app,
|
||||
route_maps=[
|
||||
# Admin routes under /admin path -> tools
|
||||
RouteMap(
|
||||
methods="*",
|
||||
pattern=r".*/admin/.*",
|
||||
mcp_type=MCPType.TOOL,
|
||||
tags={"admin"},
|
||||
),
|
||||
# Public routes -> tools
|
||||
RouteMap(
|
||||
methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"public"}
|
||||
),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
],
|
||||
)
|
||||
|
||||
tools = await mcp3.get_tools()
|
||||
resources = await mcp3.get_resources()
|
||||
|
||||
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
|
||||
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
|
||||
|
||||
print("\n=== Example 4: Multiple tag AND condition ===")
|
||||
|
||||
# Strategy 4: Routes must have ALL specified tags
|
||||
mcp4 = FastMCP.from_fastapi(
|
||||
app=app,
|
||||
route_maps=[
|
||||
# Routes with BOTH "users" AND "admin" tags -> tools
|
||||
RouteMap(
|
||||
methods="*",
|
||||
pattern=r".*",
|
||||
mcp_type=MCPType.TOOL,
|
||||
tags={"users", "admin"},
|
||||
),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
],
|
||||
)
|
||||
|
||||
tools = await mcp4.get_tools()
|
||||
resources = await mcp4.get_resources()
|
||||
|
||||
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
|
||||
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -210,6 +210,23 @@ class Client:
|
|||
result = await self.session.send_ping()
|
||||
return isinstance(result, mcp.types.EmptyResult)
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
request_id: str | int,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Send a cancellation notification for an in-progress request."""
|
||||
notification = mcp.types.ClientNotification(
|
||||
mcp.types.CancelledNotification(
|
||||
method="notifications/cancelled",
|
||||
params=mcp.types.CancelledNotificationParams(
|
||||
requestId=request_id,
|
||||
reason=reason,
|
||||
),
|
||||
)
|
||||
)
|
||||
await self.session.send_notification(notification)
|
||||
|
||||
async def progress(
|
||||
self,
|
||||
progress_token: str | int,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from mcp.client.sse import sse_client
|
|||
from mcp.client.stdio import stdio_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.client.websocket import websocket_client
|
||||
from mcp.server.fastmcp import FastMCP as FastMCP1Server
|
||||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
from pydantic import AnyUrl
|
||||
from typing_extensions import Unpack
|
||||
|
|
@ -448,15 +449,21 @@ class NpxStdioTransport(StdioTransport):
|
|||
|
||||
|
||||
class FastMCPTransport(ClientTransport):
|
||||
"""
|
||||
Special transport for in-memory connections to an MCP server.
|
||||
"""In-memory transport for FastMCP servers.
|
||||
|
||||
This is particularly useful for testing or when client and server
|
||||
are in the same process.
|
||||
This transport connects directly to a FastMCP server instance in the same
|
||||
Python process. It works with both FastMCP 2.x servers and FastMCP 1.0
|
||||
servers from the low-level MCP SDK. This is particularly useful for unit
|
||||
tests or scenarios where client and server run in the same runtime.
|
||||
"""
|
||||
|
||||
def __init__(self, mcp: FastMCPServer):
|
||||
self.server = mcp # Can be FastMCP or MCPServer
|
||||
def __init__(self, mcp: FastMCPServer | FastMCP1Server):
|
||||
"""Initialize a FastMCPTransport from a FastMCP server instance."""
|
||||
|
||||
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
|
||||
# ``_mcp_server`` attribute pointing to the underlying MCP server
|
||||
# implementation, so we can treat them identically.
|
||||
self.server = mcp
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def connect_session(
|
||||
|
|
@ -528,8 +535,12 @@ class MCPConfigTransport(ClientTransport):
|
|||
config = MCPConfig.from_dict(config)
|
||||
self.config = config
|
||||
|
||||
# if there are no servers, raise an error
|
||||
if len(self.config.mcpServers) == 0:
|
||||
raise ValueError("No MCP servers defined in the config")
|
||||
|
||||
# if there's exactly one server, create a client for that server
|
||||
if len(self.config.mcpServers) == 1:
|
||||
elif len(self.config.mcpServers) == 1:
|
||||
self.transport = list(self.config.mcpServers.values())[0].to_transport()
|
||||
|
||||
# otherwise create a composite client
|
||||
|
|
@ -558,6 +569,7 @@ class MCPConfigTransport(ClientTransport):
|
|||
def infer_transport(
|
||||
transport: ClientTransport
|
||||
| FastMCPServer
|
||||
| FastMCP1Server
|
||||
| AnyUrl
|
||||
| Path
|
||||
| MCPConfig
|
||||
|
|
@ -573,7 +585,7 @@ def infer_transport(
|
|||
|
||||
The function supports these input types:
|
||||
- ClientTransport: Used directly without modification
|
||||
- FastMCPServer: Creates an in-memory FastMCPTransport
|
||||
- FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
|
||||
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
|
||||
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
|
||||
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
|
||||
|
|
@ -610,8 +622,8 @@ def infer_transport(
|
|||
if isinstance(transport, ClientTransport):
|
||||
return transport
|
||||
|
||||
# the transport is a FastMCP server
|
||||
elif isinstance(transport, FastMCPServer):
|
||||
# the transport is a FastMCP server (2.x or 1.0)
|
||||
elif isinstance(transport, FastMCPServer | FastMCP1Server):
|
||||
inferred_transport = FastMCPTransport(mcp=transport)
|
||||
|
||||
# the transport is a path to a script
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ from mcp.shared.context import RequestContext
|
|||
from mcp.types import (
|
||||
CreateMessageResult,
|
||||
ImageContent,
|
||||
ModelHint,
|
||||
ModelPreferences,
|
||||
Root,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
|
|
@ -200,6 +202,7 @@ class Context:
|
|||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
model_preferences: ModelPreferences | str | list[str] | None = None,
|
||||
) -> TextContent | ImageContent:
|
||||
"""
|
||||
Send a sampling request to the client and await the response.
|
||||
|
|
@ -231,6 +234,7 @@ class Context:
|
|||
system_prompt=system_prompt,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
model_preferences=self._parse_model_preferences(model_preferences),
|
||||
)
|
||||
|
||||
return result.content
|
||||
|
|
@ -248,3 +252,45 @@ class Context:
|
|||
)
|
||||
|
||||
return fastmcp.server.dependencies.get_http_request()
|
||||
|
||||
def _parse_model_preferences(
|
||||
self, model_preferences: ModelPreferences | str | list[str] | None
|
||||
) -> ModelPreferences | None:
|
||||
"""
|
||||
Validates and converts user input for model_preferences into a ModelPreferences object.
|
||||
|
||||
Args:
|
||||
model_preferences (ModelPreferences | str | list[str] | None):
|
||||
The model preferences to use. Accepts:
|
||||
- ModelPreferences (returns as-is)
|
||||
- str (single model hint)
|
||||
- list[str] (multiple model hints)
|
||||
- None (no preferences)
|
||||
|
||||
Returns:
|
||||
ModelPreferences | None: The parsed ModelPreferences object, or None if not provided.
|
||||
|
||||
Raises:
|
||||
ValueError: If the input is not a supported type or contains invalid values.
|
||||
"""
|
||||
if model_preferences is None:
|
||||
return None
|
||||
elif isinstance(model_preferences, ModelPreferences):
|
||||
return model_preferences
|
||||
elif isinstance(model_preferences, str):
|
||||
# Single model hint
|
||||
return ModelPreferences(hints=[ModelHint(name=model_preferences)])
|
||||
elif isinstance(model_preferences, list):
|
||||
# List of model hints (strings)
|
||||
if not all(isinstance(h, str) for h in model_preferences):
|
||||
raise ValueError(
|
||||
"All elements of model_preferences list must be"
|
||||
" strings (model name hints)."
|
||||
)
|
||||
return ModelPreferences(
|
||||
hints=[ModelHint(name=h) for h in model_preferences]
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"model_preferences must be one of: ModelPreferences, str, list[str], or None."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@ def create_sse_app(
|
|||
# Add custom routes with lowest precedence
|
||||
if routes:
|
||||
server_routes.extend(routes)
|
||||
server_routes.extend(server._additional_http_routes)
|
||||
|
||||
# Add middleware
|
||||
if middleware:
|
||||
|
|
@ -359,6 +360,7 @@ def create_streamable_http_app(
|
|||
# Add custom routes with lowest precedence
|
||||
if routes:
|
||||
server_routes.extend(routes)
|
||||
server_routes.extend(server._additional_http_routes)
|
||||
|
||||
# Add middleware
|
||||
if middleware:
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ from __future__ import annotations
|
|||
import enum
|
||||
import json
|
||||
import re
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from re import Pattern
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
|
|
@ -33,8 +34,32 @@ logger = get_logger(__name__)
|
|||
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
||||
|
||||
|
||||
class MCPType(enum.Enum):
|
||||
"""Type of FastMCP component to create from a route.
|
||||
|
||||
Enum values:
|
||||
TOOL: Convert the route to a callable Tool
|
||||
RESOURCE: Convert the route to a Resource (typically GET endpoints)
|
||||
RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
|
||||
PROMPT: Convert the route to a Prompt (not yet implemented)
|
||||
EXCLUDE: Exclude the route from being converted to any MCP component
|
||||
IGNORE: Deprecated, use EXCLUDE instead
|
||||
"""
|
||||
|
||||
TOOL = "TOOL"
|
||||
RESOURCE = "RESOURCE"
|
||||
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
|
||||
PROMPT = "PROMPT"
|
||||
EXCLUDE = "EXCLUDE"
|
||||
|
||||
|
||||
# Keep RouteType as an alias to MCPType for backward compatibility
|
||||
class RouteType(enum.Enum):
|
||||
"""Type of FastMCP component to create from a route."""
|
||||
"""
|
||||
Deprecated: Use MCPType instead.
|
||||
|
||||
This enum is kept for backward compatibility and will be removed in a future version.
|
||||
"""
|
||||
|
||||
TOOL = "TOOL"
|
||||
RESOURCE = "RESOURCE"
|
||||
|
|
@ -47,32 +72,71 @@ class RouteType(enum.Enum):
|
|||
class RouteMap:
|
||||
"""Mapping configuration for HTTP routes to FastMCP component types."""
|
||||
|
||||
methods: list[HttpMethod] | Literal["*"]
|
||||
pattern: Pattern[str] | str
|
||||
route_type: RouteType
|
||||
methods: list[HttpMethod] | Literal["*"] = field(default="*")
|
||||
pattern: Pattern[str] | str = field(default=r".*")
|
||||
mcp_type: MCPType | None = field(default=None)
|
||||
route_type: RouteType | MCPType | None = field(default=None)
|
||||
tags: set[str] = field(default_factory=set)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate and process the route map after initialization."""
|
||||
# Handle backward compatibility for route_type, deprecated in 2.5.0
|
||||
if self.mcp_type is None and self.route_type is not None:
|
||||
warnings.warn(
|
||||
"The 'route_type' parameter is deprecated and will be removed in a future version. "
|
||||
"Use 'mcp_type' instead with the appropriate MCPType value.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
if isinstance(self.route_type, RouteType):
|
||||
warnings.warn(
|
||||
"The RouteType class is deprecated and will be removed in a future version. "
|
||||
"Use MCPType instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
# Check for the deprecated IGNORE value
|
||||
if self.route_type == RouteType.IGNORE:
|
||||
warnings.warn(
|
||||
"RouteType.IGNORE is deprecated and will be removed in a future version. "
|
||||
"Use MCPType.EXCLUDE instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Convert from RouteType to MCPType if needed
|
||||
if isinstance(self.route_type, RouteType):
|
||||
route_type_name = self.route_type.name
|
||||
if route_type_name == "IGNORE":
|
||||
route_type_name = "EXCLUDE"
|
||||
self.mcp_type = getattr(MCPType, route_type_name)
|
||||
else:
|
||||
self.mcp_type = self.route_type
|
||||
elif self.mcp_type is None:
|
||||
raise ValueError("`mcp_type` must be provided")
|
||||
|
||||
# Set route_type to match mcp_type for backward compatibility
|
||||
if self.route_type is None:
|
||||
self.route_type = self.mcp_type
|
||||
|
||||
|
||||
# Default route mappings as a list, where order determines priority
|
||||
DEFAULT_ROUTE_MAPPINGS = [
|
||||
# GET requests with path parameters go to ResourceTemplate
|
||||
RouteMap(
|
||||
methods=["GET"], pattern=r".*\{.*\}.*", route_type=RouteType.RESOURCE_TEMPLATE
|
||||
methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE
|
||||
),
|
||||
# GET requests without path parameters go to Resource
|
||||
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
# All other HTTP methods go to Tool
|
||||
RouteMap(
|
||||
methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
pattern=r".*",
|
||||
route_type=RouteType.TOOL,
|
||||
),
|
||||
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
|
||||
]
|
||||
|
||||
|
||||
def _determine_route_type(
|
||||
route: openapi.HTTPRoute,
|
||||
mappings: list[RouteMap],
|
||||
) -> RouteType:
|
||||
) -> MCPType:
|
||||
"""
|
||||
Determines the FastMCP component type based on the route and mappings.
|
||||
|
||||
|
|
@ -81,7 +145,7 @@ def _determine_route_type(
|
|||
mappings: List of RouteMap objects in priority order
|
||||
|
||||
Returns:
|
||||
RouteType for this route
|
||||
MCPType for this route
|
||||
"""
|
||||
# Check mappings in priority order (first match wins)
|
||||
for route_map in mappings:
|
||||
|
|
@ -94,20 +158,24 @@ def _determine_route_type(
|
|||
pattern_matches = re.search(route_map.pattern, route.path)
|
||||
|
||||
if pattern_matches:
|
||||
# Check if tags match (if specified)
|
||||
# If route_map.tags is empty, tags are not matched
|
||||
# If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
|
||||
if route_map.tags:
|
||||
route_tags_set = set(route.tags or [])
|
||||
if not route_map.tags.issubset(route_tags_set):
|
||||
# Tags don't match, continue to next mapping
|
||||
continue
|
||||
|
||||
# We know mcp_type is not None here due to post_init validation
|
||||
assert route_map.mcp_type is not None
|
||||
logger.debug(
|
||||
f"Route {route.method} {route.path} matched mapping to {route_map.route_type.name}"
|
||||
f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
|
||||
)
|
||||
return route_map.route_type
|
||||
return route_map.mcp_type
|
||||
|
||||
# Default fallback
|
||||
return RouteType.TOOL
|
||||
|
||||
|
||||
# Placeholder function to provide function metadata
|
||||
async def _openapi_passthrough(*args, **kwargs):
|
||||
"""Placeholder function for OpenAPI endpoints."""
|
||||
# This is kept for metadata generation purposes
|
||||
pass
|
||||
return MCPType.TOOL
|
||||
|
||||
|
||||
class OpenAPITool(Tool):
|
||||
|
|
@ -555,13 +623,13 @@ class FastMCPOpenAPI(FastMCP):
|
|||
RouteMap(
|
||||
methods=["GET", "POST", "PATCH"],
|
||||
pattern=r".*/users/.*",
|
||||
route_type=RouteType.RESOURCE_TEMPLATE
|
||||
mcp_type=MCPType.RESOURCE_TEMPLATE
|
||||
),
|
||||
# Map all analytics endpoints to Tool
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r".*/analytics/.*",
|
||||
route_type=RouteType.TOOL
|
||||
mcp_type=MCPType.TOOL
|
||||
),
|
||||
]
|
||||
|
||||
|
|
@ -599,6 +667,10 @@ class FastMCPOpenAPI(FastMCP):
|
|||
|
||||
self._client = client
|
||||
self._timeout = timeout
|
||||
|
||||
# Keep track of names to detect collisions
|
||||
self._used_names = {"tools": set(), "resources": set(), "templates": set()}
|
||||
|
||||
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
||||
|
||||
# Process routes
|
||||
|
|
@ -607,34 +679,99 @@ class FastMCPOpenAPI(FastMCP):
|
|||
# Determine route type based on mappings or default rules
|
||||
route_type = _determine_route_type(route, route_maps)
|
||||
|
||||
# Use operation_id if available, otherwise generate a name
|
||||
operation_id = route.operation_id
|
||||
if not operation_id:
|
||||
# Generate operation ID from method and path
|
||||
path_parts = route.path.strip("/").split("/")
|
||||
path_name = "_".join(p for p in path_parts if not p.startswith("{"))
|
||||
operation_id = f"{route.method.lower()}_{path_name}"
|
||||
# Generate a default name from the route
|
||||
component_name = self._generate_default_name(route, route_type)
|
||||
|
||||
if route_type == RouteType.TOOL:
|
||||
self._create_openapi_tool(route, operation_id)
|
||||
elif route_type == RouteType.RESOURCE:
|
||||
self._create_openapi_resource(route, operation_id)
|
||||
elif route_type == RouteType.RESOURCE_TEMPLATE:
|
||||
self._create_openapi_template(route, operation_id)
|
||||
elif route_type == RouteType.PROMPT:
|
||||
if route_type == MCPType.TOOL:
|
||||
self._create_openapi_tool(route, component_name)
|
||||
elif route_type == MCPType.RESOURCE:
|
||||
self._create_openapi_resource(route, component_name)
|
||||
elif route_type == MCPType.RESOURCE_TEMPLATE:
|
||||
self._create_openapi_template(route, component_name)
|
||||
elif route_type == MCPType.PROMPT:
|
||||
# Not implemented yet
|
||||
logger.warning(
|
||||
f"PROMPT route type not implemented: {route.method} {route.path}"
|
||||
)
|
||||
elif route_type == RouteType.IGNORE:
|
||||
logger.info(f"Ignoring route: {route.method} {route.path}")
|
||||
elif route_type == MCPType.EXCLUDE:
|
||||
logger.info(f"Excluding route: {route.method} {route.path}")
|
||||
|
||||
logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
|
||||
|
||||
def _create_openapi_tool(self, route: openapi.HTTPRoute, operation_id: str):
|
||||
def _generate_default_name(
|
||||
self, route: openapi.HTTPRoute, mcp_type: MCPType
|
||||
) -> str:
|
||||
"""Generate a default name from the route path."""
|
||||
# First check for OpenAPI operationId which takes precedence
|
||||
if route.operation_id:
|
||||
return route.operation_id
|
||||
|
||||
# For path-based naming, clean up the path
|
||||
path_parts = route.path.strip("/").split("/")
|
||||
|
||||
# Remove path parameters (parts with {})
|
||||
clean_parts = []
|
||||
for part in path_parts:
|
||||
if part.startswith("{") and part.endswith("}"):
|
||||
# For templates, include parameter name without braces
|
||||
if mcp_type == MCPType.RESOURCE_TEMPLATE:
|
||||
param_name = part[1:-1] # Remove braces
|
||||
clean_parts.append(param_name)
|
||||
else:
|
||||
clean_parts.append(part)
|
||||
|
||||
# Join the parts
|
||||
resource_name = "_".join(clean_parts)
|
||||
|
||||
# For tools, might be useful to keep the method for clarity on what it does
|
||||
if mcp_type == MCPType.TOOL:
|
||||
# Only include method if it helps distinguish (POST, PUT, PATCH, DELETE)
|
||||
# For GET we don't need the method as it's implied for resources
|
||||
if route.method != "GET":
|
||||
resource_name = f"{route.method.lower()}_{resource_name}"
|
||||
|
||||
return resource_name
|
||||
|
||||
def _get_unique_name(
|
||||
self, name: str, component_type: Literal["tools", "resources", "templates"]
|
||||
) -> str:
|
||||
"""
|
||||
Ensure the name is unique within its component type by appending numbers if needed.
|
||||
|
||||
Args:
|
||||
name: The proposed name
|
||||
component_type: The type of component ("tools", "resources", or "templates")
|
||||
|
||||
Returns:
|
||||
str: A unique name for the component
|
||||
"""
|
||||
# Check if the name is already used
|
||||
if name not in self._used_names[component_type]:
|
||||
self._used_names[component_type].add(name)
|
||||
return name
|
||||
|
||||
# Find the next available number suffix
|
||||
counter = 2
|
||||
while f"{name}_{counter}" in self._used_names[component_type]:
|
||||
counter += 1
|
||||
|
||||
# Create the new name
|
||||
new_name = f"{name}_{counter}"
|
||||
logger.debug(
|
||||
f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
|
||||
f"Using '{new_name}' instead."
|
||||
)
|
||||
|
||||
self._used_names[component_type].add(new_name)
|
||||
return new_name
|
||||
|
||||
def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
|
||||
"""Creates and registers an OpenAPITool with enhanced description."""
|
||||
combined_schema = _combine_schemas(route)
|
||||
tool_name = operation_id
|
||||
|
||||
# Get a unique tool name
|
||||
tool_name = self._get_unique_name(name, "tools")
|
||||
|
||||
base_description = (
|
||||
route.description
|
||||
or route.summary
|
||||
|
|
@ -664,9 +801,11 @@ class FastMCPOpenAPI(FastMCP):
|
|||
f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
||||
)
|
||||
|
||||
def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str):
|
||||
def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
|
||||
"""Creates and registers an OpenAPIResource with enhanced description."""
|
||||
resource_name = operation_id
|
||||
# Get a unique resource name
|
||||
resource_name = self._get_unique_name(name, "resources")
|
||||
|
||||
resource_uri = f"resource://openapi/{resource_name}"
|
||||
base_description = (
|
||||
route.description or route.summary or f"Represents {route.path}"
|
||||
|
|
@ -695,9 +834,11 @@ class FastMCPOpenAPI(FastMCP):
|
|||
f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
||||
)
|
||||
|
||||
def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str):
|
||||
def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
|
||||
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
|
||||
template_name = operation_id
|
||||
# Get a unique template name
|
||||
template_name = self._get_unique_name(name, "templates")
|
||||
|
||||
path_params = [p.name for p in route.parameters if p.location == "path"]
|
||||
path_params.sort() # Sort for consistent URIs
|
||||
|
||||
|
|
|
|||
|
|
@ -879,7 +879,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
auth_server_provider=self._auth_server_provider,
|
||||
auth_settings=self.settings.auth,
|
||||
debug=self.settings.debug,
|
||||
routes=self._additional_http_routes,
|
||||
middleware=middleware,
|
||||
)
|
||||
|
||||
|
|
@ -930,7 +929,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
json_response=self.settings.json_response,
|
||||
stateless_http=self.settings.stateless_http,
|
||||
debug=self.settings.debug,
|
||||
routes=self._additional_http_routes,
|
||||
middleware=middleware,
|
||||
)
|
||||
elif transport == "sse":
|
||||
|
|
@ -941,7 +939,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
auth_server_provider=self._auth_server_provider,
|
||||
auth_settings=self.settings.auth,
|
||||
debug=self.settings.debug,
|
||||
routes=self._additional_http_routes,
|
||||
middleware=middleware,
|
||||
)
|
||||
|
||||
|
|
@ -1026,7 +1023,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
from fastmcp.server.proxy import FastMCPProxy
|
||||
|
||||
if tool_separator is not None:
|
||||
# Deprecated since 2.3.6
|
||||
# Deprecated since 2.4.0
|
||||
warnings.warn(
|
||||
"The tool_separator parameter is deprecated and will be removed in a future version. "
|
||||
"Tools are now prefixed using 'prefix_toolname' format.",
|
||||
|
|
@ -1035,7 +1032,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
)
|
||||
|
||||
if resource_separator is not None:
|
||||
# Deprecated since 2.3.6
|
||||
# Deprecated since 2.4.0
|
||||
warnings.warn(
|
||||
"The resource_separator parameter is deprecated and ignored. "
|
||||
"Resource prefixes are now added using the protocol://prefix/path format.",
|
||||
|
|
@ -1044,7 +1041,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
)
|
||||
|
||||
if prompt_separator is not None:
|
||||
# Deprecated since 2.3.6
|
||||
# Deprecated since 2.4.0
|
||||
warnings.warn(
|
||||
"The prompt_separator parameter is deprecated and will be removed in a future version. "
|
||||
"Prompts are now prefixed using 'prefix_promptname' format.",
|
||||
|
|
@ -1111,7 +1108,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
prompt_separator: Deprecated. Separator for prompt names.
|
||||
"""
|
||||
if tool_separator is not None:
|
||||
# Deprecated since 2.3.6
|
||||
# Deprecated since 2.4.0
|
||||
warnings.warn(
|
||||
"The tool_separator parameter is deprecated and will be removed in a future version. "
|
||||
"Tools are now prefixed using 'prefix_toolname' format.",
|
||||
|
|
@ -1120,7 +1117,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
)
|
||||
|
||||
if resource_separator is not None:
|
||||
# Deprecated since 2.3.6
|
||||
# Deprecated since 2.4.0
|
||||
warnings.warn(
|
||||
"The resource_separator parameter is deprecated and ignored. "
|
||||
"Resource prefixes are now added using the protocol://prefix/path format.",
|
||||
|
|
@ -1129,7 +1126,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
)
|
||||
|
||||
if prompt_separator is not None:
|
||||
# Deprecated since 2.3.6
|
||||
# Deprecated since 2.4.0
|
||||
warnings.warn(
|
||||
"The prompt_separator parameter is deprecated and will be removed in a future version. "
|
||||
"Prompts are now prefixed using 'prefix_promptname' format.",
|
||||
|
|
@ -1175,19 +1172,22 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
"""
|
||||
Create a FastMCP server from an OpenAPI specification.
|
||||
"""
|
||||
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
|
||||
from .openapi import FastMCPOpenAPI, MCPType, RouteMap
|
||||
|
||||
# Deprecated since 2.5.0
|
||||
if all_routes_as_tools:
|
||||
warnings.warn(
|
||||
"The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
|
||||
'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if all_routes_as_tools and route_maps:
|
||||
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
|
||||
|
||||
elif all_routes_as_tools:
|
||||
route_maps = [
|
||||
RouteMap(
|
||||
methods="*",
|
||||
pattern=r".*",
|
||||
route_type=RouteType.TOOL,
|
||||
)
|
||||
]
|
||||
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
|
||||
|
||||
return FastMCPOpenAPI(
|
||||
openapi_spec=openapi_spec,
|
||||
|
|
@ -1209,15 +1209,22 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
Create a FastMCP server from a FastAPI application.
|
||||
"""
|
||||
|
||||
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
|
||||
from .openapi import FastMCPOpenAPI, MCPType, RouteMap
|
||||
|
||||
# Deprecated since 2.5.0
|
||||
if all_routes_as_tools:
|
||||
warnings.warn(
|
||||
"The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
|
||||
'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if all_routes_as_tools and route_maps:
|
||||
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
|
||||
|
||||
elif all_routes_as_tools:
|
||||
route_maps = [
|
||||
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
|
||||
]
|
||||
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
|
||||
|
|
|
|||
|
|
@ -32,11 +32,12 @@ def infer_transport_type_from_url(
|
|||
return "streamable-http"
|
||||
|
||||
|
||||
class LocalMCPServer(BaseModel):
|
||||
class StdioMCPServer(BaseModel):
|
||||
command: str
|
||||
args: list[str] = Field(default_factory=list)
|
||||
env: dict[str, Any] = Field(default_factory=dict)
|
||||
cwd: str | None = None
|
||||
transport: Literal["stdio"] = "stdio"
|
||||
|
||||
def to_transport(self) -> StdioTransport:
|
||||
from fastmcp.client.transports import StdioTransport
|
||||
|
|
@ -51,8 +52,8 @@ class LocalMCPServer(BaseModel):
|
|||
|
||||
class RemoteMCPServer(BaseModel):
|
||||
url: str
|
||||
transport: Literal["streamable-http", "sse", "http"] | None = None
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
transport: Literal["streamable-http", "sse", "http"] | None = None
|
||||
|
||||
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
||||
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
||||
|
|
@ -69,7 +70,7 @@ class RemoteMCPServer(BaseModel):
|
|||
|
||||
|
||||
class MCPConfig(BaseModel):
|
||||
mcpServers: dict[str, LocalMCPServer | RemoteMCPServer]
|
||||
mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
|
||||
|
|
|
|||
|
|
@ -728,7 +728,19 @@ class TestInferTransport:
|
|||
assert transport.transport.command == "echo"
|
||||
assert transport.transport.args == ["hello"]
|
||||
|
||||
def test_infer_composite_client(config):
|
||||
def test_config_with_no_servers(self):
|
||||
"""Test that an empty MCPConfig raises a ValueError."""
|
||||
config = {"mcpServers": {}}
|
||||
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
|
||||
infer_transport(config)
|
||||
|
||||
def test_mcpconfigtransport_with_no_servers(self):
|
||||
"""Test that MCPConfigTransport raises a ValueError when initialized with an empty config."""
|
||||
config = {"mcpServers": {}}
|
||||
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
|
||||
MCPConfigTransport(config=config)
|
||||
|
||||
def test_infer_composite_client(self):
|
||||
config = {
|
||||
"mcpServers": {
|
||||
"local": {
|
||||
|
|
@ -744,4 +756,17 @@ class TestInferTransport:
|
|||
transport = infer_transport(config)
|
||||
assert isinstance(transport, MCPConfigTransport)
|
||||
assert isinstance(transport.transport, FastMCPTransport)
|
||||
assert len(transport.transport.server._mounted_servers) == 2
|
||||
assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2
|
||||
|
||||
def test_infer_fastmcp_server(self, fastmcp_server):
|
||||
"""FastMCP server instances should infer to FastMCPTransport."""
|
||||
transport = infer_transport(fastmcp_server)
|
||||
assert isinstance(transport, FastMCPTransport)
|
||||
|
||||
def test_infer_fastmcp_v1_server(self):
|
||||
"""FastMCP 1.0 server instances should infer to FastMCPTransport."""
|
||||
from mcp.server.fastmcp import FastMCP as FastMCP1
|
||||
|
||||
server = FastMCP1()
|
||||
transport = infer_transport(server)
|
||||
assert isinstance(transport, FastMCPTransport)
|
||||
|
|
|
|||
113
tests/deprecated/test_route_type_ignore.py
Normal file
113
tests/deprecated/test_route_type_ignore.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Tests for the deprecated RouteType.IGNORE."""
|
||||
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from fastmcp.server.openapi import (
|
||||
FastMCPOpenAPI,
|
||||
MCPType,
|
||||
RouteMap,
|
||||
RouteType,
|
||||
)
|
||||
|
||||
|
||||
def test_route_type_ignore_deprecation_warning():
|
||||
"""Test that using RouteType.IGNORE emits a deprecation warning."""
|
||||
# Let's manually capture the warnings
|
||||
|
||||
# Record all warnings
|
||||
with warnings.catch_warnings(record=True) as recorded:
|
||||
# Make sure warnings are always triggered
|
||||
warnings.simplefilter("always")
|
||||
|
||||
# Create a RouteMap with RouteType.IGNORE
|
||||
route_map = RouteMap(
|
||||
methods=["GET"], pattern=r"^/analytics$", route_type=RouteType.IGNORE
|
||||
)
|
||||
|
||||
# Check for the expected warnings in the recorded warnings
|
||||
route_type_warning = False
|
||||
ignore_warning = False
|
||||
|
||||
for w in recorded:
|
||||
if issubclass(w.category, DeprecationWarning):
|
||||
message = str(w.message)
|
||||
if "route_type' parameter is deprecated" in message:
|
||||
route_type_warning = True
|
||||
if "RouteType.IGNORE is deprecated" in message:
|
||||
ignore_warning = True
|
||||
|
||||
# Make sure both warnings were triggered
|
||||
assert route_type_warning, "Missing 'route_type' deprecation warning"
|
||||
assert ignore_warning, "Missing 'RouteType.IGNORE' deprecation warning"
|
||||
|
||||
# Verify that RouteType.IGNORE was converted to MCPType.EXCLUDE
|
||||
assert route_map.mcp_type == MCPType.EXCLUDE
|
||||
|
||||
|
||||
class TestRouteTypeIgnoreDeprecation:
|
||||
"""Test class for the deprecated RouteType.IGNORE."""
|
||||
|
||||
@pytest.fixture
|
||||
def basic_openapi_spec(self) -> dict:
|
||||
"""Create a simple OpenAPI spec for testing."""
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/items": {
|
||||
"get": {
|
||||
"operationId": "get_items",
|
||||
"summary": "Get all items",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
}
|
||||
},
|
||||
"/analytics": {
|
||||
"get": {
|
||||
"operationId": "get_analytics",
|
||||
"summary": "Get analytics data",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_client(self) -> httpx.AsyncClient:
|
||||
"""Create a mock client for testing."""
|
||||
|
||||
async def _responder(request):
|
||||
return httpx.Response(200, json={"success": True})
|
||||
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(_responder))
|
||||
|
||||
async def test_route_type_ignore_conversion(self, basic_openapi_spec, mock_client):
|
||||
"""Test that routes with RouteType.IGNORE are properly excluded."""
|
||||
# Capture the deprecation warning without checking the exact message
|
||||
with pytest.warns(DeprecationWarning):
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_client,
|
||||
route_maps=[
|
||||
# Use the deprecated RouteType.IGNORE
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r"^/analytics$",
|
||||
route_type=RouteType.IGNORE,
|
||||
),
|
||||
# Make everything else a resource
|
||||
RouteMap(
|
||||
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Check that the analytics route was excluded (converted from IGNORE to EXCLUDE)
|
||||
resources = await server.get_resources()
|
||||
resource_uris = [str(r.uri) for r in resources.values()]
|
||||
|
||||
# Analytics should be excluded
|
||||
assert "resource://openapi/get_items" in resource_uris
|
||||
assert "resource://openapi/get_analytics" not in resource_uris
|
||||
105
tests/server/http/test_custom_routes.py
Normal file
105
tests/server/http/test_custom_routes.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import pytest
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.http import create_sse_app, create_streamable_http_app
|
||||
|
||||
|
||||
class TestCustomRoutes:
|
||||
@pytest.fixture
|
||||
def server_with_custom_route(self):
|
||||
"""Create a FastMCP server with a custom route."""
|
||||
server = FastMCP()
|
||||
|
||||
@server.custom_route("/custom-route", methods=["GET"])
|
||||
async def custom_route(request: Request):
|
||||
return JSONResponse({"message": "custom route"})
|
||||
|
||||
return server
|
||||
|
||||
def test_custom_routes_via_server_http_app(self, server_with_custom_route):
|
||||
"""Test that custom routes are included when using server.http_app()."""
|
||||
# Get the app via server.http_app()
|
||||
app = server_with_custom_route.http_app()
|
||||
|
||||
# Verify that the custom route is included
|
||||
custom_route_found = False
|
||||
for route in app.routes:
|
||||
if isinstance(route, Route) and route.path == "/custom-route":
|
||||
custom_route_found = True
|
||||
break
|
||||
|
||||
assert custom_route_found, "Custom route was not found in app routes"
|
||||
|
||||
def test_custom_routes_via_streamable_http_app_direct(
|
||||
self, server_with_custom_route
|
||||
):
|
||||
"""Test that custom routes are included when using create_streamable_http_app directly."""
|
||||
# Create the app by calling the constructor function directly
|
||||
app = create_streamable_http_app(
|
||||
server=server_with_custom_route, streamable_http_path="/api"
|
||||
)
|
||||
|
||||
# Verify that the custom route is included
|
||||
custom_route_found = False
|
||||
for route in app.routes:
|
||||
if isinstance(route, Route) and route.path == "/custom-route":
|
||||
custom_route_found = True
|
||||
break
|
||||
|
||||
assert custom_route_found, "Custom route was not found in app routes"
|
||||
|
||||
def test_custom_routes_via_sse_app_direct(self, server_with_custom_route):
|
||||
"""Test that custom routes are included when using create_sse_app directly."""
|
||||
# Create the app by calling the constructor function directly
|
||||
app = create_sse_app(
|
||||
server=server_with_custom_route, message_path="/message", sse_path="/sse"
|
||||
)
|
||||
|
||||
# Verify that the custom route is included
|
||||
custom_route_found = False
|
||||
for route in app.routes:
|
||||
if isinstance(route, Route) and route.path == "/custom-route":
|
||||
custom_route_found = True
|
||||
break
|
||||
|
||||
assert custom_route_found, "Custom route was not found in app routes"
|
||||
|
||||
def test_multiple_custom_routes(
|
||||
self,
|
||||
):
|
||||
"""Test that multiple custom routes are included in both methods."""
|
||||
server = FastMCP()
|
||||
|
||||
custom_paths = ["/route1", "/route2", "/route3"]
|
||||
|
||||
# Add multiple custom routes
|
||||
for path in custom_paths:
|
||||
|
||||
@server.custom_route(path, methods=["GET"])
|
||||
async def custom_route(request: Request):
|
||||
return JSONResponse({"message": f"route {path}"})
|
||||
|
||||
# Test with server.http_app()
|
||||
app1 = server.http_app()
|
||||
|
||||
# Test with direct constructor call
|
||||
app2 = create_streamable_http_app(server=server, streamable_http_path="/api")
|
||||
|
||||
# Check all routes are in both apps
|
||||
for path in custom_paths:
|
||||
# Check in app1
|
||||
route_in_app1 = any(
|
||||
isinstance(route, Route) and route.path == path for route in app1.routes
|
||||
)
|
||||
assert route_in_app1, f"Route {path} not found in server.http_app()"
|
||||
|
||||
# Check in app2
|
||||
route_in_app2 = any(
|
||||
isinstance(route, Route) and route.path == path for route in app2.routes
|
||||
)
|
||||
assert route_in_app2, (
|
||||
f"Route {path} not found in create_streamable_http_app()"
|
||||
)
|
||||
|
|
@ -18,11 +18,11 @@ from fastmcp.client import Client
|
|||
from fastmcp.exceptions import ToolError
|
||||
from fastmcp.server.openapi import (
|
||||
FastMCPOpenAPI,
|
||||
MCPType,
|
||||
OpenAPIResource,
|
||||
OpenAPIResourceTemplate,
|
||||
OpenAPITool,
|
||||
RouteMap,
|
||||
RouteType,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -304,7 +304,7 @@ class TestTools:
|
|||
openapi_spec=openapi_spec,
|
||||
client=api_client,
|
||||
route_maps=[
|
||||
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)
|
||||
],
|
||||
)
|
||||
async with Client(mcp_server) as client:
|
||||
|
|
@ -956,9 +956,7 @@ async def test_empty_query_parameters_not_sent(
|
|||
mcp_server = FastMCPOpenAPI(
|
||||
openapi_spec=openapi_spec,
|
||||
client=api_client,
|
||||
route_maps=[
|
||||
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
|
||||
],
|
||||
route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
|
||||
)
|
||||
|
||||
# Call the search tool with mixed parameter values
|
||||
|
|
@ -1499,17 +1497,15 @@ class TestFastAPIDescriptionPropagation:
|
|||
# Create custom route mappings
|
||||
route_maps = [
|
||||
# Map GET /items to Resource
|
||||
RouteMap(
|
||||
methods=["GET"], pattern=r"^/items$", route_type=RouteType.RESOURCE
|
||||
),
|
||||
RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE),
|
||||
# Map GET /items/{item_id} to ResourceTemplate
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r"^/items/\{.*\}$",
|
||||
route_type=RouteType.RESOURCE_TEMPLATE,
|
||||
mcp_type=MCPType.RESOURCE_TEMPLATE,
|
||||
),
|
||||
# Map POST /items to Tool
|
||||
RouteMap(methods=["POST"], pattern=r"^/items$", route_type=RouteType.TOOL),
|
||||
RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL),
|
||||
]
|
||||
|
||||
# Create FastMCP server with the OpenAPI spec and custom route mappings
|
||||
|
|
@ -1918,7 +1914,7 @@ class TestRouteMapWildcard:
|
|||
):
|
||||
"""Test that a RouteMap with methods='*' matches all HTTP methods."""
|
||||
# Create a single route map with wildcard method
|
||||
route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)]
|
||||
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
|
|
@ -1930,225 +1926,223 @@ class TestRouteMapWildcard:
|
|||
tools = mcp._tool_manager.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
|
||||
# Check that all operations were mapped as tools
|
||||
# Check that all 4 operations became tools
|
||||
expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
|
||||
assert tool_names == expected_tools
|
||||
|
||||
# No resources or templates should be created
|
||||
resources = mcp._resource_manager.get_resources()
|
||||
templates = mcp._resource_manager.get_templates()
|
||||
assert len(resources) == 0
|
||||
assert len(templates) == 0
|
||||
|
||||
async def test_priority_specific_over_wildcard(
|
||||
self, basic_openapi_spec, mock_basic_client
|
||||
):
|
||||
"""Test that specific method maps take priority over wildcard."""
|
||||
# Create route maps with specific method first, then wildcard
|
||||
route_maps = [
|
||||
# GET operations should be mapped to resources
|
||||
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
|
||||
# All other operations should be mapped to tools
|
||||
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
|
||||
]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_basic_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# Check GET operations went to resources
|
||||
resources = mcp._resource_manager.get_resources()
|
||||
resource_names = {r.name for r in resources.values()}
|
||||
assert "getUsers" in resource_names
|
||||
assert "getPosts" in resource_names
|
||||
assert len(resources) == 2
|
||||
|
||||
# Check other operations went to tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "createUser" in tool_names
|
||||
assert "createPost" in tool_names
|
||||
assert len(tools) == 2
|
||||
|
||||
async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client):
|
||||
"""Test that when wildcard is first, it matches everything."""
|
||||
# Create route maps with wildcard first, then specific methods
|
||||
route_maps = [
|
||||
# Wildcard first matches everything
|
||||
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
|
||||
# This should never be reached
|
||||
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
|
||||
]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_basic_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# All operations should be tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
assert len(tools) == 4
|
||||
|
||||
# No resources should be created
|
||||
resources = mcp._resource_manager.get_resources()
|
||||
assert len(resources) == 0
|
||||
|
||||
async def test_wildcard_with_specific_paths(
|
||||
self, basic_openapi_spec, mock_basic_client
|
||||
):
|
||||
"""Test wildcard methods combined with specific path patterns."""
|
||||
route_maps = [
|
||||
# All methods on /users path -> Resources
|
||||
RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE),
|
||||
# All methods on /posts path -> Tools
|
||||
RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL),
|
||||
]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_basic_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# Check /users operations went to resources
|
||||
resources = mcp._resource_manager.get_resources()
|
||||
resource_names = {r.name for r in resources.values()}
|
||||
assert "getUsers" in resource_names
|
||||
assert "createUser" in resource_names
|
||||
assert len(resources) == 2
|
||||
|
||||
# Check /posts operations went to tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
assert "getPosts" in tool_names
|
||||
assert "createPost" in tool_names
|
||||
assert len(tools) == 2
|
||||
|
||||
|
||||
class TestAllRoutesAsTools:
|
||||
"""Tests for the all_routes_as_tools parameter in FastMCP class methods."""
|
||||
class TestRouteMapTags:
|
||||
"""Tests for RouteMap tags functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_api_spec(self) -> dict:
|
||||
"""A simple OpenAPI spec with both GET and POST methods."""
|
||||
def tagged_openapi_spec(self) -> dict:
|
||||
"""Create an OpenAPI spec with various tags for testing."""
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"info": {"title": "Tagged API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/items": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "getItems",
|
||||
"operationId": "getUsers",
|
||||
"tags": ["users", "public"],
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createItem",
|
||||
"operationId": "createUser",
|
||||
"tags": ["users", "admin"],
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
},
|
||||
},
|
||||
"/admin/stats": {
|
||||
"get": {
|
||||
"operationId": "getAdminStats",
|
||||
"tags": ["admin", "internal"],
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"operationId": "getHealth",
|
||||
"tags": ["public"],
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
}
|
||||
},
|
||||
"/metrics": {
|
||||
"get": {
|
||||
"operationId": "getMetrics",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_client(self) -> httpx.AsyncClient:
|
||||
"""Simple mock client for testing."""
|
||||
"""Create a simple mock client."""
|
||||
|
||||
async def _responder(request):
|
||||
return httpx.Response(200, json={"result": "ok"})
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
transport = httpx.MockTransport(_responder)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client):
|
||||
"""Test FastMCP.from_openapi with all_routes_as_tools=True."""
|
||||
# Create server with all routes as tools
|
||||
server = FastMCP.from_openapi(
|
||||
openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True
|
||||
async def test_tags_as_tools(self, tagged_openapi_spec, mock_client):
|
||||
"""Test that routes with specific tags are converted to tools."""
|
||||
# Convert routes with "admin" tag to tools
|
||||
route_maps = [
|
||||
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
]
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=tagged_openapi_spec,
|
||||
client=mock_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# All operations (GET and POST) should be mapped to tools
|
||||
tools = server._tool_manager.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
# Check that admin-tagged routes are tools
|
||||
tools = server._tool_manager.get_tools()
|
||||
tool_names = {t.name for t in tools.values()}
|
||||
|
||||
assert "getItems" in tool_names
|
||||
assert "createItem" in tool_names
|
||||
assert len(tools) == 2
|
||||
|
||||
# No resources or templates should be created
|
||||
resources = server._resource_manager.get_resources()
|
||||
templates = server._resource_manager.get_templates()
|
||||
assert len(resources) == 0
|
||||
assert len(templates) == 0
|
||||
resource_names = {r.name for r in resources.values()}
|
||||
|
||||
async def test_from_openapi_all_routes_as_tools_conflicting_args(
|
||||
self, simple_api_spec, mock_client
|
||||
):
|
||||
"""Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided."""
|
||||
# Try to create server with conflicting args
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
|
||||
):
|
||||
FastMCP.from_openapi(
|
||||
openapi_spec=simple_api_spec,
|
||||
client=mock_client,
|
||||
all_routes_as_tools=True,
|
||||
route_maps=[
|
||||
RouteMap(
|
||||
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
|
||||
)
|
||||
],
|
||||
)
|
||||
# Routes with "admin" tag should be tools
|
||||
assert "createUser" in tool_names
|
||||
assert "getAdminStats" in tool_names
|
||||
|
||||
async def test_from_fastapi_all_routes_as_tools(self):
|
||||
"""Test FastMCP.from_fastapi with all_routes_as_tools=True."""
|
||||
# Create a simple FastAPI app
|
||||
app = FastAPI(title="Test FastAPI")
|
||||
# Routes without "admin" tag should be resources
|
||||
assert "getUsers" in resource_names
|
||||
assert "getHealth" in resource_names
|
||||
assert "getMetrics" in resource_names
|
||||
|
||||
@app.get("/items")
|
||||
async def get_items():
|
||||
return [{"id": 1, "name": "Item 1"}]
|
||||
async def test_exclude_tags(self, tagged_openapi_spec, mock_client):
|
||||
"""Test that routes with specific tags are excluded."""
|
||||
# Exclude routes with "internal" tag
|
||||
route_maps = [
|
||||
RouteMap(
|
||||
methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}
|
||||
),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
|
||||
]
|
||||
|
||||
@app.post("/items")
|
||||
async def create_item(item: dict):
|
||||
return {"id": 2, **item}
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=tagged_openapi_spec,
|
||||
client=mock_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# Create server with all routes as tools
|
||||
server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True)
|
||||
|
||||
# Both GET and POST operations should be mapped to tools
|
||||
tools = server._tool_manager.list_tools()
|
||||
|
||||
# Get tool names from the generated operation IDs
|
||||
tool_names = {t.name for t in tools}
|
||||
|
||||
# Check that both routes were mapped to tools
|
||||
# The exact names depend on FastAPI's operation ID generation
|
||||
assert len(tools) == 2
|
||||
assert any("get" in name.lower() for name in tool_names)
|
||||
assert any("post" in name.lower() for name in tool_names)
|
||||
|
||||
# No resources or templates should be created
|
||||
# Check that internal-tagged routes are excluded
|
||||
resources = server._resource_manager.get_resources()
|
||||
templates = server._resource_manager.get_templates()
|
||||
assert len(resources) == 0
|
||||
assert len(templates) == 0
|
||||
resource_names = {r.name for r in resources.values()}
|
||||
|
||||
async def test_from_fastapi_all_routes_as_tools_conflicting_args(self):
|
||||
"""Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided."""
|
||||
app = FastAPI(title="Test FastAPI")
|
||||
tools = server._tool_manager.get_tools()
|
||||
tool_names = {t.name for t in tools.values()}
|
||||
|
||||
# Try to create server with conflicting args
|
||||
with pytest.raises(
|
||||
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
|
||||
):
|
||||
FastMCP.from_fastapi(
|
||||
app=app,
|
||||
all_routes_as_tools=True,
|
||||
route_maps=[
|
||||
RouteMap(
|
||||
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
|
||||
)
|
||||
],
|
||||
)
|
||||
# Internal-tagged route should be excluded
|
||||
assert "getAdminStats" not in resource_names
|
||||
assert "getAdminStats" not in tool_names
|
||||
|
||||
# Other routes should still be present
|
||||
assert "getUsers" in resource_names
|
||||
assert "getHealth" in resource_names
|
||||
assert "getMetrics" in resource_names
|
||||
assert "createUser" in tool_names
|
||||
|
||||
async def test_multiple_tags_and_condition(self, tagged_openapi_spec, mock_client):
|
||||
"""Test that routes must have ALL specified tags (AND condition)."""
|
||||
# Routes must have BOTH "users" AND "admin" tags
|
||||
route_maps = [
|
||||
RouteMap(
|
||||
methods="*",
|
||||
pattern=r".*",
|
||||
mcp_type=MCPType.TOOL,
|
||||
tags={"users", "admin"},
|
||||
),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
]
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=tagged_openapi_spec,
|
||||
client=mock_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
tools = server._tool_manager.get_tools()
|
||||
tool_names = {t.name for t in tools.values()}
|
||||
|
||||
resources = server._resource_manager.get_resources()
|
||||
resource_names = {r.name for r in resources.values()}
|
||||
|
||||
# Only createUser has both "users" AND "admin" tags
|
||||
assert "createUser" in tool_names
|
||||
|
||||
# Other routes should be resources
|
||||
assert "getUsers" in resource_names # has "users" but not "admin"
|
||||
assert "getAdminStats" in resource_names # has "admin" but not "users"
|
||||
assert "getHealth" in resource_names
|
||||
assert "getMetrics" in resource_names
|
||||
|
||||
async def test_pattern_and_tags_combination(self, tagged_openapi_spec, mock_client):
|
||||
"""Test that both pattern and tags must be satisfied."""
|
||||
# Routes matching pattern AND having specific tags
|
||||
route_maps = [
|
||||
RouteMap(
|
||||
methods="*",
|
||||
pattern=r".*/admin/.*",
|
||||
mcp_type=MCPType.TOOL,
|
||||
tags={"admin"},
|
||||
),
|
||||
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
||||
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
|
||||
]
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=tagged_openapi_spec,
|
||||
client=mock_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
tools = server._tool_manager.get_tools()
|
||||
tool_names = {t.name for t in tools.values()}
|
||||
|
||||
resources = server._resource_manager.get_resources()
|
||||
resource_names = {r.name for r in resources.values()}
|
||||
|
||||
# Only getAdminStats matches both /admin/ pattern AND "admin" tag
|
||||
assert "getAdminStats" in tool_names
|
||||
|
||||
# createUser has "admin" tag but doesn't match pattern, so it becomes a tool via POST rule
|
||||
assert "createUser" in tool_names
|
||||
|
||||
# Other routes should be resources (GET)
|
||||
assert "getUsers" in resource_names
|
||||
assert "getHealth" in resource_names
|
||||
assert "getMetrics" in resource_names
|
||||
|
||||
async def test_empty_tags_ignored(self, tagged_openapi_spec, mock_client):
|
||||
"""Test that empty tags set is ignored (matches all routes)."""
|
||||
# Empty tags should match all routes
|
||||
route_maps = [
|
||||
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags=set()),
|
||||
]
|
||||
|
||||
server = FastMCPOpenAPI(
|
||||
openapi_spec=tagged_openapi_spec,
|
||||
client=mock_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
tools = server._tool_manager.get_tools()
|
||||
tool_names = {t.name for t in tools.values()}
|
||||
|
||||
# All routes should be tools since empty tags matches everything
|
||||
expected_tools = {
|
||||
"getUsers",
|
||||
"createUser",
|
||||
"getAdminStats",
|
||||
"getHealth",
|
||||
"getMetrics",
|
||||
}
|
||||
assert tool_names == expected_tools
|
||||
|
|
@ -6,7 +6,7 @@ import pytest
|
|||
from fastapi import FastAPI, Query
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.server.openapi import OpenAPITool, RouteMap, RouteType
|
||||
from fastmcp.server.openapi import MCPType, OpenAPITool, RouteMap
|
||||
from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo
|
||||
|
||||
|
||||
|
|
@ -286,9 +286,7 @@ async def test_array_query_param_with_fastapi():
|
|||
# Create a FastMCP server from the FastAPI app
|
||||
mcp = FastMCP.from_fastapi(
|
||||
app,
|
||||
route_maps=[
|
||||
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
|
||||
],
|
||||
route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
|
||||
)
|
||||
|
||||
# Test with the client
|
||||
|
|
@ -2,9 +2,11 @@ import warnings
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from mcp.types import ModelPreferences
|
||||
from starlette.requests import Request
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.server.server import FastMCP
|
||||
|
||||
|
||||
class TestContextDeprecations:
|
||||
|
|
@ -57,3 +59,30 @@ class TestContextDeprecations:
|
|||
assert "https://gofastmcp.com/patterns/http-requests" in str(
|
||||
warning.message
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context():
|
||||
return Context(fastmcp=FastMCP())
|
||||
|
||||
|
||||
class TestParseModelPreferences:
|
||||
def test_parse_model_preferences_string(self, context):
|
||||
mp = context._parse_model_preferences("claude-3-sonnet")
|
||||
assert isinstance(mp, ModelPreferences)
|
||||
assert mp.hints is not None
|
||||
assert mp.hints[0].name == "claude-3-sonnet"
|
||||
|
||||
def test_parse_model_preferences_list(self, context):
|
||||
mp = context._parse_model_preferences(["claude-3-sonnet", "claude"])
|
||||
assert isinstance(mp, ModelPreferences)
|
||||
assert mp.hints is not None
|
||||
assert [h.name for h in mp.hints] == ["claude-3-sonnet", "claude"]
|
||||
|
||||
def test_parse_model_preferences_object(self, context):
|
||||
obj = ModelPreferences(hints=[])
|
||||
assert context._parse_model_preferences(obj) is obj
|
||||
|
||||
def test_parse_model_preferences_invalid_type(self, context):
|
||||
with pytest.raises(ValueError):
|
||||
context._parse_model_preferences(123)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from fastmcp.client.transports import (
|
|||
StdioTransport,
|
||||
StreamableHttpTransport,
|
||||
)
|
||||
from fastmcp.utilities.mcp_config import LocalMCPServer, MCPConfig, RemoteMCPServer
|
||||
from fastmcp.utilities.mcp_config import MCPConfig, RemoteMCPServer, StdioMCPServer
|
||||
|
||||
|
||||
def test_parse_single_stdio_config():
|
||||
|
|
@ -89,7 +89,7 @@ def test_parse_multiple_servers():
|
|||
assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
|
||||
assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
|
||||
|
||||
assert isinstance(mcp_config.mcpServers["test_server_2"], LocalMCPServer)
|
||||
assert isinstance(mcp_config.mcpServers["test_server_2"], StdioMCPServer)
|
||||
assert isinstance(
|
||||
mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue