Spaces:
Running
Running
Merge branch 'main' into mask-errors
Browse files- docs/clients/client.mdx +2 -2
- docs/clients/transports.mdx +3 -3
- docs/docs.json +1 -2
- docs/getting-started/installation.mdx +14 -0
- docs/patterns/fastapi.mdx +9 -106
- docs/servers/composition.mdx +1 -1
- docs/servers/context.mdx +4 -3
- docs/{patterns → servers}/openapi.mdx +137 -150
- docs/servers/proxy.mdx +1 -1
- examples/tags_example.py +141 -0
- src/fastmcp/client/client.py +17 -0
- src/fastmcp/client/transports.py +22 -10
- src/fastmcp/server/context.py +46 -0
- src/fastmcp/server/http.py +2 -0
- src/fastmcp/server/openapi.py +190 -49
- src/fastmcp/server/server.py +28 -21
- src/fastmcp/utilities/mcp_config.py +4 -3
- tests/client/test_client.py +27 -2
- tests/deprecated/test_route_type_ignore.py +113 -0
- tests/server/http/test_custom_routes.py +105 -0
- tests/server/{test_http_dependencies.py → http/test_http_dependencies.py} +0 -0
- tests/server/{test_http_middleware.py → http/test_http_middleware.py} +0 -0
- tests/server/{test_openapi.py → openapi/test_openapi.py} +183 -189
- tests/server/{test_openapi_path_parameters.py → openapi/test_openapi_path_parameters.py} +2 -4
- tests/server/test_context.py +29 -0
- tests/utilities/test_mcp_config.py +2 -2
docs/clients/client.mdx
CHANGED
|
@@ -37,7 +37,7 @@ Clients must be initialized with a `transport`. You can either provide an alread
|
|
| 37 |
The following inference rules are used to determine the appropriate `ClientTransport` based on the input type:
|
| 38 |
|
| 39 |
1. **`ClientTransport` Instance**: If you provide an already instantiated transport object, it's used directly.
|
| 40 |
-
2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing).
|
| 41 |
3. **`Path` or `str` pointing to an existing file**:
|
| 42 |
* If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`.
|
| 43 |
* 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
|
|
| 91 |
|
| 92 |
### Multi-Server Clients
|
| 93 |
|
| 94 |
-
<VersionBadge version="2.
|
| 95 |
|
| 96 |
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.
|
| 97 |
|
|
|
|
| 37 |
The following inference rules are used to determine the appropriate `ClientTransport` based on the input type:
|
| 38 |
|
| 39 |
1. **`ClientTransport` Instance**: If you provide an already instantiated transport object, it's used directly.
|
| 40 |
+
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`.
|
| 41 |
3. **`Path` or `str` pointing to an existing file**:
|
| 42 |
* If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`.
|
| 43 |
* If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`.
|
|
|
|
| 91 |
|
| 92 |
### Multi-Server Clients
|
| 93 |
|
| 94 |
+
<VersionBadge version="2.4.0" />
|
| 95 |
|
| 96 |
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.
|
| 97 |
|
docs/clients/transports.mdx
CHANGED
|
@@ -290,8 +290,8 @@ asyncio.run(main())
|
|
| 290 |
### FastMCP Transport
|
| 291 |
|
| 292 |
- **Class:** `fastmcp.client.transports.FastMCPTransport`
|
| 293 |
-
- **Inferred From:** An instance of `fastmcp.server.FastMCP`
|
| 294 |
-
- **Use Case:** Connecting directly to a
|
| 295 |
|
| 296 |
This is extremely useful for testing your FastMCP servers.
|
| 297 |
|
|
@@ -323,7 +323,7 @@ Communication happens through efficient in-memory queues, making it very fast an
|
|
| 323 |
|
| 324 |
### MCPConfig Transport
|
| 325 |
|
| 326 |
-
<VersionBadge version="2.
|
| 327 |
|
| 328 |
- **Class:** `fastmcp.client.transports.MCPConfigTransport`
|
| 329 |
- **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema
|
|
|
|
| 290 |
### FastMCP Transport
|
| 291 |
|
| 292 |
- **Class:** `fastmcp.client.transports.FastMCPTransport`
|
| 293 |
+
- **Inferred From:** An instance of `fastmcp.server.FastMCP` or a **FastMCP 1.0 server** (`mcp.server.fastmcp.FastMCP`)
|
| 294 |
+
- **Use Case:** Connecting directly to a FastMCP server instance in the same Python process
|
| 295 |
|
| 296 |
This is extremely useful for testing your FastMCP servers.
|
| 297 |
|
|
|
|
| 323 |
|
| 324 |
### MCPConfig Transport
|
| 325 |
|
| 326 |
+
<VersionBadge version="2.4.0" />
|
| 327 |
|
| 328 |
- **Class:** `fastmcp.client.transports.MCPConfigTransport`
|
| 329 |
- **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema
|
docs/docs.json
CHANGED
|
@@ -50,6 +50,7 @@
|
|
| 50 |
"servers/resources",
|
| 51 |
"servers/prompts",
|
| 52 |
"servers/context",
|
|
|
|
| 53 |
"servers/proxy",
|
| 54 |
"servers/composition"
|
| 55 |
]
|
|
@@ -76,8 +77,6 @@
|
|
| 76 |
"pages": [
|
| 77 |
"patterns/decorating-methods",
|
| 78 |
"patterns/http-requests",
|
| 79 |
-
"patterns/openapi",
|
| 80 |
-
"patterns/fastapi",
|
| 81 |
"patterns/contrib",
|
| 82 |
"patterns/testing"
|
| 83 |
]
|
|
|
|
| 50 |
"servers/resources",
|
| 51 |
"servers/prompts",
|
| 52 |
"servers/context",
|
| 53 |
+
"servers/openapi",
|
| 54 |
"servers/proxy",
|
| 55 |
"servers/composition"
|
| 56 |
]
|
|
|
|
| 77 |
"pages": [
|
| 78 |
"patterns/decorating-methods",
|
| 79 |
"patterns/http-requests",
|
|
|
|
|
|
|
| 80 |
"patterns/contrib",
|
| 81 |
"patterns/testing"
|
| 82 |
]
|
docs/getting-started/installation.mdx
CHANGED
|
@@ -60,6 +60,20 @@ mcp = FastMCP("My MCP Server")
|
|
| 60 |
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.
|
| 61 |
</Warning>
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
## Installing for Development
|
| 64 |
|
| 65 |
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):
|
|
|
|
| 60 |
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.
|
| 61 |
</Warning>
|
| 62 |
|
| 63 |
+
## Versioning and Breaking Changes
|
| 64 |
+
|
| 65 |
+
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.
|
| 66 |
+
|
| 67 |
+
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:
|
| 68 |
+
- A significant new feature set that warrants a new minor version
|
| 69 |
+
- Introducing breaking changes that may affect behavior on upgrade
|
| 70 |
+
|
| 71 |
+
For users concerned about stability in production environments, we recommend pinning FastMCP to a specific version in your dependencies.
|
| 72 |
+
|
| 73 |
+
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.
|
| 74 |
+
|
| 75 |
+
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.
|
| 76 |
+
|
| 77 |
## Installing for Development
|
| 78 |
|
| 79 |
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):
|
docs/patterns/fastapi.mdx
CHANGED
|
@@ -8,19 +8,18 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|
| 8 |
|
| 9 |
<VersionBadge version="2.0.0" />
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
<Tip>
|
| 15 |
-
FastMCP does *not* include FastAPI as a dependency; you must install it separately to run these examples.
|
| 16 |
-
</Tip>
|
| 17 |
|
|
|
|
| 18 |
|
| 19 |
-
```python
|
| 20 |
from fastapi import FastAPI
|
| 21 |
from fastmcp import FastMCP
|
| 22 |
|
| 23 |
-
|
| 24 |
# A FastAPI app
|
| 25 |
app = FastAPI()
|
| 26 |
|
|
@@ -36,7 +35,6 @@ def get_item(item_id: int):
|
|
| 36 |
def create_item(name: str):
|
| 37 |
return {"id": 3, "name": name}
|
| 38 |
|
| 39 |
-
|
| 40 |
# Create an MCP server from your FastAPI app
|
| 41 |
mcp = FastMCP.from_fastapi(app=app)
|
| 42 |
|
|
@@ -44,101 +42,6 @@ if __name__ == "__main__":
|
|
| 44 |
mcp.run() # Start the MCP server
|
| 45 |
```
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
You can set a timeout for all API requests:
|
| 52 |
-
|
| 53 |
-
```python
|
| 54 |
-
# Set a 5 second timeout for all requests
|
| 55 |
-
mcp = FastMCP.from_fastapi(app=app, timeout=5.0)
|
| 56 |
-
```
|
| 57 |
-
|
| 58 |
-
This timeout is applied to all requests made by tools, resources, and resource templates.
|
| 59 |
-
|
| 60 |
-
## Route Mapping
|
| 61 |
-
|
| 62 |
-
By default, FastMCP will map FastAPI routes to MCP components according to the following rules:
|
| 63 |
-
|
| 64 |
-
| FastAPI Route Type | FastAPI Example | MCP Component | Notes |
|
| 65 |
-
|--------------------|--------------|---------|-------|
|
| 66 |
-
| GET without path params | `@app.get("/stats")` | Resource | Simple resources for fetching data |
|
| 67 |
-
| GET with path params | `@app.get("/users/{id}")` | Resource Template | Path parameters become template parameters |
|
| 68 |
-
| POST, PUT, DELETE, etc. | `@app.post("/users")` | Tool | Operations that modify data |
|
| 69 |
-
|
| 70 |
-
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.
|
| 71 |
-
|
| 72 |
-
## Complete Example
|
| 73 |
-
|
| 74 |
-
Here's a more detailed example with a data model:
|
| 75 |
-
|
| 76 |
-
```python [expandable]
|
| 77 |
-
import asyncio
|
| 78 |
-
from fastapi import FastAPI, HTTPException
|
| 79 |
-
from pydantic import BaseModel
|
| 80 |
-
from fastmcp import FastMCP, Client
|
| 81 |
-
|
| 82 |
-
# Define your Pydantic model
|
| 83 |
-
class Item(BaseModel):
|
| 84 |
-
name: str
|
| 85 |
-
price: float
|
| 86 |
-
|
| 87 |
-
# Create your FastAPI app
|
| 88 |
-
app = FastAPI()
|
| 89 |
-
items = {} # In-memory database
|
| 90 |
-
|
| 91 |
-
@app.get("/items")
|
| 92 |
-
def list_items():
|
| 93 |
-
"""List all items"""
|
| 94 |
-
return list(items.values())
|
| 95 |
-
|
| 96 |
-
@app.get("/items/{item_id}")
|
| 97 |
-
def get_item(item_id: int):
|
| 98 |
-
"""Get item by ID"""
|
| 99 |
-
if item_id not in items:
|
| 100 |
-
raise HTTPException(404, "Item not found")
|
| 101 |
-
return items[item_id]
|
| 102 |
-
|
| 103 |
-
@app.post("/items")
|
| 104 |
-
def create_item(item: Item):
|
| 105 |
-
"""Create a new item"""
|
| 106 |
-
item_id = len(items) + 1
|
| 107 |
-
items[item_id] = {"id": item_id, **item.model_dump()}
|
| 108 |
-
return items[item_id]
|
| 109 |
-
|
| 110 |
-
# Test your MCP server with a client
|
| 111 |
-
async def check_mcp(mcp: FastMCP):
|
| 112 |
-
# List the components that were created
|
| 113 |
-
tools = await mcp.get_tools()
|
| 114 |
-
resources = await mcp.get_resources()
|
| 115 |
-
templates = await mcp.get_resource_templates()
|
| 116 |
-
|
| 117 |
-
print(
|
| 118 |
-
f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}"
|
| 119 |
-
)
|
| 120 |
-
print(
|
| 121 |
-
f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}"
|
| 122 |
-
)
|
| 123 |
-
print(
|
| 124 |
-
f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}"
|
| 125 |
-
)
|
| 126 |
-
|
| 127 |
-
return mcp
|
| 128 |
-
|
| 129 |
-
if __name__ == "__main__":
|
| 130 |
-
# Create MCP server from FastAPI app
|
| 131 |
-
mcp = FastMCP.from_fastapi(app=app)
|
| 132 |
-
|
| 133 |
-
asyncio.run(check_mcp(mcp))
|
| 134 |
-
|
| 135 |
-
# In a real scenario, you would run the server:
|
| 136 |
-
mcp.run()
|
| 137 |
-
```
|
| 138 |
-
|
| 139 |
-
## Benefits
|
| 140 |
-
|
| 141 |
-
- **Leverage existing FastAPI apps** - No need to rewrite your API logic
|
| 142 |
-
- **Schema reuse** - FastAPI's Pydantic models and validation are inherited
|
| 143 |
-
- **Full feature support** - Works with FastAPI's authentication, dependencies, etc.
|
| 144 |
-
- **ASGI transport** - Direct communication without additional HTTP overhead
|
|
|
|
| 8 |
|
| 9 |
<VersionBadge version="2.0.0" />
|
| 10 |
|
| 11 |
+
<Note>
|
| 12 |
+
**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.
|
| 13 |
+
</Note>
|
| 14 |
|
| 15 |
+
## Quick Start
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
FastMCP can automatically convert FastAPI applications into MCP servers:
|
| 18 |
|
| 19 |
+
```python
|
| 20 |
from fastapi import FastAPI
|
| 21 |
from fastmcp import FastMCP
|
| 22 |
|
|
|
|
| 23 |
# A FastAPI app
|
| 24 |
app = FastAPI()
|
| 25 |
|
|
|
|
| 35 |
def create_item(name: str):
|
| 36 |
return {"id": 3, "name": name}
|
| 37 |
|
|
|
|
| 38 |
# Create an MCP server from your FastAPI app
|
| 39 |
mcp = FastMCP.from_fastapi(app=app)
|
| 40 |
|
|
|
|
| 42 |
mcp.run() # Start the MCP server
|
| 43 |
```
|
| 44 |
|
| 45 |
+
<Tip>
|
| 46 |
+
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).
|
| 47 |
+
</Tip>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/servers/composition.mdx
CHANGED
|
@@ -35,7 +35,7 @@ The choice of importing or mounting depends on your use case and requirements.
|
|
| 35 |
|
| 36 |
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.
|
| 37 |
|
| 38 |
-
<VersionBadge version="2.
|
| 39 |
|
| 40 |
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.
|
| 41 |
|
|
|
|
| 35 |
|
| 36 |
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.
|
| 37 |
|
| 38 |
+
<VersionBadge version="2.4.0" />
|
| 39 |
|
| 40 |
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.
|
| 41 |
|
docs/servers/context.mdx
CHANGED
|
@@ -228,8 +228,8 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
|
|
| 228 |
# Create a sampling prompt asking for sentiment analysis
|
| 229 |
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}"
|
| 230 |
|
| 231 |
-
# Send the sampling request to the client's LLM
|
| 232 |
-
response = await ctx.sample(prompt)
|
| 233 |
|
| 234 |
# Process the LLM's response
|
| 235 |
sentiment = response.text.strip().lower()
|
|
@@ -247,11 +247,12 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
|
|
| 247 |
|
| 248 |
**Method signature:**
|
| 249 |
|
| 250 |
-
- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None) -> TextContent | ImageContent`**
|
| 251 |
- `messages`: A string or list of strings/message objects to send to the LLM
|
| 252 |
- `system_prompt`: Optional system prompt to guide the LLM's behavior
|
| 253 |
- `temperature`: Optional sampling temperature (controls randomness)
|
| 254 |
- `max_tokens`: Optional maximum number of tokens to generate (defaults to 512)
|
|
|
|
| 255 |
- Returns the LLM's response as TextContent or ImageContent
|
| 256 |
|
| 257 |
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.
|
|
|
|
| 228 |
# Create a sampling prompt asking for sentiment analysis
|
| 229 |
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}"
|
| 230 |
|
| 231 |
+
# Send the sampling request to the client's LLM (provide a hint for the model you want to use)
|
| 232 |
+
response = await ctx.sample(prompt, model_preferences="claude-3-sonnet")
|
| 233 |
|
| 234 |
# Process the LLM's response
|
| 235 |
sentiment = response.text.strip().lower()
|
|
|
|
| 247 |
|
| 248 |
**Method signature:**
|
| 249 |
|
| 250 |
+
- **`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`**
|
| 251 |
- `messages`: A string or list of strings/message objects to send to the LLM
|
| 252 |
- `system_prompt`: Optional system prompt to guide the LLM's behavior
|
| 253 |
- `temperature`: Optional sampling temperature (controls randomness)
|
| 254 |
- `max_tokens`: Optional maximum number of tokens to generate (defaults to 512)
|
| 255 |
+
- `model_preferences`: Optional model selection preferences (e.g., a model hint string, list of hints, or a ModelPreferences object)
|
| 256 |
- Returns the LLM's response as TextContent or ImageContent
|
| 257 |
|
| 258 |
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.
|
docs/{patterns → servers}/openapi.mdx
RENAMED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
title: OpenAPI Integration
|
| 3 |
-
sidebarTitle: OpenAPI
|
| 4 |
description: Generate MCP servers from OpenAPI specs
|
| 5 |
icon: code-branch
|
| 6 |
---
|
|
@@ -8,7 +8,7 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
|
|
| 8 |
|
| 9 |
<VersionBadge version="2.0.0" />
|
| 10 |
|
| 11 |
-
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.
|
| 12 |
|
| 13 |
```python
|
| 14 |
import httpx
|
|
@@ -27,127 +27,103 @@ if __name__ == "__main__":
|
|
| 27 |
mcp.run()
|
| 28 |
```
|
| 29 |
|
| 30 |
-
##
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
timeout=5.0
|
| 42 |
-
)
|
| 43 |
-
```
|
| 44 |
|
| 45 |
-
This timeout is applied to all requests made by tools, resources, and resource templates.
|
| 46 |
|
| 47 |
-
|
| 48 |
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|- | - | - | - |
|
| 53 |
-
| `GET` without path params | `GET /stats` | Resource | Simple resources for fetching data |
|
| 54 |
-
| `GET` with path params | `GET /users/{id}` | Resource Template | Path parameters become template parameters |
|
| 55 |
-
| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool | Operations that modify data |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
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:
|
| 59 |
|
| 60 |
```python
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
DEFAULT_ROUTE_MAPPINGS = [
|
| 63 |
# GET with path parameters -> ResourceTemplate
|
| 64 |
RouteMap(
|
| 65 |
methods=["GET"],
|
| 66 |
pattern=r".*\{.*\}.*",
|
| 67 |
-
|
|
|
|
| 68 |
),
|
| 69 |
-
|
| 70 |
# GET without path parameters -> Resource
|
| 71 |
RouteMap(
|
| 72 |
methods=["GET"],
|
| 73 |
pattern=r".*",
|
| 74 |
-
|
|
|
|
| 75 |
),
|
| 76 |
-
|
| 77 |
# All other methods -> Tool
|
| 78 |
RouteMap(
|
| 79 |
-
methods="*",
|
| 80 |
pattern=r".*",
|
| 81 |
-
|
|
|
|
| 82 |
),
|
| 83 |
]
|
| 84 |
```
|
|
|
|
| 85 |
### Custom Route Maps
|
| 86 |
|
| 87 |
-
|
| 88 |
|
| 89 |
-
```python
|
| 90 |
-
from fastmcp.server.openapi import RouteMap,
|
| 91 |
-
|
| 92 |
-
# Custom mapping rules
|
| 93 |
-
custom_maps = [
|
| 94 |
-
# Force all analytics endpoints to be Tools
|
| 95 |
-
RouteMap(methods=["GET"],
|
| 96 |
-
pattern=r"^/analytics/.*",
|
| 97 |
-
route_type=RouteType.TOOL)
|
| 98 |
-
]
|
| 99 |
|
| 100 |
-
|
| 101 |
-
mcp = await FastMCP.from_openapi(
|
| 102 |
openapi_spec=spec,
|
| 103 |
client=api_client,
|
| 104 |
-
route_maps=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
)
|
| 106 |
```
|
| 107 |
|
|
|
|
| 108 |
|
| 109 |
-
|
| 110 |
|
| 111 |
-
|
| 112 |
|
| 113 |
-
``
|
| 114 |
-
# Make all endpoints tools, regardless of HTTP method
|
| 115 |
-
mcp = FastMCP.from_openapi(
|
| 116 |
-
openapi_spec=spec,
|
| 117 |
-
client=api_client,
|
| 118 |
-
all_routes_as_tools=True
|
| 119 |
-
)
|
| 120 |
-
```
|
| 121 |
-
|
| 122 |
-
This is equivalent to defining a single route map that matches all routes:
|
| 123 |
|
| 124 |
-
|
| 125 |
-
# Same effect as all_routes_as_tools=True
|
| 126 |
-
mcp = FastMCP.from_openapi(
|
| 127 |
-
openapi_spec=spec,
|
| 128 |
-
client=api_client,
|
| 129 |
-
route_maps=[
|
| 130 |
-
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
|
| 131 |
-
]
|
| 132 |
-
)
|
| 133 |
-
```
|
| 134 |
|
| 135 |
-
|
| 136 |
|
| 137 |
-
|
| 138 |
|
| 139 |
-
1. FastMCP parses your OpenAPI spec to extract routes and schemas
|
| 140 |
-
2. It applies mapping rules to categorize each route
|
| 141 |
-
3. When an MCP client calls a tool or accesses a resource:
|
| 142 |
-
- FastMCP constructs an HTTP request based on the OpenAPI definition
|
| 143 |
-
- It sends the request through the provided httpx client
|
| 144 |
-
- It translates the HTTP response to the appropriate MCP format
|
| 145 |
|
| 146 |
-
##
|
| 147 |
|
| 148 |
FastMCP carefully handles different types of parameters in OpenAPI requests:
|
| 149 |
|
| 150 |
-
###
|
| 151 |
|
| 152 |
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.
|
| 153 |
|
|
@@ -163,7 +139,7 @@ await client.call_tool("search_products", {
|
|
| 163 |
|
| 164 |
The resulting HTTP request will only include `category=electronics&min_price=100`.
|
| 165 |
|
| 166 |
-
###
|
| 167 |
|
| 168 |
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.
|
| 169 |
|
|
@@ -175,89 +151,100 @@ await client.call_tool("get_product", {"product_id": 123})
|
|
| 175 |
await client.call_tool("get_product", {"product_id": None})
|
| 176 |
```
|
| 177 |
|
| 178 |
-
##
|
| 179 |
|
| 180 |
-
|
| 181 |
-
import asyncio
|
| 182 |
|
|
|
|
| 183 |
import httpx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
|
|
|
|
|
|
| 185 |
from fastmcp import FastMCP
|
| 186 |
|
| 187 |
-
#
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
"get": {
|
| 198 |
-
"operationId": "listPets",
|
| 199 |
-
"summary": "List all pets",
|
| 200 |
-
"responses": {"200": {"description": "A list of pets"}},
|
| 201 |
-
},
|
| 202 |
-
"post": {
|
| 203 |
-
"operationId": "createPet",
|
| 204 |
-
"summary": "Create a new pet",
|
| 205 |
-
"responses": {"201": {"description": "Pet created successfully"}},
|
| 206 |
-
},
|
| 207 |
-
},
|
| 208 |
-
"/pets/{petId}": {
|
| 209 |
-
"get": {
|
| 210 |
-
"operationId": "getPet",
|
| 211 |
-
"summary": "Get a pet by ID",
|
| 212 |
-
"parameters": [
|
| 213 |
-
{
|
| 214 |
-
"name": "petId",
|
| 215 |
-
"in": "path",
|
| 216 |
-
"required": True,
|
| 217 |
-
"schema": {"type": "string"},
|
| 218 |
-
}
|
| 219 |
-
],
|
| 220 |
-
"responses": {
|
| 221 |
-
"200": {"description": "Pet details"},
|
| 222 |
-
"404": {"description": "Pet not found"},
|
| 223 |
-
},
|
| 224 |
-
}
|
| 225 |
-
},
|
| 226 |
-
},
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
async def check_mcp(mcp: FastMCP):
|
| 231 |
-
# List what components were created
|
| 232 |
-
tools = await mcp.get_tools()
|
| 233 |
-
resources = await mcp.get_resources()
|
| 234 |
-
templates = await mcp.get_resource_templates()
|
| 235 |
-
|
| 236 |
-
print(
|
| 237 |
-
f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}"
|
| 238 |
-
) # Should include createPet
|
| 239 |
-
print(
|
| 240 |
-
f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}"
|
| 241 |
-
) # Should include listPets
|
| 242 |
-
print(
|
| 243 |
-
f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}"
|
| 244 |
-
) # Should include getPet
|
| 245 |
-
|
| 246 |
-
return mcp
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
if __name__ == "__main__":
|
| 250 |
-
#
|
| 251 |
-
|
| 252 |
|
| 253 |
-
|
| 254 |
-
mcp = FastMCP.from_openapi(
|
| 255 |
-
openapi_spec=petstore_spec, client=client, name="PetStore"
|
| 256 |
-
)
|
| 257 |
|
| 258 |
-
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
```
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: OpenAPI Integration
|
| 3 |
+
sidebarTitle: OpenAPI Integration
|
| 4 |
description: Generate MCP servers from OpenAPI specs
|
| 5 |
icon: code-branch
|
| 6 |
---
|
|
|
|
| 8 |
|
| 9 |
<VersionBadge version="2.0.0" />
|
| 10 |
|
| 11 |
+
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.
|
| 12 |
|
| 13 |
```python
|
| 14 |
import httpx
|
|
|
|
| 27 |
mcp.run()
|
| 28 |
```
|
| 29 |
|
| 30 |
+
## Route Mapping
|
| 31 |
|
| 32 |
+
<VersionBadge version="2.5.0" />
|
| 33 |
|
| 34 |
+
By default, OpenAPI routes are mapped to MCP components based on these rules:
|
| 35 |
|
| 36 |
+
| OpenAPI Route | Example |MCP Component |
|
| 37 |
+
| - | - | - |
|
| 38 |
+
| `GET` with path params | `GET /users/{id}` | Resource Template |
|
| 39 |
+
| `GET` without path params | `GET /stats` | Resource |
|
| 40 |
+
| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool |
|
|
|
|
|
|
|
|
|
|
| 41 |
|
|
|
|
| 42 |
|
| 43 |
+
Internally, FastMCP uses a priority-ordered list of `RouteMap` objects to determine the component type for each route. Each `RouteMap` specifies:
|
| 44 |
|
| 45 |
+
- **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all)
|
| 46 |
+
- **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all)
|
| 47 |
+
- **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.
|
| 48 |
+
- **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)
|
| 49 |
|
| 50 |
+
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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
```python
|
| 53 |
+
from fastmcp.server.openapi import RouteMap, MCPType
|
| 54 |
+
|
| 55 |
+
# Default route mappings
|
| 56 |
DEFAULT_ROUTE_MAPPINGS = [
|
| 57 |
# GET with path parameters -> ResourceTemplate
|
| 58 |
RouteMap(
|
| 59 |
methods=["GET"],
|
| 60 |
pattern=r".*\{.*\}.*",
|
| 61 |
+
tags={},
|
| 62 |
+
mcp_type=MCPType.RESOURCE_TEMPLATE
|
| 63 |
),
|
|
|
|
| 64 |
# GET without path parameters -> Resource
|
| 65 |
RouteMap(
|
| 66 |
methods=["GET"],
|
| 67 |
pattern=r".*",
|
| 68 |
+
tags={},
|
| 69 |
+
mcp_type=MCPType.RESOURCE
|
| 70 |
),
|
|
|
|
| 71 |
# All other methods -> Tool
|
| 72 |
RouteMap(
|
| 73 |
+
methods="*",
|
| 74 |
pattern=r".*",
|
| 75 |
+
tags={},
|
| 76 |
+
mcp_type=MCPType.TOOL
|
| 77 |
),
|
| 78 |
]
|
| 79 |
```
|
| 80 |
+
|
| 81 |
### Custom Route Maps
|
| 82 |
|
| 83 |
+
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!).
|
| 84 |
|
| 85 |
+
```python {1, 6-18}
|
| 86 |
+
from fastmcp.server.openapi import RouteMap, MCPType
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
mcp = FastMCP.from_openapi(
|
|
|
|
| 89 |
openapi_spec=spec,
|
| 90 |
client=api_client,
|
| 91 |
+
route_maps=[
|
| 92 |
+
# All GET analytics endpoints should be tools
|
| 93 |
+
RouteMap(
|
| 94 |
+
methods=["GET"],
|
| 95 |
+
pattern=r"^/analytics/.*",
|
| 96 |
+
mcp_type=MCPType.TOOL,
|
| 97 |
+
),
|
| 98 |
+
# Exclude all admin endpoints
|
| 99 |
+
RouteMap(
|
| 100 |
+
pattern=r"^/admin/.*",
|
| 101 |
+
mcp_type=MCPType.EXCLUDE,
|
| 102 |
+
)
|
| 103 |
+
]
|
| 104 |
)
|
| 105 |
```
|
| 106 |
|
| 107 |
+
### Treat All Routes as Tools
|
| 108 |
|
| 109 |
+
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.
|
| 110 |
|
| 111 |
+
### Prevent Default Mappings
|
| 112 |
|
| 113 |
+
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
### Tag-Based Routing
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
+
<VersionBadge version="2.5.0" />
|
| 118 |
|
| 119 |
+
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.
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
+
## Request Parameter Handling
|
| 123 |
|
| 124 |
FastMCP carefully handles different types of parameters in OpenAPI requests:
|
| 125 |
|
| 126 |
+
### Query Parameters
|
| 127 |
|
| 128 |
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.
|
| 129 |
|
|
|
|
| 139 |
|
| 140 |
The resulting HTTP request will only include `category=electronics&min_price=100`.
|
| 141 |
|
| 142 |
+
### Path Parameters
|
| 143 |
|
| 144 |
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.
|
| 145 |
|
|
|
|
| 151 |
await client.call_tool("get_product", {"product_id": None})
|
| 152 |
```
|
| 153 |
|
| 154 |
+
## Authorization
|
| 155 |
|
| 156 |
+
If your API requires authentication, set headers on the client before creating the MCP server.
|
|
|
|
| 157 |
|
| 158 |
+
```python
|
| 159 |
import httpx
|
| 160 |
+
from fastmcp import FastMCP
|
| 161 |
+
|
| 162 |
+
# Create a client with authentication
|
| 163 |
+
api_client = httpx.AsyncClient(
|
| 164 |
+
base_url="https://api.example.com",
|
| 165 |
+
headers={"Authorization": "Bearer YOUR_TOKEN"}
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Create an MCP server from your OpenAPI spec
|
| 169 |
+
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
## Timeouts
|
| 173 |
+
|
| 174 |
+
You can set a timeout for all requests by providing a `timeout` parameter (in seconds):
|
| 175 |
+
|
| 176 |
+
```python
|
| 177 |
+
mcp = FastMCP.from_openapi(
|
| 178 |
+
openapi_spec=spec,
|
| 179 |
+
client=api_client,
|
| 180 |
+
timeout=30.0 # 30 second timeout
|
| 181 |
+
)
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
## FastAPI Integration
|
| 185 |
+
|
| 186 |
+
<VersionBadge version="2.0.0" />
|
| 187 |
+
|
| 188 |
+
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)).
|
| 189 |
+
|
| 190 |
+
<Tip>
|
| 191 |
+
FastMCP does *not* include FastAPI as a dependency; you must install it separately to use this integration.
|
| 192 |
+
</Tip>
|
| 193 |
|
| 194 |
+
```python
|
| 195 |
+
from fastapi import FastAPI
|
| 196 |
from fastmcp import FastMCP
|
| 197 |
|
| 198 |
+
# A FastAPI app
|
| 199 |
+
app = FastAPI()
|
| 200 |
+
|
| 201 |
+
@app.get("/items", tags=["items"])
|
| 202 |
+
def list_items():
|
| 203 |
+
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
|
| 204 |
+
|
| 205 |
+
@app.get("/items/{item_id}", tags=["items", "detail"])
|
| 206 |
+
def get_item(item_id: int):
|
| 207 |
+
return {"id": item_id, "name": f"Item {item_id}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
+
@app.post("/items", tags=["items", "create"])
|
| 210 |
+
def create_item(name: str):
|
| 211 |
+
return {"id": 3, "name": name}
|
| 212 |
+
|
| 213 |
+
# Create an MCP server from your FastAPI app
|
| 214 |
+
mcp = FastMCP.from_fastapi(app=app)
|
| 215 |
|
| 216 |
if __name__ == "__main__":
|
| 217 |
+
mcp.run() # Start the MCP server
|
| 218 |
+
```
|
| 219 |
|
| 220 |
+
### Configuration Options
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
+
**Timeout**: You can set a timeout for all API requests:
|
| 223 |
|
| 224 |
+
```python
|
| 225 |
+
# Set a 5 second timeout for all requests
|
| 226 |
+
mcp = FastMCP.from_fastapi(app=app, timeout=5.0)
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
**Route Mapping**: All the route mapping features (including tags) work with FastAPI apps:
|
| 230 |
+
|
| 231 |
+
```python
|
| 232 |
+
from fastmcp.server.openapi import RouteMap, MCPType
|
| 233 |
+
|
| 234 |
+
# Use tag-based routing with FastAPI
|
| 235 |
+
mcp = FastMCP.from_fastapi(
|
| 236 |
+
app=app,
|
| 237 |
+
route_maps=[
|
| 238 |
+
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
|
| 239 |
+
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}),
|
| 240 |
+
]
|
| 241 |
+
)
|
| 242 |
```
|
| 243 |
|
| 244 |
+
### Benefits
|
| 245 |
+
|
| 246 |
+
- **Leverage existing FastAPI apps** - No need to rewrite your API logic
|
| 247 |
+
- **Schema reuse** - FastAPI's Pydantic models and validation are inherited
|
| 248 |
+
- **Full feature support** - Works with FastAPI's authentication, dependencies, etc.
|
| 249 |
+
- **ASGI transport** - Direct communication without additional HTTP overhead
|
| 250 |
+
|
docs/servers/proxy.mdx
CHANGED
|
@@ -106,7 +106,7 @@ proxy = FastMCP.as_proxy(
|
|
| 106 |
|
| 107 |
### Configuration-Based Proxies
|
| 108 |
|
| 109 |
-
<VersionBadge version="2.
|
| 110 |
|
| 111 |
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.
|
| 112 |
|
|
|
|
| 106 |
|
| 107 |
### Configuration-Based Proxies
|
| 108 |
|
| 109 |
+
<VersionBadge version="2.4.0" />
|
| 110 |
|
| 111 |
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.
|
| 112 |
|
examples/tags_example.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Example demonstrating RouteMap tags functionality.
|
| 3 |
+
|
| 4 |
+
This example shows how to use the tags parameter in RouteMap
|
| 5 |
+
to selectively route OpenAPI endpoints based on their tags.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
|
| 12 |
+
from fastmcp import FastMCP
|
| 13 |
+
from fastmcp.server.openapi import MCPType, RouteMap
|
| 14 |
+
|
| 15 |
+
# Create a FastAPI app with tagged endpoints
|
| 16 |
+
app = FastAPI(title="Tagged API Example")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@app.get("/users", tags=["users", "public"])
|
| 20 |
+
async def get_users():
|
| 21 |
+
"""Get all users - public endpoint"""
|
| 22 |
+
return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@app.post("/users", tags=["users", "admin"])
|
| 26 |
+
async def create_user(name: str):
|
| 27 |
+
"""Create a user - admin only"""
|
| 28 |
+
return {"id": 3, "name": name}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@app.get("/admin/stats", tags=["admin", "internal"])
|
| 32 |
+
async def get_admin_stats():
|
| 33 |
+
"""Get admin statistics - internal use"""
|
| 34 |
+
return {"total_users": 100, "active_sessions": 25}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@app.get("/health", tags=["public"])
|
| 38 |
+
async def health_check():
|
| 39 |
+
"""Public health check"""
|
| 40 |
+
return {"status": "healthy"}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@app.get("/metrics")
|
| 44 |
+
async def get_metrics():
|
| 45 |
+
"""Metrics endpoint with no tags"""
|
| 46 |
+
return {"requests": 1000, "errors": 5}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def main():
|
| 50 |
+
"""Demonstrate different tag-based routing strategies."""
|
| 51 |
+
|
| 52 |
+
print("=== Example 1: Make admin-tagged routes tools ===")
|
| 53 |
+
|
| 54 |
+
# Strategy 1: Convert admin-tagged routes to tools
|
| 55 |
+
mcp1 = FastMCP.from_fastapi(
|
| 56 |
+
app=app,
|
| 57 |
+
route_maps=[
|
| 58 |
+
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
|
| 59 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 60 |
+
],
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
tools = await mcp1.get_tools()
|
| 64 |
+
resources = await mcp1.get_resources()
|
| 65 |
+
|
| 66 |
+
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
|
| 67 |
+
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
|
| 68 |
+
|
| 69 |
+
print("\n=== Example 2: Exclude internal routes ===")
|
| 70 |
+
|
| 71 |
+
# Strategy 2: Exclude internal routes entirely
|
| 72 |
+
mcp2 = FastMCP.from_fastapi(
|
| 73 |
+
app=app,
|
| 74 |
+
route_maps=[
|
| 75 |
+
RouteMap(
|
| 76 |
+
methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}
|
| 77 |
+
),
|
| 78 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 79 |
+
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
|
| 80 |
+
],
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
tools = await mcp2.get_tools()
|
| 84 |
+
resources = await mcp2.get_resources()
|
| 85 |
+
|
| 86 |
+
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
|
| 87 |
+
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
|
| 88 |
+
|
| 89 |
+
print("\n=== Example 3: Pattern + Tags combination ===")
|
| 90 |
+
|
| 91 |
+
# Strategy 3: Routes matching both pattern AND tags
|
| 92 |
+
mcp3 = FastMCP.from_fastapi(
|
| 93 |
+
app=app,
|
| 94 |
+
route_maps=[
|
| 95 |
+
# Admin routes under /admin path -> tools
|
| 96 |
+
RouteMap(
|
| 97 |
+
methods="*",
|
| 98 |
+
pattern=r".*/admin/.*",
|
| 99 |
+
mcp_type=MCPType.TOOL,
|
| 100 |
+
tags={"admin"},
|
| 101 |
+
),
|
| 102 |
+
# Public routes -> tools
|
| 103 |
+
RouteMap(
|
| 104 |
+
methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"public"}
|
| 105 |
+
),
|
| 106 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 107 |
+
],
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
tools = await mcp3.get_tools()
|
| 111 |
+
resources = await mcp3.get_resources()
|
| 112 |
+
|
| 113 |
+
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
|
| 114 |
+
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
|
| 115 |
+
|
| 116 |
+
print("\n=== Example 4: Multiple tag AND condition ===")
|
| 117 |
+
|
| 118 |
+
# Strategy 4: Routes must have ALL specified tags
|
| 119 |
+
mcp4 = FastMCP.from_fastapi(
|
| 120 |
+
app=app,
|
| 121 |
+
route_maps=[
|
| 122 |
+
# Routes with BOTH "users" AND "admin" tags -> tools
|
| 123 |
+
RouteMap(
|
| 124 |
+
methods="*",
|
| 125 |
+
pattern=r".*",
|
| 126 |
+
mcp_type=MCPType.TOOL,
|
| 127 |
+
tags={"users", "admin"},
|
| 128 |
+
),
|
| 129 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 130 |
+
],
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
tools = await mcp4.get_tools()
|
| 134 |
+
resources = await mcp4.get_resources()
|
| 135 |
+
|
| 136 |
+
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
|
| 137 |
+
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
if __name__ == "__main__":
|
| 141 |
+
asyncio.run(main())
|
src/fastmcp/client/client.py
CHANGED
|
@@ -210,6 +210,23 @@ class Client:
|
|
| 210 |
result = await self.session.send_ping()
|
| 211 |
return isinstance(result, mcp.types.EmptyResult)
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
async def progress(
|
| 214 |
self,
|
| 215 |
progress_token: str | int,
|
|
|
|
| 210 |
result = await self.session.send_ping()
|
| 211 |
return isinstance(result, mcp.types.EmptyResult)
|
| 212 |
|
| 213 |
+
async def cancel(
|
| 214 |
+
self,
|
| 215 |
+
request_id: str | int,
|
| 216 |
+
reason: str | None = None,
|
| 217 |
+
) -> None:
|
| 218 |
+
"""Send a cancellation notification for an in-progress request."""
|
| 219 |
+
notification = mcp.types.ClientNotification(
|
| 220 |
+
mcp.types.CancelledNotification(
|
| 221 |
+
method="notifications/cancelled",
|
| 222 |
+
params=mcp.types.CancelledNotificationParams(
|
| 223 |
+
requestId=request_id,
|
| 224 |
+
reason=reason,
|
| 225 |
+
),
|
| 226 |
+
)
|
| 227 |
+
)
|
| 228 |
+
await self.session.send_notification(notification)
|
| 229 |
+
|
| 230 |
async def progress(
|
| 231 |
self,
|
| 232 |
progress_token: str | int,
|
src/fastmcp/client/transports.py
CHANGED
|
@@ -19,6 +19,7 @@ from mcp.client.sse import sse_client
|
|
| 19 |
from mcp.client.stdio import stdio_client
|
| 20 |
from mcp.client.streamable_http import streamablehttp_client
|
| 21 |
from mcp.client.websocket import websocket_client
|
|
|
|
| 22 |
from mcp.shared.memory import create_connected_server_and_client_session
|
| 23 |
from pydantic import AnyUrl
|
| 24 |
from typing_extensions import Unpack
|
|
@@ -448,15 +449,21 @@ class NpxStdioTransport(StdioTransport):
|
|
| 448 |
|
| 449 |
|
| 450 |
class FastMCPTransport(ClientTransport):
|
| 451 |
-
"""
|
| 452 |
-
Special transport for in-memory connections to an MCP server.
|
| 453 |
|
| 454 |
-
This
|
| 455 |
-
|
|
|
|
|
|
|
| 456 |
"""
|
| 457 |
|
| 458 |
-
def __init__(self, mcp: FastMCPServer):
|
| 459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
|
| 461 |
@contextlib.asynccontextmanager
|
| 462 |
async def connect_session(
|
|
@@ -528,8 +535,12 @@ class MCPConfigTransport(ClientTransport):
|
|
| 528 |
config = MCPConfig.from_dict(config)
|
| 529 |
self.config = config
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
# if there's exactly one server, create a client for that server
|
| 532 |
-
|
| 533 |
self.transport = list(self.config.mcpServers.values())[0].to_transport()
|
| 534 |
|
| 535 |
# otherwise create a composite client
|
|
@@ -558,6 +569,7 @@ class MCPConfigTransport(ClientTransport):
|
|
| 558 |
def infer_transport(
|
| 559 |
transport: ClientTransport
|
| 560 |
| FastMCPServer
|
|
|
|
| 561 |
| AnyUrl
|
| 562 |
| Path
|
| 563 |
| MCPConfig
|
|
@@ -573,7 +585,7 @@ def infer_transport(
|
|
| 573 |
|
| 574 |
The function supports these input types:
|
| 575 |
- ClientTransport: Used directly without modification
|
| 576 |
-
- FastMCPServer: Creates an in-memory FastMCPTransport
|
| 577 |
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
|
| 578 |
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
|
| 579 |
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
|
|
@@ -610,8 +622,8 @@ def infer_transport(
|
|
| 610 |
if isinstance(transport, ClientTransport):
|
| 611 |
return transport
|
| 612 |
|
| 613 |
-
# the transport is a FastMCP server
|
| 614 |
-
elif isinstance(transport, FastMCPServer):
|
| 615 |
inferred_transport = FastMCPTransport(mcp=transport)
|
| 616 |
|
| 617 |
# the transport is a path to a script
|
|
|
|
| 19 |
from mcp.client.stdio import stdio_client
|
| 20 |
from mcp.client.streamable_http import streamablehttp_client
|
| 21 |
from mcp.client.websocket import websocket_client
|
| 22 |
+
from mcp.server.fastmcp import FastMCP as FastMCP1Server
|
| 23 |
from mcp.shared.memory import create_connected_server_and_client_session
|
| 24 |
from pydantic import AnyUrl
|
| 25 |
from typing_extensions import Unpack
|
|
|
|
| 449 |
|
| 450 |
|
| 451 |
class FastMCPTransport(ClientTransport):
|
| 452 |
+
"""In-memory transport for FastMCP servers.
|
|
|
|
| 453 |
|
| 454 |
+
This transport connects directly to a FastMCP server instance in the same
|
| 455 |
+
Python process. It works with both FastMCP 2.x servers and FastMCP 1.0
|
| 456 |
+
servers from the low-level MCP SDK. This is particularly useful for unit
|
| 457 |
+
tests or scenarios where client and server run in the same runtime.
|
| 458 |
"""
|
| 459 |
|
| 460 |
+
def __init__(self, mcp: FastMCPServer | FastMCP1Server):
|
| 461 |
+
"""Initialize a FastMCPTransport from a FastMCP server instance."""
|
| 462 |
+
|
| 463 |
+
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
|
| 464 |
+
# ``_mcp_server`` attribute pointing to the underlying MCP server
|
| 465 |
+
# implementation, so we can treat them identically.
|
| 466 |
+
self.server = mcp
|
| 467 |
|
| 468 |
@contextlib.asynccontextmanager
|
| 469 |
async def connect_session(
|
|
|
|
| 535 |
config = MCPConfig.from_dict(config)
|
| 536 |
self.config = config
|
| 537 |
|
| 538 |
+
# if there are no servers, raise an error
|
| 539 |
+
if len(self.config.mcpServers) == 0:
|
| 540 |
+
raise ValueError("No MCP servers defined in the config")
|
| 541 |
+
|
| 542 |
# if there's exactly one server, create a client for that server
|
| 543 |
+
elif len(self.config.mcpServers) == 1:
|
| 544 |
self.transport = list(self.config.mcpServers.values())[0].to_transport()
|
| 545 |
|
| 546 |
# otherwise create a composite client
|
|
|
|
| 569 |
def infer_transport(
|
| 570 |
transport: ClientTransport
|
| 571 |
| FastMCPServer
|
| 572 |
+
| FastMCP1Server
|
| 573 |
| AnyUrl
|
| 574 |
| Path
|
| 575 |
| MCPConfig
|
|
|
|
| 585 |
|
| 586 |
The function supports these input types:
|
| 587 |
- ClientTransport: Used directly without modification
|
| 588 |
+
- FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
|
| 589 |
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
|
| 590 |
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
|
| 591 |
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
|
|
|
|
| 622 |
if isinstance(transport, ClientTransport):
|
| 623 |
return transport
|
| 624 |
|
| 625 |
+
# the transport is a FastMCP server (2.x or 1.0)
|
| 626 |
+
elif isinstance(transport, FastMCPServer | FastMCP1Server):
|
| 627 |
inferred_transport = FastMCPTransport(mcp=transport)
|
| 628 |
|
| 629 |
# the transport is a path to a script
|
src/fastmcp/server/context.py
CHANGED
|
@@ -12,6 +12,8 @@ from mcp.shared.context import RequestContext
|
|
| 12 |
from mcp.types import (
|
| 13 |
CreateMessageResult,
|
| 14 |
ImageContent,
|
|
|
|
|
|
|
| 15 |
Root,
|
| 16 |
SamplingMessage,
|
| 17 |
TextContent,
|
|
@@ -200,6 +202,7 @@ class Context:
|
|
| 200 |
system_prompt: str | None = None,
|
| 201 |
temperature: float | None = None,
|
| 202 |
max_tokens: int | None = None,
|
|
|
|
| 203 |
) -> TextContent | ImageContent:
|
| 204 |
"""
|
| 205 |
Send a sampling request to the client and await the response.
|
|
@@ -231,6 +234,7 @@ class Context:
|
|
| 231 |
system_prompt=system_prompt,
|
| 232 |
temperature=temperature,
|
| 233 |
max_tokens=max_tokens,
|
|
|
|
| 234 |
)
|
| 235 |
|
| 236 |
return result.content
|
|
@@ -248,3 +252,45 @@ class Context:
|
|
| 248 |
)
|
| 249 |
|
| 250 |
return fastmcp.server.dependencies.get_http_request()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
from mcp.types import (
|
| 13 |
CreateMessageResult,
|
| 14 |
ImageContent,
|
| 15 |
+
ModelHint,
|
| 16 |
+
ModelPreferences,
|
| 17 |
Root,
|
| 18 |
SamplingMessage,
|
| 19 |
TextContent,
|
|
|
|
| 202 |
system_prompt: str | None = None,
|
| 203 |
temperature: float | None = None,
|
| 204 |
max_tokens: int | None = None,
|
| 205 |
+
model_preferences: ModelPreferences | str | list[str] | None = None,
|
| 206 |
) -> TextContent | ImageContent:
|
| 207 |
"""
|
| 208 |
Send a sampling request to the client and await the response.
|
|
|
|
| 234 |
system_prompt=system_prompt,
|
| 235 |
temperature=temperature,
|
| 236 |
max_tokens=max_tokens,
|
| 237 |
+
model_preferences=self._parse_model_preferences(model_preferences),
|
| 238 |
)
|
| 239 |
|
| 240 |
return result.content
|
|
|
|
| 252 |
)
|
| 253 |
|
| 254 |
return fastmcp.server.dependencies.get_http_request()
|
| 255 |
+
|
| 256 |
+
def _parse_model_preferences(
|
| 257 |
+
self, model_preferences: ModelPreferences | str | list[str] | None
|
| 258 |
+
) -> ModelPreferences | None:
|
| 259 |
+
"""
|
| 260 |
+
Validates and converts user input for model_preferences into a ModelPreferences object.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
model_preferences (ModelPreferences | str | list[str] | None):
|
| 264 |
+
The model preferences to use. Accepts:
|
| 265 |
+
- ModelPreferences (returns as-is)
|
| 266 |
+
- str (single model hint)
|
| 267 |
+
- list[str] (multiple model hints)
|
| 268 |
+
- None (no preferences)
|
| 269 |
+
|
| 270 |
+
Returns:
|
| 271 |
+
ModelPreferences | None: The parsed ModelPreferences object, or None if not provided.
|
| 272 |
+
|
| 273 |
+
Raises:
|
| 274 |
+
ValueError: If the input is not a supported type or contains invalid values.
|
| 275 |
+
"""
|
| 276 |
+
if model_preferences is None:
|
| 277 |
+
return None
|
| 278 |
+
elif isinstance(model_preferences, ModelPreferences):
|
| 279 |
+
return model_preferences
|
| 280 |
+
elif isinstance(model_preferences, str):
|
| 281 |
+
# Single model hint
|
| 282 |
+
return ModelPreferences(hints=[ModelHint(name=model_preferences)])
|
| 283 |
+
elif isinstance(model_preferences, list):
|
| 284 |
+
# List of model hints (strings)
|
| 285 |
+
if not all(isinstance(h, str) for h in model_preferences):
|
| 286 |
+
raise ValueError(
|
| 287 |
+
"All elements of model_preferences list must be"
|
| 288 |
+
" strings (model name hints)."
|
| 289 |
+
)
|
| 290 |
+
return ModelPreferences(
|
| 291 |
+
hints=[ModelHint(name=h) for h in model_preferences]
|
| 292 |
+
)
|
| 293 |
+
else:
|
| 294 |
+
raise ValueError(
|
| 295 |
+
"model_preferences must be one of: ModelPreferences, str, list[str], or None."
|
| 296 |
+
)
|
src/fastmcp/server/http.py
CHANGED
|
@@ -241,6 +241,7 @@ def create_sse_app(
|
|
| 241 |
# Add custom routes with lowest precedence
|
| 242 |
if routes:
|
| 243 |
server_routes.extend(routes)
|
|
|
|
| 244 |
|
| 245 |
# Add middleware
|
| 246 |
if middleware:
|
|
@@ -359,6 +360,7 @@ def create_streamable_http_app(
|
|
| 359 |
# Add custom routes with lowest precedence
|
| 360 |
if routes:
|
| 361 |
server_routes.extend(routes)
|
|
|
|
| 362 |
|
| 363 |
# Add middleware
|
| 364 |
if middleware:
|
|
|
|
| 241 |
# Add custom routes with lowest precedence
|
| 242 |
if routes:
|
| 243 |
server_routes.extend(routes)
|
| 244 |
+
server_routes.extend(server._additional_http_routes)
|
| 245 |
|
| 246 |
# Add middleware
|
| 247 |
if middleware:
|
|
|
|
| 360 |
# Add custom routes with lowest precedence
|
| 361 |
if routes:
|
| 362 |
server_routes.extend(routes)
|
| 363 |
+
server_routes.extend(server._additional_http_routes)
|
| 364 |
|
| 365 |
# Add middleware
|
| 366 |
if middleware:
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -5,8 +5,9 @@ from __future__ import annotations
|
|
| 5 |
import enum
|
| 6 |
import json
|
| 7 |
import re
|
|
|
|
| 8 |
from collections.abc import Callable
|
| 9 |
-
from dataclasses import dataclass
|
| 10 |
from re import Pattern
|
| 11 |
from typing import TYPE_CHECKING, Any, Literal
|
| 12 |
|
|
@@ -33,8 +34,32 @@ logger = get_logger(__name__)
|
|
| 33 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
class RouteType(enum.Enum):
|
| 37 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
TOOL = "TOOL"
|
| 40 |
RESOURCE = "RESOURCE"
|
|
@@ -47,32 +72,71 @@ class RouteType(enum.Enum):
|
|
| 47 |
class RouteMap:
|
| 48 |
"""Mapping configuration for HTTP routes to FastMCP component types."""
|
| 49 |
|
| 50 |
-
methods: list[HttpMethod] | Literal["*"]
|
| 51 |
-
pattern: Pattern[str] | str
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
# Default route mappings as a list, where order determines priority
|
| 56 |
DEFAULT_ROUTE_MAPPINGS = [
|
| 57 |
# GET requests with path parameters go to ResourceTemplate
|
| 58 |
RouteMap(
|
| 59 |
-
methods=["GET"], pattern=r".*\{.*\}.*",
|
| 60 |
),
|
| 61 |
# GET requests without path parameters go to Resource
|
| 62 |
-
RouteMap(methods=["GET"], pattern=r".*",
|
| 63 |
# All other HTTP methods go to Tool
|
| 64 |
-
RouteMap(
|
| 65 |
-
methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
| 66 |
-
pattern=r".*",
|
| 67 |
-
route_type=RouteType.TOOL,
|
| 68 |
-
),
|
| 69 |
]
|
| 70 |
|
| 71 |
|
| 72 |
def _determine_route_type(
|
| 73 |
route: openapi.HTTPRoute,
|
| 74 |
mappings: list[RouteMap],
|
| 75 |
-
) ->
|
| 76 |
"""
|
| 77 |
Determines the FastMCP component type based on the route and mappings.
|
| 78 |
|
|
@@ -81,7 +145,7 @@ def _determine_route_type(
|
|
| 81 |
mappings: List of RouteMap objects in priority order
|
| 82 |
|
| 83 |
Returns:
|
| 84 |
-
|
| 85 |
"""
|
| 86 |
# Check mappings in priority order (first match wins)
|
| 87 |
for route_map in mappings:
|
|
@@ -94,20 +158,24 @@ def _determine_route_type(
|
|
| 94 |
pattern_matches = re.search(route_map.pattern, route.path)
|
| 95 |
|
| 96 |
if pattern_matches:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
logger.debug(
|
| 98 |
-
f"Route {route.method} {route.path} matched mapping to {route_map.
|
| 99 |
)
|
| 100 |
-
return route_map.
|
| 101 |
|
| 102 |
# Default fallback
|
| 103 |
-
return
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
# Placeholder function to provide function metadata
|
| 107 |
-
async def _openapi_passthrough(*args, **kwargs):
|
| 108 |
-
"""Placeholder function for OpenAPI endpoints."""
|
| 109 |
-
# This is kept for metadata generation purposes
|
| 110 |
-
pass
|
| 111 |
|
| 112 |
|
| 113 |
class OpenAPITool(Tool):
|
|
@@ -555,13 +623,13 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 555 |
RouteMap(
|
| 556 |
methods=["GET", "POST", "PATCH"],
|
| 557 |
pattern=r".*/users/.*",
|
| 558 |
-
|
| 559 |
),
|
| 560 |
# Map all analytics endpoints to Tool
|
| 561 |
RouteMap(
|
| 562 |
methods=["GET"],
|
| 563 |
pattern=r".*/analytics/.*",
|
| 564 |
-
|
| 565 |
),
|
| 566 |
]
|
| 567 |
|
|
@@ -599,6 +667,10 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 599 |
|
| 600 |
self._client = client
|
| 601 |
self._timeout = timeout
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
| 603 |
|
| 604 |
# Process routes
|
|
@@ -607,34 +679,99 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 607 |
# Determine route type based on mappings or default rules
|
| 608 |
route_type = _determine_route_type(route, route_maps)
|
| 609 |
|
| 610 |
-
#
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
elif route_type == RouteType.RESOURCE:
|
| 621 |
-
self._create_openapi_resource(route, operation_id)
|
| 622 |
-
elif route_type == RouteType.RESOURCE_TEMPLATE:
|
| 623 |
-
self._create_openapi_template(route, operation_id)
|
| 624 |
-
elif route_type == RouteType.PROMPT:
|
| 625 |
# Not implemented yet
|
| 626 |
logger.warning(
|
| 627 |
f"PROMPT route type not implemented: {route.method} {route.path}"
|
| 628 |
)
|
| 629 |
-
elif route_type ==
|
| 630 |
-
logger.info(f"
|
| 631 |
|
| 632 |
logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
|
| 633 |
|
| 634 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
"""Creates and registers an OpenAPITool with enhanced description."""
|
| 636 |
combined_schema = _combine_schemas(route)
|
| 637 |
-
|
|
|
|
|
|
|
|
|
|
| 638 |
base_description = (
|
| 639 |
route.description
|
| 640 |
or route.summary
|
|
@@ -664,9 +801,11 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 664 |
f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
| 665 |
)
|
| 666 |
|
| 667 |
-
def _create_openapi_resource(self, route: openapi.HTTPRoute,
|
| 668 |
"""Creates and registers an OpenAPIResource with enhanced description."""
|
| 669 |
-
|
|
|
|
|
|
|
| 670 |
resource_uri = f"resource://openapi/{resource_name}"
|
| 671 |
base_description = (
|
| 672 |
route.description or route.summary or f"Represents {route.path}"
|
|
@@ -695,9 +834,11 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 695 |
f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
| 696 |
)
|
| 697 |
|
| 698 |
-
def _create_openapi_template(self, route: openapi.HTTPRoute,
|
| 699 |
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
|
| 700 |
-
|
|
|
|
|
|
|
| 701 |
path_params = [p.name for p in route.parameters if p.location == "path"]
|
| 702 |
path_params.sort() # Sort for consistent URIs
|
| 703 |
|
|
|
|
| 5 |
import enum
|
| 6 |
import json
|
| 7 |
import re
|
| 8 |
+
import warnings
|
| 9 |
from collections.abc import Callable
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
from re import Pattern
|
| 12 |
from typing import TYPE_CHECKING, Any, Literal
|
| 13 |
|
|
|
|
| 34 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
| 35 |
|
| 36 |
|
| 37 |
+
class MCPType(enum.Enum):
|
| 38 |
+
"""Type of FastMCP component to create from a route.
|
| 39 |
+
|
| 40 |
+
Enum values:
|
| 41 |
+
TOOL: Convert the route to a callable Tool
|
| 42 |
+
RESOURCE: Convert the route to a Resource (typically GET endpoints)
|
| 43 |
+
RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
|
| 44 |
+
PROMPT: Convert the route to a Prompt (not yet implemented)
|
| 45 |
+
EXCLUDE: Exclude the route from being converted to any MCP component
|
| 46 |
+
IGNORE: Deprecated, use EXCLUDE instead
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
TOOL = "TOOL"
|
| 50 |
+
RESOURCE = "RESOURCE"
|
| 51 |
+
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
|
| 52 |
+
PROMPT = "PROMPT"
|
| 53 |
+
EXCLUDE = "EXCLUDE"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# Keep RouteType as an alias to MCPType for backward compatibility
|
| 57 |
class RouteType(enum.Enum):
|
| 58 |
+
"""
|
| 59 |
+
Deprecated: Use MCPType instead.
|
| 60 |
+
|
| 61 |
+
This enum is kept for backward compatibility and will be removed in a future version.
|
| 62 |
+
"""
|
| 63 |
|
| 64 |
TOOL = "TOOL"
|
| 65 |
RESOURCE = "RESOURCE"
|
|
|
|
| 72 |
class RouteMap:
|
| 73 |
"""Mapping configuration for HTTP routes to FastMCP component types."""
|
| 74 |
|
| 75 |
+
methods: list[HttpMethod] | Literal["*"] = field(default="*")
|
| 76 |
+
pattern: Pattern[str] | str = field(default=r".*")
|
| 77 |
+
mcp_type: MCPType | None = field(default=None)
|
| 78 |
+
route_type: RouteType | MCPType | None = field(default=None)
|
| 79 |
+
tags: set[str] = field(default_factory=set)
|
| 80 |
+
|
| 81 |
+
def __post_init__(self):
|
| 82 |
+
"""Validate and process the route map after initialization."""
|
| 83 |
+
# Handle backward compatibility for route_type, deprecated in 2.5.0
|
| 84 |
+
if self.mcp_type is None and self.route_type is not None:
|
| 85 |
+
warnings.warn(
|
| 86 |
+
"The 'route_type' parameter is deprecated and will be removed in a future version. "
|
| 87 |
+
"Use 'mcp_type' instead with the appropriate MCPType value.",
|
| 88 |
+
DeprecationWarning,
|
| 89 |
+
stacklevel=2,
|
| 90 |
+
)
|
| 91 |
+
if isinstance(self.route_type, RouteType):
|
| 92 |
+
warnings.warn(
|
| 93 |
+
"The RouteType class is deprecated and will be removed in a future version. "
|
| 94 |
+
"Use MCPType instead.",
|
| 95 |
+
DeprecationWarning,
|
| 96 |
+
stacklevel=2,
|
| 97 |
+
)
|
| 98 |
+
# Check for the deprecated IGNORE value
|
| 99 |
+
if self.route_type == RouteType.IGNORE:
|
| 100 |
+
warnings.warn(
|
| 101 |
+
"RouteType.IGNORE is deprecated and will be removed in a future version. "
|
| 102 |
+
"Use MCPType.EXCLUDE instead.",
|
| 103 |
+
DeprecationWarning,
|
| 104 |
+
stacklevel=2,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Convert from RouteType to MCPType if needed
|
| 108 |
+
if isinstance(self.route_type, RouteType):
|
| 109 |
+
route_type_name = self.route_type.name
|
| 110 |
+
if route_type_name == "IGNORE":
|
| 111 |
+
route_type_name = "EXCLUDE"
|
| 112 |
+
self.mcp_type = getattr(MCPType, route_type_name)
|
| 113 |
+
else:
|
| 114 |
+
self.mcp_type = self.route_type
|
| 115 |
+
elif self.mcp_type is None:
|
| 116 |
+
raise ValueError("`mcp_type` must be provided")
|
| 117 |
+
|
| 118 |
+
# Set route_type to match mcp_type for backward compatibility
|
| 119 |
+
if self.route_type is None:
|
| 120 |
+
self.route_type = self.mcp_type
|
| 121 |
|
| 122 |
|
| 123 |
# Default route mappings as a list, where order determines priority
|
| 124 |
DEFAULT_ROUTE_MAPPINGS = [
|
| 125 |
# GET requests with path parameters go to ResourceTemplate
|
| 126 |
RouteMap(
|
| 127 |
+
methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE
|
| 128 |
),
|
| 129 |
# GET requests without path parameters go to Resource
|
| 130 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 131 |
# All other HTTP methods go to Tool
|
| 132 |
+
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
]
|
| 134 |
|
| 135 |
|
| 136 |
def _determine_route_type(
|
| 137 |
route: openapi.HTTPRoute,
|
| 138 |
mappings: list[RouteMap],
|
| 139 |
+
) -> MCPType:
|
| 140 |
"""
|
| 141 |
Determines the FastMCP component type based on the route and mappings.
|
| 142 |
|
|
|
|
| 145 |
mappings: List of RouteMap objects in priority order
|
| 146 |
|
| 147 |
Returns:
|
| 148 |
+
MCPType for this route
|
| 149 |
"""
|
| 150 |
# Check mappings in priority order (first match wins)
|
| 151 |
for route_map in mappings:
|
|
|
|
| 158 |
pattern_matches = re.search(route_map.pattern, route.path)
|
| 159 |
|
| 160 |
if pattern_matches:
|
| 161 |
+
# Check if tags match (if specified)
|
| 162 |
+
# If route_map.tags is empty, tags are not matched
|
| 163 |
+
# If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
|
| 164 |
+
if route_map.tags:
|
| 165 |
+
route_tags_set = set(route.tags or [])
|
| 166 |
+
if not route_map.tags.issubset(route_tags_set):
|
| 167 |
+
# Tags don't match, continue to next mapping
|
| 168 |
+
continue
|
| 169 |
+
|
| 170 |
+
# We know mcp_type is not None here due to post_init validation
|
| 171 |
+
assert route_map.mcp_type is not None
|
| 172 |
logger.debug(
|
| 173 |
+
f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
|
| 174 |
)
|
| 175 |
+
return route_map.mcp_type
|
| 176 |
|
| 177 |
# Default fallback
|
| 178 |
+
return MCPType.TOOL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
|
| 181 |
class OpenAPITool(Tool):
|
|
|
|
| 623 |
RouteMap(
|
| 624 |
methods=["GET", "POST", "PATCH"],
|
| 625 |
pattern=r".*/users/.*",
|
| 626 |
+
mcp_type=MCPType.RESOURCE_TEMPLATE
|
| 627 |
),
|
| 628 |
# Map all analytics endpoints to Tool
|
| 629 |
RouteMap(
|
| 630 |
methods=["GET"],
|
| 631 |
pattern=r".*/analytics/.*",
|
| 632 |
+
mcp_type=MCPType.TOOL
|
| 633 |
),
|
| 634 |
]
|
| 635 |
|
|
|
|
| 667 |
|
| 668 |
self._client = client
|
| 669 |
self._timeout = timeout
|
| 670 |
+
|
| 671 |
+
# Keep track of names to detect collisions
|
| 672 |
+
self._used_names = {"tools": set(), "resources": set(), "templates": set()}
|
| 673 |
+
|
| 674 |
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
| 675 |
|
| 676 |
# Process routes
|
|
|
|
| 679 |
# Determine route type based on mappings or default rules
|
| 680 |
route_type = _determine_route_type(route, route_maps)
|
| 681 |
|
| 682 |
+
# Generate a default name from the route
|
| 683 |
+
component_name = self._generate_default_name(route, route_type)
|
| 684 |
+
|
| 685 |
+
if route_type == MCPType.TOOL:
|
| 686 |
+
self._create_openapi_tool(route, component_name)
|
| 687 |
+
elif route_type == MCPType.RESOURCE:
|
| 688 |
+
self._create_openapi_resource(route, component_name)
|
| 689 |
+
elif route_type == MCPType.RESOURCE_TEMPLATE:
|
| 690 |
+
self._create_openapi_template(route, component_name)
|
| 691 |
+
elif route_type == MCPType.PROMPT:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
# Not implemented yet
|
| 693 |
logger.warning(
|
| 694 |
f"PROMPT route type not implemented: {route.method} {route.path}"
|
| 695 |
)
|
| 696 |
+
elif route_type == MCPType.EXCLUDE:
|
| 697 |
+
logger.info(f"Excluding route: {route.method} {route.path}")
|
| 698 |
|
| 699 |
logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
|
| 700 |
|
| 701 |
+
def _generate_default_name(
|
| 702 |
+
self, route: openapi.HTTPRoute, mcp_type: MCPType
|
| 703 |
+
) -> str:
|
| 704 |
+
"""Generate a default name from the route path."""
|
| 705 |
+
# First check for OpenAPI operationId which takes precedence
|
| 706 |
+
if route.operation_id:
|
| 707 |
+
return route.operation_id
|
| 708 |
+
|
| 709 |
+
# For path-based naming, clean up the path
|
| 710 |
+
path_parts = route.path.strip("/").split("/")
|
| 711 |
+
|
| 712 |
+
# Remove path parameters (parts with {})
|
| 713 |
+
clean_parts = []
|
| 714 |
+
for part in path_parts:
|
| 715 |
+
if part.startswith("{") and part.endswith("}"):
|
| 716 |
+
# For templates, include parameter name without braces
|
| 717 |
+
if mcp_type == MCPType.RESOURCE_TEMPLATE:
|
| 718 |
+
param_name = part[1:-1] # Remove braces
|
| 719 |
+
clean_parts.append(param_name)
|
| 720 |
+
else:
|
| 721 |
+
clean_parts.append(part)
|
| 722 |
+
|
| 723 |
+
# Join the parts
|
| 724 |
+
resource_name = "_".join(clean_parts)
|
| 725 |
+
|
| 726 |
+
# For tools, might be useful to keep the method for clarity on what it does
|
| 727 |
+
if mcp_type == MCPType.TOOL:
|
| 728 |
+
# Only include method if it helps distinguish (POST, PUT, PATCH, DELETE)
|
| 729 |
+
# For GET we don't need the method as it's implied for resources
|
| 730 |
+
if route.method != "GET":
|
| 731 |
+
resource_name = f"{route.method.lower()}_{resource_name}"
|
| 732 |
+
|
| 733 |
+
return resource_name
|
| 734 |
+
|
| 735 |
+
def _get_unique_name(
|
| 736 |
+
self, name: str, component_type: Literal["tools", "resources", "templates"]
|
| 737 |
+
) -> str:
|
| 738 |
+
"""
|
| 739 |
+
Ensure the name is unique within its component type by appending numbers if needed.
|
| 740 |
+
|
| 741 |
+
Args:
|
| 742 |
+
name: The proposed name
|
| 743 |
+
component_type: The type of component ("tools", "resources", or "templates")
|
| 744 |
+
|
| 745 |
+
Returns:
|
| 746 |
+
str: A unique name for the component
|
| 747 |
+
"""
|
| 748 |
+
# Check if the name is already used
|
| 749 |
+
if name not in self._used_names[component_type]:
|
| 750 |
+
self._used_names[component_type].add(name)
|
| 751 |
+
return name
|
| 752 |
+
|
| 753 |
+
# Find the next available number suffix
|
| 754 |
+
counter = 2
|
| 755 |
+
while f"{name}_{counter}" in self._used_names[component_type]:
|
| 756 |
+
counter += 1
|
| 757 |
+
|
| 758 |
+
# Create the new name
|
| 759 |
+
new_name = f"{name}_{counter}"
|
| 760 |
+
logger.debug(
|
| 761 |
+
f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
|
| 762 |
+
f"Using '{new_name}' instead."
|
| 763 |
+
)
|
| 764 |
+
|
| 765 |
+
self._used_names[component_type].add(new_name)
|
| 766 |
+
return new_name
|
| 767 |
+
|
| 768 |
+
def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
|
| 769 |
"""Creates and registers an OpenAPITool with enhanced description."""
|
| 770 |
combined_schema = _combine_schemas(route)
|
| 771 |
+
|
| 772 |
+
# Get a unique tool name
|
| 773 |
+
tool_name = self._get_unique_name(name, "tools")
|
| 774 |
+
|
| 775 |
base_description = (
|
| 776 |
route.description
|
| 777 |
or route.summary
|
|
|
|
| 801 |
f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
| 802 |
)
|
| 803 |
|
| 804 |
+
def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
|
| 805 |
"""Creates and registers an OpenAPIResource with enhanced description."""
|
| 806 |
+
# Get a unique resource name
|
| 807 |
+
resource_name = self._get_unique_name(name, "resources")
|
| 808 |
+
|
| 809 |
resource_uri = f"resource://openapi/{resource_name}"
|
| 810 |
base_description = (
|
| 811 |
route.description or route.summary or f"Represents {route.path}"
|
|
|
|
| 834 |
f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
| 835 |
)
|
| 836 |
|
| 837 |
+
def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
|
| 838 |
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
|
| 839 |
+
# Get a unique template name
|
| 840 |
+
template_name = self._get_unique_name(name, "templates")
|
| 841 |
+
|
| 842 |
path_params = [p.name for p in route.parameters if p.location == "path"]
|
| 843 |
path_params.sort() # Sort for consistent URIs
|
| 844 |
|
src/fastmcp/server/server.py
CHANGED
|
@@ -879,7 +879,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 879 |
auth_server_provider=self._auth_server_provider,
|
| 880 |
auth_settings=self.settings.auth,
|
| 881 |
debug=self.settings.debug,
|
| 882 |
-
routes=self._additional_http_routes,
|
| 883 |
middleware=middleware,
|
| 884 |
)
|
| 885 |
|
|
@@ -930,7 +929,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 930 |
json_response=self.settings.json_response,
|
| 931 |
stateless_http=self.settings.stateless_http,
|
| 932 |
debug=self.settings.debug,
|
| 933 |
-
routes=self._additional_http_routes,
|
| 934 |
middleware=middleware,
|
| 935 |
)
|
| 936 |
elif transport == "sse":
|
|
@@ -941,7 +939,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 941 |
auth_server_provider=self._auth_server_provider,
|
| 942 |
auth_settings=self.settings.auth,
|
| 943 |
debug=self.settings.debug,
|
| 944 |
-
routes=self._additional_http_routes,
|
| 945 |
middleware=middleware,
|
| 946 |
)
|
| 947 |
|
|
@@ -1026,7 +1023,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1026 |
from fastmcp.server.proxy import FastMCPProxy
|
| 1027 |
|
| 1028 |
if tool_separator is not None:
|
| 1029 |
-
# Deprecated since 2.
|
| 1030 |
warnings.warn(
|
| 1031 |
"The tool_separator parameter is deprecated and will be removed in a future version. "
|
| 1032 |
"Tools are now prefixed using 'prefix_toolname' format.",
|
|
@@ -1035,7 +1032,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1035 |
)
|
| 1036 |
|
| 1037 |
if resource_separator is not None:
|
| 1038 |
-
# Deprecated since 2.
|
| 1039 |
warnings.warn(
|
| 1040 |
"The resource_separator parameter is deprecated and ignored. "
|
| 1041 |
"Resource prefixes are now added using the protocol://prefix/path format.",
|
|
@@ -1044,7 +1041,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1044 |
)
|
| 1045 |
|
| 1046 |
if prompt_separator is not None:
|
| 1047 |
-
# Deprecated since 2.
|
| 1048 |
warnings.warn(
|
| 1049 |
"The prompt_separator parameter is deprecated and will be removed in a future version. "
|
| 1050 |
"Prompts are now prefixed using 'prefix_promptname' format.",
|
|
@@ -1111,7 +1108,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1111 |
prompt_separator: Deprecated. Separator for prompt names.
|
| 1112 |
"""
|
| 1113 |
if tool_separator is not None:
|
| 1114 |
-
# Deprecated since 2.
|
| 1115 |
warnings.warn(
|
| 1116 |
"The tool_separator parameter is deprecated and will be removed in a future version. "
|
| 1117 |
"Tools are now prefixed using 'prefix_toolname' format.",
|
|
@@ -1120,7 +1117,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1120 |
)
|
| 1121 |
|
| 1122 |
if resource_separator is not None:
|
| 1123 |
-
# Deprecated since 2.
|
| 1124 |
warnings.warn(
|
| 1125 |
"The resource_separator parameter is deprecated and ignored. "
|
| 1126 |
"Resource prefixes are now added using the protocol://prefix/path format.",
|
|
@@ -1129,7 +1126,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1129 |
)
|
| 1130 |
|
| 1131 |
if prompt_separator is not None:
|
| 1132 |
-
# Deprecated since 2.
|
| 1133 |
warnings.warn(
|
| 1134 |
"The prompt_separator parameter is deprecated and will be removed in a future version. "
|
| 1135 |
"Prompts are now prefixed using 'prefix_promptname' format.",
|
|
@@ -1175,19 +1172,22 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1175 |
"""
|
| 1176 |
Create a FastMCP server from an OpenAPI specification.
|
| 1177 |
"""
|
| 1178 |
-
from .openapi import FastMCPOpenAPI,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1179 |
|
| 1180 |
if all_routes_as_tools and route_maps:
|
| 1181 |
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
|
| 1182 |
|
| 1183 |
elif all_routes_as_tools:
|
| 1184 |
-
route_maps = [
|
| 1185 |
-
RouteMap(
|
| 1186 |
-
methods="*",
|
| 1187 |
-
pattern=r".*",
|
| 1188 |
-
route_type=RouteType.TOOL,
|
| 1189 |
-
)
|
| 1190 |
-
]
|
| 1191 |
|
| 1192 |
return FastMCPOpenAPI(
|
| 1193 |
openapi_spec=openapi_spec,
|
|
@@ -1209,15 +1209,22 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1209 |
Create a FastMCP server from a FastAPI application.
|
| 1210 |
"""
|
| 1211 |
|
| 1212 |
-
from .openapi import FastMCPOpenAPI,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1213 |
|
| 1214 |
if all_routes_as_tools and route_maps:
|
| 1215 |
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
|
| 1216 |
|
| 1217 |
elif all_routes_as_tools:
|
| 1218 |
-
route_maps = [
|
| 1219 |
-
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
|
| 1220 |
-
]
|
| 1221 |
|
| 1222 |
client = httpx.AsyncClient(
|
| 1223 |
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
|
|
|
|
| 879 |
auth_server_provider=self._auth_server_provider,
|
| 880 |
auth_settings=self.settings.auth,
|
| 881 |
debug=self.settings.debug,
|
|
|
|
| 882 |
middleware=middleware,
|
| 883 |
)
|
| 884 |
|
|
|
|
| 929 |
json_response=self.settings.json_response,
|
| 930 |
stateless_http=self.settings.stateless_http,
|
| 931 |
debug=self.settings.debug,
|
|
|
|
| 932 |
middleware=middleware,
|
| 933 |
)
|
| 934 |
elif transport == "sse":
|
|
|
|
| 939 |
auth_server_provider=self._auth_server_provider,
|
| 940 |
auth_settings=self.settings.auth,
|
| 941 |
debug=self.settings.debug,
|
|
|
|
| 942 |
middleware=middleware,
|
| 943 |
)
|
| 944 |
|
|
|
|
| 1023 |
from fastmcp.server.proxy import FastMCPProxy
|
| 1024 |
|
| 1025 |
if tool_separator is not None:
|
| 1026 |
+
# Deprecated since 2.4.0
|
| 1027 |
warnings.warn(
|
| 1028 |
"The tool_separator parameter is deprecated and will be removed in a future version. "
|
| 1029 |
"Tools are now prefixed using 'prefix_toolname' format.",
|
|
|
|
| 1032 |
)
|
| 1033 |
|
| 1034 |
if resource_separator is not None:
|
| 1035 |
+
# Deprecated since 2.4.0
|
| 1036 |
warnings.warn(
|
| 1037 |
"The resource_separator parameter is deprecated and ignored. "
|
| 1038 |
"Resource prefixes are now added using the protocol://prefix/path format.",
|
|
|
|
| 1041 |
)
|
| 1042 |
|
| 1043 |
if prompt_separator is not None:
|
| 1044 |
+
# Deprecated since 2.4.0
|
| 1045 |
warnings.warn(
|
| 1046 |
"The prompt_separator parameter is deprecated and will be removed in a future version. "
|
| 1047 |
"Prompts are now prefixed using 'prefix_promptname' format.",
|
|
|
|
| 1108 |
prompt_separator: Deprecated. Separator for prompt names.
|
| 1109 |
"""
|
| 1110 |
if tool_separator is not None:
|
| 1111 |
+
# Deprecated since 2.4.0
|
| 1112 |
warnings.warn(
|
| 1113 |
"The tool_separator parameter is deprecated and will be removed in a future version. "
|
| 1114 |
"Tools are now prefixed using 'prefix_toolname' format.",
|
|
|
|
| 1117 |
)
|
| 1118 |
|
| 1119 |
if resource_separator is not None:
|
| 1120 |
+
# Deprecated since 2.4.0
|
| 1121 |
warnings.warn(
|
| 1122 |
"The resource_separator parameter is deprecated and ignored. "
|
| 1123 |
"Resource prefixes are now added using the protocol://prefix/path format.",
|
|
|
|
| 1126 |
)
|
| 1127 |
|
| 1128 |
if prompt_separator is not None:
|
| 1129 |
+
# Deprecated since 2.4.0
|
| 1130 |
warnings.warn(
|
| 1131 |
"The prompt_separator parameter is deprecated and will be removed in a future version. "
|
| 1132 |
"Prompts are now prefixed using 'prefix_promptname' format.",
|
|
|
|
| 1172 |
"""
|
| 1173 |
Create a FastMCP server from an OpenAPI specification.
|
| 1174 |
"""
|
| 1175 |
+
from .openapi import FastMCPOpenAPI, MCPType, RouteMap
|
| 1176 |
+
|
| 1177 |
+
# Deprecated since 2.5.0
|
| 1178 |
+
if all_routes_as_tools:
|
| 1179 |
+
warnings.warn(
|
| 1180 |
+
"The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
|
| 1181 |
+
'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
|
| 1182 |
+
DeprecationWarning,
|
| 1183 |
+
stacklevel=2,
|
| 1184 |
+
)
|
| 1185 |
|
| 1186 |
if all_routes_as_tools and route_maps:
|
| 1187 |
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
|
| 1188 |
|
| 1189 |
elif all_routes_as_tools:
|
| 1190 |
+
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1191 |
|
| 1192 |
return FastMCPOpenAPI(
|
| 1193 |
openapi_spec=openapi_spec,
|
|
|
|
| 1209 |
Create a FastMCP server from a FastAPI application.
|
| 1210 |
"""
|
| 1211 |
|
| 1212 |
+
from .openapi import FastMCPOpenAPI, MCPType, RouteMap
|
| 1213 |
+
|
| 1214 |
+
# Deprecated since 2.5.0
|
| 1215 |
+
if all_routes_as_tools:
|
| 1216 |
+
warnings.warn(
|
| 1217 |
+
"The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
|
| 1218 |
+
'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
|
| 1219 |
+
DeprecationWarning,
|
| 1220 |
+
stacklevel=2,
|
| 1221 |
+
)
|
| 1222 |
|
| 1223 |
if all_routes_as_tools and route_maps:
|
| 1224 |
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
|
| 1225 |
|
| 1226 |
elif all_routes_as_tools:
|
| 1227 |
+
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
|
|
|
|
|
|
|
| 1228 |
|
| 1229 |
client = httpx.AsyncClient(
|
| 1230 |
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
|
src/fastmcp/utilities/mcp_config.py
CHANGED
|
@@ -32,11 +32,12 @@ def infer_transport_type_from_url(
|
|
| 32 |
return "streamable-http"
|
| 33 |
|
| 34 |
|
| 35 |
-
class
|
| 36 |
command: str
|
| 37 |
args: list[str] = Field(default_factory=list)
|
| 38 |
env: dict[str, Any] = Field(default_factory=dict)
|
| 39 |
cwd: str | None = None
|
|
|
|
| 40 |
|
| 41 |
def to_transport(self) -> StdioTransport:
|
| 42 |
from fastmcp.client.transports import StdioTransport
|
|
@@ -51,8 +52,8 @@ class LocalMCPServer(BaseModel):
|
|
| 51 |
|
| 52 |
class RemoteMCPServer(BaseModel):
|
| 53 |
url: str
|
| 54 |
-
transport: Literal["streamable-http", "sse", "http"] | None = None
|
| 55 |
headers: dict[str, str] = Field(default_factory=dict)
|
|
|
|
| 56 |
|
| 57 |
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
| 58 |
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
@@ -69,7 +70,7 @@ class RemoteMCPServer(BaseModel):
|
|
| 69 |
|
| 70 |
|
| 71 |
class MCPConfig(BaseModel):
|
| 72 |
-
mcpServers: dict[str,
|
| 73 |
|
| 74 |
@classmethod
|
| 75 |
def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
|
|
|
|
| 32 |
return "streamable-http"
|
| 33 |
|
| 34 |
|
| 35 |
+
class StdioMCPServer(BaseModel):
|
| 36 |
command: str
|
| 37 |
args: list[str] = Field(default_factory=list)
|
| 38 |
env: dict[str, Any] = Field(default_factory=dict)
|
| 39 |
cwd: str | None = None
|
| 40 |
+
transport: Literal["stdio"] = "stdio"
|
| 41 |
|
| 42 |
def to_transport(self) -> StdioTransport:
|
| 43 |
from fastmcp.client.transports import StdioTransport
|
|
|
|
| 52 |
|
| 53 |
class RemoteMCPServer(BaseModel):
|
| 54 |
url: str
|
|
|
|
| 55 |
headers: dict[str, str] = Field(default_factory=dict)
|
| 56 |
+
transport: Literal["streamable-http", "sse", "http"] | None = None
|
| 57 |
|
| 58 |
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
| 59 |
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
class MCPConfig(BaseModel):
|
| 73 |
+
mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
|
| 74 |
|
| 75 |
@classmethod
|
| 76 |
def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
|
tests/client/test_client.py
CHANGED
|
@@ -728,7 +728,19 @@ class TestInferTransport:
|
|
| 728 |
assert transport.transport.command == "echo"
|
| 729 |
assert transport.transport.args == ["hello"]
|
| 730 |
|
| 731 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
config = {
|
| 733 |
"mcpServers": {
|
| 734 |
"local": {
|
|
@@ -744,4 +756,17 @@ class TestInferTransport:
|
|
| 744 |
transport = infer_transport(config)
|
| 745 |
assert isinstance(transport, MCPConfigTransport)
|
| 746 |
assert isinstance(transport.transport, FastMCPTransport)
|
| 747 |
-
assert len(transport.transport.server._mounted_servers) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
assert transport.transport.command == "echo"
|
| 729 |
assert transport.transport.args == ["hello"]
|
| 730 |
|
| 731 |
+
def test_config_with_no_servers(self):
|
| 732 |
+
"""Test that an empty MCPConfig raises a ValueError."""
|
| 733 |
+
config = {"mcpServers": {}}
|
| 734 |
+
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
|
| 735 |
+
infer_transport(config)
|
| 736 |
+
|
| 737 |
+
def test_mcpconfigtransport_with_no_servers(self):
|
| 738 |
+
"""Test that MCPConfigTransport raises a ValueError when initialized with an empty config."""
|
| 739 |
+
config = {"mcpServers": {}}
|
| 740 |
+
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
|
| 741 |
+
MCPConfigTransport(config=config)
|
| 742 |
+
|
| 743 |
+
def test_infer_composite_client(self):
|
| 744 |
config = {
|
| 745 |
"mcpServers": {
|
| 746 |
"local": {
|
|
|
|
| 756 |
transport = infer_transport(config)
|
| 757 |
assert isinstance(transport, MCPConfigTransport)
|
| 758 |
assert isinstance(transport.transport, FastMCPTransport)
|
| 759 |
+
assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2
|
| 760 |
+
|
| 761 |
+
def test_infer_fastmcp_server(self, fastmcp_server):
|
| 762 |
+
"""FastMCP server instances should infer to FastMCPTransport."""
|
| 763 |
+
transport = infer_transport(fastmcp_server)
|
| 764 |
+
assert isinstance(transport, FastMCPTransport)
|
| 765 |
+
|
| 766 |
+
def test_infer_fastmcp_v1_server(self):
|
| 767 |
+
"""FastMCP 1.0 server instances should infer to FastMCPTransport."""
|
| 768 |
+
from mcp.server.fastmcp import FastMCP as FastMCP1
|
| 769 |
+
|
| 770 |
+
server = FastMCP1()
|
| 771 |
+
transport = infer_transport(server)
|
| 772 |
+
assert isinstance(transport, FastMCPTransport)
|
tests/deprecated/test_route_type_ignore.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the deprecated RouteType.IGNORE."""
|
| 2 |
+
|
| 3 |
+
import warnings
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from fastmcp.server.openapi import (
|
| 9 |
+
FastMCPOpenAPI,
|
| 10 |
+
MCPType,
|
| 11 |
+
RouteMap,
|
| 12 |
+
RouteType,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_route_type_ignore_deprecation_warning():
|
| 17 |
+
"""Test that using RouteType.IGNORE emits a deprecation warning."""
|
| 18 |
+
# Let's manually capture the warnings
|
| 19 |
+
|
| 20 |
+
# Record all warnings
|
| 21 |
+
with warnings.catch_warnings(record=True) as recorded:
|
| 22 |
+
# Make sure warnings are always triggered
|
| 23 |
+
warnings.simplefilter("always")
|
| 24 |
+
|
| 25 |
+
# Create a RouteMap with RouteType.IGNORE
|
| 26 |
+
route_map = RouteMap(
|
| 27 |
+
methods=["GET"], pattern=r"^/analytics$", route_type=RouteType.IGNORE
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# Check for the expected warnings in the recorded warnings
|
| 31 |
+
route_type_warning = False
|
| 32 |
+
ignore_warning = False
|
| 33 |
+
|
| 34 |
+
for w in recorded:
|
| 35 |
+
if issubclass(w.category, DeprecationWarning):
|
| 36 |
+
message = str(w.message)
|
| 37 |
+
if "route_type' parameter is deprecated" in message:
|
| 38 |
+
route_type_warning = True
|
| 39 |
+
if "RouteType.IGNORE is deprecated" in message:
|
| 40 |
+
ignore_warning = True
|
| 41 |
+
|
| 42 |
+
# Make sure both warnings were triggered
|
| 43 |
+
assert route_type_warning, "Missing 'route_type' deprecation warning"
|
| 44 |
+
assert ignore_warning, "Missing 'RouteType.IGNORE' deprecation warning"
|
| 45 |
+
|
| 46 |
+
# Verify that RouteType.IGNORE was converted to MCPType.EXCLUDE
|
| 47 |
+
assert route_map.mcp_type == MCPType.EXCLUDE
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TestRouteTypeIgnoreDeprecation:
|
| 51 |
+
"""Test class for the deprecated RouteType.IGNORE."""
|
| 52 |
+
|
| 53 |
+
@pytest.fixture
|
| 54 |
+
def basic_openapi_spec(self) -> dict:
|
| 55 |
+
"""Create a simple OpenAPI spec for testing."""
|
| 56 |
+
return {
|
| 57 |
+
"openapi": "3.0.0",
|
| 58 |
+
"info": {"title": "Test API", "version": "1.0.0"},
|
| 59 |
+
"paths": {
|
| 60 |
+
"/items": {
|
| 61 |
+
"get": {
|
| 62 |
+
"operationId": "get_items",
|
| 63 |
+
"summary": "Get all items",
|
| 64 |
+
"responses": {"200": {"description": "Success"}},
|
| 65 |
+
}
|
| 66 |
+
},
|
| 67 |
+
"/analytics": {
|
| 68 |
+
"get": {
|
| 69 |
+
"operationId": "get_analytics",
|
| 70 |
+
"summary": "Get analytics data",
|
| 71 |
+
"responses": {"200": {"description": "Success"}},
|
| 72 |
+
}
|
| 73 |
+
},
|
| 74 |
+
},
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
@pytest.fixture
|
| 78 |
+
async def mock_client(self) -> httpx.AsyncClient:
|
| 79 |
+
"""Create a mock client for testing."""
|
| 80 |
+
|
| 81 |
+
async def _responder(request):
|
| 82 |
+
return httpx.Response(200, json={"success": True})
|
| 83 |
+
|
| 84 |
+
return httpx.AsyncClient(transport=httpx.MockTransport(_responder))
|
| 85 |
+
|
| 86 |
+
async def test_route_type_ignore_conversion(self, basic_openapi_spec, mock_client):
|
| 87 |
+
"""Test that routes with RouteType.IGNORE are properly excluded."""
|
| 88 |
+
# Capture the deprecation warning without checking the exact message
|
| 89 |
+
with pytest.warns(DeprecationWarning):
|
| 90 |
+
server = FastMCPOpenAPI(
|
| 91 |
+
openapi_spec=basic_openapi_spec,
|
| 92 |
+
client=mock_client,
|
| 93 |
+
route_maps=[
|
| 94 |
+
# Use the deprecated RouteType.IGNORE
|
| 95 |
+
RouteMap(
|
| 96 |
+
methods=["GET"],
|
| 97 |
+
pattern=r"^/analytics$",
|
| 98 |
+
route_type=RouteType.IGNORE,
|
| 99 |
+
),
|
| 100 |
+
# Make everything else a resource
|
| 101 |
+
RouteMap(
|
| 102 |
+
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
|
| 103 |
+
),
|
| 104 |
+
],
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Check that the analytics route was excluded (converted from IGNORE to EXCLUDE)
|
| 108 |
+
resources = await server.get_resources()
|
| 109 |
+
resource_uris = [str(r.uri) for r in resources.values()]
|
| 110 |
+
|
| 111 |
+
# Analytics should be excluded
|
| 112 |
+
assert "resource://openapi/get_items" in resource_uris
|
| 113 |
+
assert "resource://openapi/get_analytics" not in resource_uris
|
tests/server/http/test_custom_routes.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from starlette.requests import Request
|
| 3 |
+
from starlette.responses import JSONResponse
|
| 4 |
+
from starlette.routing import Route
|
| 5 |
+
|
| 6 |
+
from fastmcp import FastMCP
|
| 7 |
+
from fastmcp.server.http import create_sse_app, create_streamable_http_app
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestCustomRoutes:
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def server_with_custom_route(self):
|
| 13 |
+
"""Create a FastMCP server with a custom route."""
|
| 14 |
+
server = FastMCP()
|
| 15 |
+
|
| 16 |
+
@server.custom_route("/custom-route", methods=["GET"])
|
| 17 |
+
async def custom_route(request: Request):
|
| 18 |
+
return JSONResponse({"message": "custom route"})
|
| 19 |
+
|
| 20 |
+
return server
|
| 21 |
+
|
| 22 |
+
def test_custom_routes_via_server_http_app(self, server_with_custom_route):
|
| 23 |
+
"""Test that custom routes are included when using server.http_app()."""
|
| 24 |
+
# Get the app via server.http_app()
|
| 25 |
+
app = server_with_custom_route.http_app()
|
| 26 |
+
|
| 27 |
+
# Verify that the custom route is included
|
| 28 |
+
custom_route_found = False
|
| 29 |
+
for route in app.routes:
|
| 30 |
+
if isinstance(route, Route) and route.path == "/custom-route":
|
| 31 |
+
custom_route_found = True
|
| 32 |
+
break
|
| 33 |
+
|
| 34 |
+
assert custom_route_found, "Custom route was not found in app routes"
|
| 35 |
+
|
| 36 |
+
def test_custom_routes_via_streamable_http_app_direct(
|
| 37 |
+
self, server_with_custom_route
|
| 38 |
+
):
|
| 39 |
+
"""Test that custom routes are included when using create_streamable_http_app directly."""
|
| 40 |
+
# Create the app by calling the constructor function directly
|
| 41 |
+
app = create_streamable_http_app(
|
| 42 |
+
server=server_with_custom_route, streamable_http_path="/api"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Verify that the custom route is included
|
| 46 |
+
custom_route_found = False
|
| 47 |
+
for route in app.routes:
|
| 48 |
+
if isinstance(route, Route) and route.path == "/custom-route":
|
| 49 |
+
custom_route_found = True
|
| 50 |
+
break
|
| 51 |
+
|
| 52 |
+
assert custom_route_found, "Custom route was not found in app routes"
|
| 53 |
+
|
| 54 |
+
def test_custom_routes_via_sse_app_direct(self, server_with_custom_route):
|
| 55 |
+
"""Test that custom routes are included when using create_sse_app directly."""
|
| 56 |
+
# Create the app by calling the constructor function directly
|
| 57 |
+
app = create_sse_app(
|
| 58 |
+
server=server_with_custom_route, message_path="/message", sse_path="/sse"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Verify that the custom route is included
|
| 62 |
+
custom_route_found = False
|
| 63 |
+
for route in app.routes:
|
| 64 |
+
if isinstance(route, Route) and route.path == "/custom-route":
|
| 65 |
+
custom_route_found = True
|
| 66 |
+
break
|
| 67 |
+
|
| 68 |
+
assert custom_route_found, "Custom route was not found in app routes"
|
| 69 |
+
|
| 70 |
+
def test_multiple_custom_routes(
|
| 71 |
+
self,
|
| 72 |
+
):
|
| 73 |
+
"""Test that multiple custom routes are included in both methods."""
|
| 74 |
+
server = FastMCP()
|
| 75 |
+
|
| 76 |
+
custom_paths = ["/route1", "/route2", "/route3"]
|
| 77 |
+
|
| 78 |
+
# Add multiple custom routes
|
| 79 |
+
for path in custom_paths:
|
| 80 |
+
|
| 81 |
+
@server.custom_route(path, methods=["GET"])
|
| 82 |
+
async def custom_route(request: Request):
|
| 83 |
+
return JSONResponse({"message": f"route {path}"})
|
| 84 |
+
|
| 85 |
+
# Test with server.http_app()
|
| 86 |
+
app1 = server.http_app()
|
| 87 |
+
|
| 88 |
+
# Test with direct constructor call
|
| 89 |
+
app2 = create_streamable_http_app(server=server, streamable_http_path="/api")
|
| 90 |
+
|
| 91 |
+
# Check all routes are in both apps
|
| 92 |
+
for path in custom_paths:
|
| 93 |
+
# Check in app1
|
| 94 |
+
route_in_app1 = any(
|
| 95 |
+
isinstance(route, Route) and route.path == path for route in app1.routes
|
| 96 |
+
)
|
| 97 |
+
assert route_in_app1, f"Route {path} not found in server.http_app()"
|
| 98 |
+
|
| 99 |
+
# Check in app2
|
| 100 |
+
route_in_app2 = any(
|
| 101 |
+
isinstance(route, Route) and route.path == path for route in app2.routes
|
| 102 |
+
)
|
| 103 |
+
assert route_in_app2, (
|
| 104 |
+
f"Route {path} not found in create_streamable_http_app()"
|
| 105 |
+
)
|
tests/server/{test_http_dependencies.py → http/test_http_dependencies.py}
RENAMED
|
File without changes
|
tests/server/{test_http_middleware.py → http/test_http_middleware.py}
RENAMED
|
File without changes
|
tests/server/{test_openapi.py → openapi/test_openapi.py}
RENAMED
|
@@ -18,11 +18,11 @@ from fastmcp.client import Client
|
|
| 18 |
from fastmcp.exceptions import ToolError
|
| 19 |
from fastmcp.server.openapi import (
|
| 20 |
FastMCPOpenAPI,
|
|
|
|
| 21 |
OpenAPIResource,
|
| 22 |
OpenAPIResourceTemplate,
|
| 23 |
OpenAPITool,
|
| 24 |
RouteMap,
|
| 25 |
-
RouteType,
|
| 26 |
)
|
| 27 |
|
| 28 |
|
|
@@ -304,7 +304,7 @@ class TestTools:
|
|
| 304 |
openapi_spec=openapi_spec,
|
| 305 |
client=api_client,
|
| 306 |
route_maps=[
|
| 307 |
-
RouteMap(methods=["GET"], pattern=r".*",
|
| 308 |
],
|
| 309 |
)
|
| 310 |
async with Client(mcp_server) as client:
|
|
@@ -956,9 +956,7 @@ async def test_empty_query_parameters_not_sent(
|
|
| 956 |
mcp_server = FastMCPOpenAPI(
|
| 957 |
openapi_spec=openapi_spec,
|
| 958 |
client=api_client,
|
| 959 |
-
route_maps=[
|
| 960 |
-
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
|
| 961 |
-
],
|
| 962 |
)
|
| 963 |
|
| 964 |
# Call the search tool with mixed parameter values
|
|
@@ -1499,17 +1497,15 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1499 |
# Create custom route mappings
|
| 1500 |
route_maps = [
|
| 1501 |
# Map GET /items to Resource
|
| 1502 |
-
RouteMap(
|
| 1503 |
-
methods=["GET"], pattern=r"^/items$", route_type=RouteType.RESOURCE
|
| 1504 |
-
),
|
| 1505 |
# Map GET /items/{item_id} to ResourceTemplate
|
| 1506 |
RouteMap(
|
| 1507 |
methods=["GET"],
|
| 1508 |
pattern=r"^/items/\{.*\}$",
|
| 1509 |
-
|
| 1510 |
),
|
| 1511 |
# Map POST /items to Tool
|
| 1512 |
-
RouteMap(methods=["POST"], pattern=r"^/items$",
|
| 1513 |
]
|
| 1514 |
|
| 1515 |
# Create FastMCP server with the OpenAPI spec and custom route mappings
|
|
@@ -1918,7 +1914,7 @@ class TestRouteMapWildcard:
|
|
| 1918 |
):
|
| 1919 |
"""Test that a RouteMap with methods='*' matches all HTTP methods."""
|
| 1920 |
# Create a single route map with wildcard method
|
| 1921 |
-
route_maps = [RouteMap(methods="*", pattern=r".*",
|
| 1922 |
|
| 1923 |
mcp = FastMCPOpenAPI(
|
| 1924 |
openapi_spec=basic_openapi_spec,
|
|
@@ -1930,225 +1926,223 @@ class TestRouteMapWildcard:
|
|
| 1930 |
tools = mcp._tool_manager.list_tools()
|
| 1931 |
tool_names = {tool.name for tool in tools}
|
| 1932 |
|
| 1933 |
-
# Check that all operations
|
| 1934 |
expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
|
| 1935 |
assert tool_names == expected_tools
|
| 1936 |
|
| 1937 |
-
# No resources or templates should be created
|
| 1938 |
-
resources = mcp._resource_manager.get_resources()
|
| 1939 |
-
templates = mcp._resource_manager.get_templates()
|
| 1940 |
-
assert len(resources) == 0
|
| 1941 |
-
assert len(templates) == 0
|
| 1942 |
-
|
| 1943 |
-
async def test_priority_specific_over_wildcard(
|
| 1944 |
-
self, basic_openapi_spec, mock_basic_client
|
| 1945 |
-
):
|
| 1946 |
-
"""Test that specific method maps take priority over wildcard."""
|
| 1947 |
-
# Create route maps with specific method first, then wildcard
|
| 1948 |
-
route_maps = [
|
| 1949 |
-
# GET operations should be mapped to resources
|
| 1950 |
-
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
|
| 1951 |
-
# All other operations should be mapped to tools
|
| 1952 |
-
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
|
| 1953 |
-
]
|
| 1954 |
-
|
| 1955 |
-
mcp = FastMCPOpenAPI(
|
| 1956 |
-
openapi_spec=basic_openapi_spec,
|
| 1957 |
-
client=mock_basic_client,
|
| 1958 |
-
route_maps=route_maps,
|
| 1959 |
-
)
|
| 1960 |
-
|
| 1961 |
-
# Check GET operations went to resources
|
| 1962 |
-
resources = mcp._resource_manager.get_resources()
|
| 1963 |
-
resource_names = {r.name for r in resources.values()}
|
| 1964 |
-
assert "getUsers" in resource_names
|
| 1965 |
-
assert "getPosts" in resource_names
|
| 1966 |
-
assert len(resources) == 2
|
| 1967 |
-
|
| 1968 |
-
# Check other operations went to tools
|
| 1969 |
-
tools = mcp._tool_manager.list_tools()
|
| 1970 |
-
tool_names = {tool.name for tool in tools}
|
| 1971 |
-
assert "createUser" in tool_names
|
| 1972 |
-
assert "createPost" in tool_names
|
| 1973 |
-
assert len(tools) == 2
|
| 1974 |
-
|
| 1975 |
-
async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client):
|
| 1976 |
-
"""Test that when wildcard is first, it matches everything."""
|
| 1977 |
-
# Create route maps with wildcard first, then specific methods
|
| 1978 |
-
route_maps = [
|
| 1979 |
-
# Wildcard first matches everything
|
| 1980 |
-
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
|
| 1981 |
-
# This should never be reached
|
| 1982 |
-
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
|
| 1983 |
-
]
|
| 1984 |
-
|
| 1985 |
-
mcp = FastMCPOpenAPI(
|
| 1986 |
-
openapi_spec=basic_openapi_spec,
|
| 1987 |
-
client=mock_basic_client,
|
| 1988 |
-
route_maps=route_maps,
|
| 1989 |
-
)
|
| 1990 |
-
|
| 1991 |
-
# All operations should be tools
|
| 1992 |
-
tools = mcp._tool_manager.list_tools()
|
| 1993 |
-
assert len(tools) == 4
|
| 1994 |
-
|
| 1995 |
-
# No resources should be created
|
| 1996 |
-
resources = mcp._resource_manager.get_resources()
|
| 1997 |
-
assert len(resources) == 0
|
| 1998 |
-
|
| 1999 |
-
async def test_wildcard_with_specific_paths(
|
| 2000 |
-
self, basic_openapi_spec, mock_basic_client
|
| 2001 |
-
):
|
| 2002 |
-
"""Test wildcard methods combined with specific path patterns."""
|
| 2003 |
-
route_maps = [
|
| 2004 |
-
# All methods on /users path -> Resources
|
| 2005 |
-
RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE),
|
| 2006 |
-
# All methods on /posts path -> Tools
|
| 2007 |
-
RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL),
|
| 2008 |
-
]
|
| 2009 |
-
|
| 2010 |
-
mcp = FastMCPOpenAPI(
|
| 2011 |
-
openapi_spec=basic_openapi_spec,
|
| 2012 |
-
client=mock_basic_client,
|
| 2013 |
-
route_maps=route_maps,
|
| 2014 |
-
)
|
| 2015 |
|
| 2016 |
-
|
| 2017 |
-
|
| 2018 |
-
resource_names = {r.name for r in resources.values()}
|
| 2019 |
-
assert "getUsers" in resource_names
|
| 2020 |
-
assert "createUser" in resource_names
|
| 2021 |
-
assert len(resources) == 2
|
| 2022 |
-
|
| 2023 |
-
# Check /posts operations went to tools
|
| 2024 |
-
tools = mcp._tool_manager.list_tools()
|
| 2025 |
-
tool_names = {tool.name for tool in tools}
|
| 2026 |
-
assert "getPosts" in tool_names
|
| 2027 |
-
assert "createPost" in tool_names
|
| 2028 |
-
assert len(tools) == 2
|
| 2029 |
-
|
| 2030 |
-
|
| 2031 |
-
class TestAllRoutesAsTools:
|
| 2032 |
-
"""Tests for the all_routes_as_tools parameter in FastMCP class methods."""
|
| 2033 |
|
| 2034 |
@pytest.fixture
|
| 2035 |
-
def
|
| 2036 |
-
"""
|
| 2037 |
return {
|
| 2038 |
"openapi": "3.1.0",
|
| 2039 |
-
"info": {"title": "
|
| 2040 |
"paths": {
|
| 2041 |
-
"/
|
| 2042 |
"get": {
|
| 2043 |
-
"operationId": "
|
|
|
|
| 2044 |
"responses": {"200": {"description": "Success"}},
|
| 2045 |
},
|
| 2046 |
"post": {
|
| 2047 |
-
"operationId": "
|
|
|
|
| 2048 |
"responses": {"201": {"description": "Created"}},
|
| 2049 |
},
|
| 2050 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2051 |
},
|
| 2052 |
}
|
| 2053 |
|
| 2054 |
@pytest.fixture
|
| 2055 |
async def mock_client(self) -> httpx.AsyncClient:
|
| 2056 |
-
"""
|
| 2057 |
|
| 2058 |
async def _responder(request):
|
| 2059 |
-
return httpx.Response(200, json={"
|
| 2060 |
|
| 2061 |
transport = httpx.MockTransport(_responder)
|
| 2062 |
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
| 2063 |
|
| 2064 |
-
async def
|
| 2065 |
-
"""Test
|
| 2066 |
-
#
|
| 2067 |
-
|
| 2068 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2069 |
)
|
| 2070 |
|
| 2071 |
-
#
|
| 2072 |
-
tools = server._tool_manager.
|
| 2073 |
-
tool_names = {t.name for t in tools}
|
| 2074 |
|
| 2075 |
-
|
| 2076 |
-
|
| 2077 |
-
|
|
|
|
|
|
|
|
|
|
| 2078 |
|
| 2079 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2080 |
resources = server._resource_manager.get_resources()
|
| 2081 |
-
|
| 2082 |
-
assert len(resources) == 0
|
| 2083 |
-
assert len(templates) == 0
|
| 2084 |
|
| 2085 |
-
|
| 2086 |
-
|
| 2087 |
-
):
|
| 2088 |
-
"""Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided."""
|
| 2089 |
-
# Try to create server with conflicting args
|
| 2090 |
-
with pytest.raises(
|
| 2091 |
-
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
|
| 2092 |
-
):
|
| 2093 |
-
FastMCP.from_openapi(
|
| 2094 |
-
openapi_spec=simple_api_spec,
|
| 2095 |
-
client=mock_client,
|
| 2096 |
-
all_routes_as_tools=True,
|
| 2097 |
-
route_maps=[
|
| 2098 |
-
RouteMap(
|
| 2099 |
-
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
|
| 2100 |
-
)
|
| 2101 |
-
],
|
| 2102 |
-
)
|
| 2103 |
|
| 2104 |
-
|
| 2105 |
-
""
|
| 2106 |
-
|
| 2107 |
-
app = FastAPI(title="Test FastAPI")
|
| 2108 |
|
| 2109 |
-
|
| 2110 |
-
|
| 2111 |
-
|
|
|
|
|
|
|
| 2112 |
|
| 2113 |
-
|
| 2114 |
-
|
| 2115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2116 |
|
| 2117 |
-
|
| 2118 |
-
|
|
|
|
|
|
|
|
|
|
| 2119 |
|
| 2120 |
-
|
| 2121 |
-
|
| 2122 |
|
| 2123 |
-
|
| 2124 |
-
|
| 2125 |
|
| 2126 |
-
#
|
| 2127 |
-
|
| 2128 |
-
|
| 2129 |
-
|
| 2130 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2131 |
|
| 2132 |
-
# No resources or templates should be created
|
| 2133 |
resources = server._resource_manager.get_resources()
|
| 2134 |
-
|
| 2135 |
-
assert len(resources) == 0
|
| 2136 |
-
assert len(templates) == 0
|
| 2137 |
|
| 2138 |
-
|
| 2139 |
-
""
|
| 2140 |
-
app = FastAPI(title="Test FastAPI")
|
| 2141 |
|
| 2142 |
-
#
|
| 2143 |
-
|
| 2144 |
-
|
| 2145 |
-
)
|
| 2146 |
-
|
| 2147 |
-
|
| 2148 |
-
|
| 2149 |
-
|
| 2150 |
-
|
| 2151 |
-
|
| 2152 |
-
|
| 2153 |
-
|
| 2154 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from fastmcp.exceptions import ToolError
|
| 19 |
from fastmcp.server.openapi import (
|
| 20 |
FastMCPOpenAPI,
|
| 21 |
+
MCPType,
|
| 22 |
OpenAPIResource,
|
| 23 |
OpenAPIResourceTemplate,
|
| 24 |
OpenAPITool,
|
| 25 |
RouteMap,
|
|
|
|
| 26 |
)
|
| 27 |
|
| 28 |
|
|
|
|
| 304 |
openapi_spec=openapi_spec,
|
| 305 |
client=api_client,
|
| 306 |
route_maps=[
|
| 307 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)
|
| 308 |
],
|
| 309 |
)
|
| 310 |
async with Client(mcp_server) as client:
|
|
|
|
| 956 |
mcp_server = FastMCPOpenAPI(
|
| 957 |
openapi_spec=openapi_spec,
|
| 958 |
client=api_client,
|
| 959 |
+
route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
|
|
|
|
|
|
|
| 960 |
)
|
| 961 |
|
| 962 |
# Call the search tool with mixed parameter values
|
|
|
|
| 1497 |
# Create custom route mappings
|
| 1498 |
route_maps = [
|
| 1499 |
# Map GET /items to Resource
|
| 1500 |
+
RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE),
|
|
|
|
|
|
|
| 1501 |
# Map GET /items/{item_id} to ResourceTemplate
|
| 1502 |
RouteMap(
|
| 1503 |
methods=["GET"],
|
| 1504 |
pattern=r"^/items/\{.*\}$",
|
| 1505 |
+
mcp_type=MCPType.RESOURCE_TEMPLATE,
|
| 1506 |
),
|
| 1507 |
# Map POST /items to Tool
|
| 1508 |
+
RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL),
|
| 1509 |
]
|
| 1510 |
|
| 1511 |
# Create FastMCP server with the OpenAPI spec and custom route mappings
|
|
|
|
| 1914 |
):
|
| 1915 |
"""Test that a RouteMap with methods='*' matches all HTTP methods."""
|
| 1916 |
# Create a single route map with wildcard method
|
| 1917 |
+
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
|
| 1918 |
|
| 1919 |
mcp = FastMCPOpenAPI(
|
| 1920 |
openapi_spec=basic_openapi_spec,
|
|
|
|
| 1926 |
tools = mcp._tool_manager.list_tools()
|
| 1927 |
tool_names = {tool.name for tool in tools}
|
| 1928 |
|
| 1929 |
+
# Check that all 4 operations became tools
|
| 1930 |
expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
|
| 1931 |
assert tool_names == expected_tools
|
| 1932 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1933 |
|
| 1934 |
+
class TestRouteMapTags:
|
| 1935 |
+
"""Tests for RouteMap tags functionality."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1936 |
|
| 1937 |
@pytest.fixture
|
| 1938 |
+
def tagged_openapi_spec(self) -> dict:
|
| 1939 |
+
"""Create an OpenAPI spec with various tags for testing."""
|
| 1940 |
return {
|
| 1941 |
"openapi": "3.1.0",
|
| 1942 |
+
"info": {"title": "Tagged API", "version": "1.0.0"},
|
| 1943 |
"paths": {
|
| 1944 |
+
"/users": {
|
| 1945 |
"get": {
|
| 1946 |
+
"operationId": "getUsers",
|
| 1947 |
+
"tags": ["users", "public"],
|
| 1948 |
"responses": {"200": {"description": "Success"}},
|
| 1949 |
},
|
| 1950 |
"post": {
|
| 1951 |
+
"operationId": "createUser",
|
| 1952 |
+
"tags": ["users", "admin"],
|
| 1953 |
"responses": {"201": {"description": "Created"}},
|
| 1954 |
},
|
| 1955 |
},
|
| 1956 |
+
"/admin/stats": {
|
| 1957 |
+
"get": {
|
| 1958 |
+
"operationId": "getAdminStats",
|
| 1959 |
+
"tags": ["admin", "internal"],
|
| 1960 |
+
"responses": {"200": {"description": "Success"}},
|
| 1961 |
+
}
|
| 1962 |
+
},
|
| 1963 |
+
"/health": {
|
| 1964 |
+
"get": {
|
| 1965 |
+
"operationId": "getHealth",
|
| 1966 |
+
"tags": ["public"],
|
| 1967 |
+
"responses": {"200": {"description": "Success"}},
|
| 1968 |
+
}
|
| 1969 |
+
},
|
| 1970 |
+
"/metrics": {
|
| 1971 |
+
"get": {
|
| 1972 |
+
"operationId": "getMetrics",
|
| 1973 |
+
"responses": {"200": {"description": "Success"}},
|
| 1974 |
+
}
|
| 1975 |
+
},
|
| 1976 |
},
|
| 1977 |
}
|
| 1978 |
|
| 1979 |
@pytest.fixture
|
| 1980 |
async def mock_client(self) -> httpx.AsyncClient:
|
| 1981 |
+
"""Create a simple mock client."""
|
| 1982 |
|
| 1983 |
async def _responder(request):
|
| 1984 |
+
return httpx.Response(200, json={"status": "ok"})
|
| 1985 |
|
| 1986 |
transport = httpx.MockTransport(_responder)
|
| 1987 |
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
| 1988 |
|
| 1989 |
+
async def test_tags_as_tools(self, tagged_openapi_spec, mock_client):
|
| 1990 |
+
"""Test that routes with specific tags are converted to tools."""
|
| 1991 |
+
# Convert routes with "admin" tag to tools
|
| 1992 |
+
route_maps = [
|
| 1993 |
+
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
|
| 1994 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 1995 |
+
]
|
| 1996 |
+
|
| 1997 |
+
server = FastMCPOpenAPI(
|
| 1998 |
+
openapi_spec=tagged_openapi_spec,
|
| 1999 |
+
client=mock_client,
|
| 2000 |
+
route_maps=route_maps,
|
| 2001 |
)
|
| 2002 |
|
| 2003 |
+
# Check that admin-tagged routes are tools
|
| 2004 |
+
tools = server._tool_manager.get_tools()
|
| 2005 |
+
tool_names = {t.name for t in tools.values()}
|
| 2006 |
|
| 2007 |
+
resources = server._resource_manager.get_resources()
|
| 2008 |
+
resource_names = {r.name for r in resources.values()}
|
| 2009 |
+
|
| 2010 |
+
# Routes with "admin" tag should be tools
|
| 2011 |
+
assert "createUser" in tool_names
|
| 2012 |
+
assert "getAdminStats" in tool_names
|
| 2013 |
|
| 2014 |
+
# Routes without "admin" tag should be resources
|
| 2015 |
+
assert "getUsers" in resource_names
|
| 2016 |
+
assert "getHealth" in resource_names
|
| 2017 |
+
assert "getMetrics" in resource_names
|
| 2018 |
+
|
| 2019 |
+
async def test_exclude_tags(self, tagged_openapi_spec, mock_client):
|
| 2020 |
+
"""Test that routes with specific tags are excluded."""
|
| 2021 |
+
# Exclude routes with "internal" tag
|
| 2022 |
+
route_maps = [
|
| 2023 |
+
RouteMap(
|
| 2024 |
+
methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}
|
| 2025 |
+
),
|
| 2026 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 2027 |
+
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
|
| 2028 |
+
]
|
| 2029 |
+
|
| 2030 |
+
server = FastMCPOpenAPI(
|
| 2031 |
+
openapi_spec=tagged_openapi_spec,
|
| 2032 |
+
client=mock_client,
|
| 2033 |
+
route_maps=route_maps,
|
| 2034 |
+
)
|
| 2035 |
+
|
| 2036 |
+
# Check that internal-tagged routes are excluded
|
| 2037 |
resources = server._resource_manager.get_resources()
|
| 2038 |
+
resource_names = {r.name for r in resources.values()}
|
|
|
|
|
|
|
| 2039 |
|
| 2040 |
+
tools = server._tool_manager.get_tools()
|
| 2041 |
+
tool_names = {t.name for t in tools.values()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2042 |
|
| 2043 |
+
# Internal-tagged route should be excluded
|
| 2044 |
+
assert "getAdminStats" not in resource_names
|
| 2045 |
+
assert "getAdminStats" not in tool_names
|
|
|
|
| 2046 |
|
| 2047 |
+
# Other routes should still be present
|
| 2048 |
+
assert "getUsers" in resource_names
|
| 2049 |
+
assert "getHealth" in resource_names
|
| 2050 |
+
assert "getMetrics" in resource_names
|
| 2051 |
+
assert "createUser" in tool_names
|
| 2052 |
|
| 2053 |
+
async def test_multiple_tags_and_condition(self, tagged_openapi_spec, mock_client):
|
| 2054 |
+
"""Test that routes must have ALL specified tags (AND condition)."""
|
| 2055 |
+
# Routes must have BOTH "users" AND "admin" tags
|
| 2056 |
+
route_maps = [
|
| 2057 |
+
RouteMap(
|
| 2058 |
+
methods="*",
|
| 2059 |
+
pattern=r".*",
|
| 2060 |
+
mcp_type=MCPType.TOOL,
|
| 2061 |
+
tags={"users", "admin"},
|
| 2062 |
+
),
|
| 2063 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 2064 |
+
]
|
| 2065 |
|
| 2066 |
+
server = FastMCPOpenAPI(
|
| 2067 |
+
openapi_spec=tagged_openapi_spec,
|
| 2068 |
+
client=mock_client,
|
| 2069 |
+
route_maps=route_maps,
|
| 2070 |
+
)
|
| 2071 |
|
| 2072 |
+
tools = server._tool_manager.get_tools()
|
| 2073 |
+
tool_names = {t.name for t in tools.values()}
|
| 2074 |
|
| 2075 |
+
resources = server._resource_manager.get_resources()
|
| 2076 |
+
resource_names = {r.name for r in resources.values()}
|
| 2077 |
|
| 2078 |
+
# Only createUser has both "users" AND "admin" tags
|
| 2079 |
+
assert "createUser" in tool_names
|
| 2080 |
+
|
| 2081 |
+
# Other routes should be resources
|
| 2082 |
+
assert "getUsers" in resource_names # has "users" but not "admin"
|
| 2083 |
+
assert "getAdminStats" in resource_names # has "admin" but not "users"
|
| 2084 |
+
assert "getHealth" in resource_names
|
| 2085 |
+
assert "getMetrics" in resource_names
|
| 2086 |
+
|
| 2087 |
+
async def test_pattern_and_tags_combination(self, tagged_openapi_spec, mock_client):
|
| 2088 |
+
"""Test that both pattern and tags must be satisfied."""
|
| 2089 |
+
# Routes matching pattern AND having specific tags
|
| 2090 |
+
route_maps = [
|
| 2091 |
+
RouteMap(
|
| 2092 |
+
methods="*",
|
| 2093 |
+
pattern=r".*/admin/.*",
|
| 2094 |
+
mcp_type=MCPType.TOOL,
|
| 2095 |
+
tags={"admin"},
|
| 2096 |
+
),
|
| 2097 |
+
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
| 2098 |
+
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
|
| 2099 |
+
]
|
| 2100 |
+
|
| 2101 |
+
server = FastMCPOpenAPI(
|
| 2102 |
+
openapi_spec=tagged_openapi_spec,
|
| 2103 |
+
client=mock_client,
|
| 2104 |
+
route_maps=route_maps,
|
| 2105 |
+
)
|
| 2106 |
+
|
| 2107 |
+
tools = server._tool_manager.get_tools()
|
| 2108 |
+
tool_names = {t.name for t in tools.values()}
|
| 2109 |
|
|
|
|
| 2110 |
resources = server._resource_manager.get_resources()
|
| 2111 |
+
resource_names = {r.name for r in resources.values()}
|
|
|
|
|
|
|
| 2112 |
|
| 2113 |
+
# Only getAdminStats matches both /admin/ pattern AND "admin" tag
|
| 2114 |
+
assert "getAdminStats" in tool_names
|
|
|
|
| 2115 |
|
| 2116 |
+
# createUser has "admin" tag but doesn't match pattern, so it becomes a tool via POST rule
|
| 2117 |
+
assert "createUser" in tool_names
|
| 2118 |
+
|
| 2119 |
+
# Other routes should be resources (GET)
|
| 2120 |
+
assert "getUsers" in resource_names
|
| 2121 |
+
assert "getHealth" in resource_names
|
| 2122 |
+
assert "getMetrics" in resource_names
|
| 2123 |
+
|
| 2124 |
+
async def test_empty_tags_ignored(self, tagged_openapi_spec, mock_client):
|
| 2125 |
+
"""Test that empty tags set is ignored (matches all routes)."""
|
| 2126 |
+
# Empty tags should match all routes
|
| 2127 |
+
route_maps = [
|
| 2128 |
+
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags=set()),
|
| 2129 |
+
]
|
| 2130 |
+
|
| 2131 |
+
server = FastMCPOpenAPI(
|
| 2132 |
+
openapi_spec=tagged_openapi_spec,
|
| 2133 |
+
client=mock_client,
|
| 2134 |
+
route_maps=route_maps,
|
| 2135 |
+
)
|
| 2136 |
+
|
| 2137 |
+
tools = server._tool_manager.get_tools()
|
| 2138 |
+
tool_names = {t.name for t in tools.values()}
|
| 2139 |
+
|
| 2140 |
+
# All routes should be tools since empty tags matches everything
|
| 2141 |
+
expected_tools = {
|
| 2142 |
+
"getUsers",
|
| 2143 |
+
"createUser",
|
| 2144 |
+
"getAdminStats",
|
| 2145 |
+
"getHealth",
|
| 2146 |
+
"getMetrics",
|
| 2147 |
+
}
|
| 2148 |
+
assert tool_names == expected_tools
|
tests/server/{test_openapi_path_parameters.py → openapi/test_openapi_path_parameters.py}
RENAMED
|
@@ -6,7 +6,7 @@ import pytest
|
|
| 6 |
from fastapi import FastAPI, Query
|
| 7 |
|
| 8 |
from fastmcp import Client, FastMCP
|
| 9 |
-
from fastmcp.server.openapi import
|
| 10 |
from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo
|
| 11 |
|
| 12 |
|
|
@@ -286,9 +286,7 @@ async def test_array_query_param_with_fastapi():
|
|
| 286 |
# Create a FastMCP server from the FastAPI app
|
| 287 |
mcp = FastMCP.from_fastapi(
|
| 288 |
app,
|
| 289 |
-
route_maps=[
|
| 290 |
-
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
|
| 291 |
-
],
|
| 292 |
)
|
| 293 |
|
| 294 |
# Test with the client
|
|
|
|
| 6 |
from fastapi import FastAPI, Query
|
| 7 |
|
| 8 |
from fastmcp import Client, FastMCP
|
| 9 |
+
from fastmcp.server.openapi import MCPType, OpenAPITool, RouteMap
|
| 10 |
from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo
|
| 11 |
|
| 12 |
|
|
|
|
| 286 |
# Create a FastMCP server from the FastAPI app
|
| 287 |
mcp = FastMCP.from_fastapi(
|
| 288 |
app,
|
| 289 |
+
route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
|
|
|
|
|
|
|
| 290 |
)
|
| 291 |
|
| 292 |
# Test with the client
|
tests/server/test_context.py
CHANGED
|
@@ -2,9 +2,11 @@ import warnings
|
|
| 2 |
from unittest.mock import MagicMock, patch
|
| 3 |
|
| 4 |
import pytest
|
|
|
|
| 5 |
from starlette.requests import Request
|
| 6 |
|
| 7 |
from fastmcp.server.context import Context
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
class TestContextDeprecations:
|
|
@@ -57,3 +59,30 @@ class TestContextDeprecations:
|
|
| 57 |
assert "https://gofastmcp.com/patterns/http-requests" in str(
|
| 58 |
warning.message
|
| 59 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from unittest.mock import MagicMock, patch
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
+
from mcp.types import ModelPreferences
|
| 6 |
from starlette.requests import Request
|
| 7 |
|
| 8 |
from fastmcp.server.context import Context
|
| 9 |
+
from fastmcp.server.server import FastMCP
|
| 10 |
|
| 11 |
|
| 12 |
class TestContextDeprecations:
|
|
|
|
| 59 |
assert "https://gofastmcp.com/patterns/http-requests" in str(
|
| 60 |
warning.message
|
| 61 |
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@pytest.fixture
|
| 65 |
+
def context():
|
| 66 |
+
return Context(fastmcp=FastMCP())
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TestParseModelPreferences:
|
| 70 |
+
def test_parse_model_preferences_string(self, context):
|
| 71 |
+
mp = context._parse_model_preferences("claude-3-sonnet")
|
| 72 |
+
assert isinstance(mp, ModelPreferences)
|
| 73 |
+
assert mp.hints is not None
|
| 74 |
+
assert mp.hints[0].name == "claude-3-sonnet"
|
| 75 |
+
|
| 76 |
+
def test_parse_model_preferences_list(self, context):
|
| 77 |
+
mp = context._parse_model_preferences(["claude-3-sonnet", "claude"])
|
| 78 |
+
assert isinstance(mp, ModelPreferences)
|
| 79 |
+
assert mp.hints is not None
|
| 80 |
+
assert [h.name for h in mp.hints] == ["claude-3-sonnet", "claude"]
|
| 81 |
+
|
| 82 |
+
def test_parse_model_preferences_object(self, context):
|
| 83 |
+
obj = ModelPreferences(hints=[])
|
| 84 |
+
assert context._parse_model_preferences(obj) is obj
|
| 85 |
+
|
| 86 |
+
def test_parse_model_preferences_invalid_type(self, context):
|
| 87 |
+
with pytest.raises(ValueError):
|
| 88 |
+
context._parse_model_preferences(123)
|
tests/utilities/test_mcp_config.py
CHANGED
|
@@ -9,7 +9,7 @@ from fastmcp.client.transports import (
|
|
| 9 |
StdioTransport,
|
| 10 |
StreamableHttpTransport,
|
| 11 |
)
|
| 12 |
-
from fastmcp.utilities.mcp_config import
|
| 13 |
|
| 14 |
|
| 15 |
def test_parse_single_stdio_config():
|
|
@@ -89,7 +89,7 @@ def test_parse_multiple_servers():
|
|
| 89 |
assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
|
| 90 |
assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
|
| 91 |
|
| 92 |
-
assert isinstance(mcp_config.mcpServers["test_server_2"],
|
| 93 |
assert isinstance(
|
| 94 |
mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
|
| 95 |
)
|
|
|
|
| 9 |
StdioTransport,
|
| 10 |
StreamableHttpTransport,
|
| 11 |
)
|
| 12 |
+
from fastmcp.utilities.mcp_config import MCPConfig, RemoteMCPServer, StdioMCPServer
|
| 13 |
|
| 14 |
|
| 15 |
def test_parse_single_stdio_config():
|
|
|
|
| 89 |
assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
|
| 90 |
assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
|
| 91 |
|
| 92 |
+
assert isinstance(mcp_config.mcpServers["test_server_2"], StdioMCPServer)
|
| 93 |
assert isinstance(
|
| 94 |
mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
|
| 95 |
)
|