mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 23:29:10 +02:00
* Upgrade to MCP 1.17+ with RFC 9728 compliance Updates FastMCP to require MCP 1.17+ and implements RFC 9728-compliant OAuth protected resource metadata URL handling. The key change is that .well-known/oauth-protected-resource endpoints are now registered at path-aware locations. For example, if an MCP server is mounted at /mcp, the metadata endpoint is now at /.well-known/oauth-protected-resource/mcp instead of /.well-known/oauth-protected-resource. This ensures proper OAuth discovery for path-based resource servers and aligns with the MCP SDK's implementation of RFC 9728 §3.1. Changes include: - Update minimum MCP version from 1.12.4 to 1.17.0 - Use build_resource_metadata_url() for RFC 9728 compliance - Configure CI to test with latest package versions (--upgrade) - Update tests for path-aware metadata URLs - Add icons field to Tool model (introduced in MCP 1.17) * Fix RemoteAuthProvider integration tests for RFC 9728 * Fix parameterized test for nested base URL paths
369 lines
13 KiB
Python
369 lines
13 KiB
Python
import base64
|
|
import json
|
|
import re
|
|
|
|
import httpx
|
|
from dirty_equals import IsStr
|
|
from fastapi import FastAPI
|
|
from mcp.types import BlobResourceContents
|
|
from pydantic import TypeAdapter
|
|
from pydantic.networks import AnyUrl
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.client import Client
|
|
from fastmcp.server.openapi import (
|
|
FastMCPOpenAPI,
|
|
MCPType,
|
|
OpenAPIResource,
|
|
OpenAPIResourceTemplate,
|
|
OpenAPITool,
|
|
RouteMap,
|
|
)
|
|
|
|
from .conftest import GET_ROUTE_MAPS, User
|
|
|
|
|
|
async def test_create_openapi_server(
|
|
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
|
):
|
|
openapi_spec = fastapi_app.openapi()
|
|
|
|
server = FastMCPOpenAPI(
|
|
openapi_spec=openapi_spec, client=api_client, name="Test App"
|
|
)
|
|
|
|
assert isinstance(server, FastMCP)
|
|
assert server.name == "Test App"
|
|
|
|
|
|
async def test_create_openapi_server_classmethod(
|
|
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
|
):
|
|
server = FastMCP.from_openapi(openapi_spec=fastapi_app.openapi(), client=api_client)
|
|
assert isinstance(server, FastMCPOpenAPI)
|
|
assert server.name == "OpenAPI FastMCP"
|
|
|
|
|
|
async def test_create_fastapi_server_classmethod(fastapi_app: FastAPI):
|
|
server = FastMCP.from_fastapi(fastapi_app)
|
|
assert isinstance(server, FastMCPOpenAPI)
|
|
assert server.name == "FastAPI App"
|
|
|
|
|
|
async def test_create_openapi_server_with_timeout(
|
|
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
|
):
|
|
server = FastMCPOpenAPI(
|
|
openapi_spec=fastapi_app.openapi(),
|
|
client=api_client,
|
|
name="Test App",
|
|
timeout=1.0,
|
|
route_maps=GET_ROUTE_MAPS,
|
|
)
|
|
assert server._timeout == 1.0
|
|
|
|
for tool in (await server.get_tools()).values():
|
|
assert isinstance(tool, OpenAPITool)
|
|
assert tool._timeout == 1.0
|
|
|
|
for resource in (await server.get_resources()).values():
|
|
assert isinstance(resource, OpenAPIResource)
|
|
assert resource._timeout == 1.0
|
|
|
|
for template in (await server.get_resource_templates()).values():
|
|
assert isinstance(template, OpenAPIResourceTemplate)
|
|
assert template._timeout == 1.0
|
|
|
|
|
|
class TestTools:
|
|
async def test_default_behavior_converts_everything_to_tools(
|
|
self, fastapi_app: FastAPI
|
|
):
|
|
"""
|
|
By default, tools exclude GET methods
|
|
"""
|
|
server = FastMCPOpenAPI.from_fastapi(fastapi_app)
|
|
assert len(await server.get_tools()) == 8
|
|
assert len(await server.get_resources()) == 0
|
|
assert len(await server.get_resource_templates()) == 0
|
|
|
|
async def test_list_tools(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
|
"""
|
|
By default, tools exclude GET methods
|
|
"""
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
tools = await client.list_tools()
|
|
assert len(tools) == 2
|
|
|
|
assert tools[0].model_dump() == dict(
|
|
name="create_user_users_post",
|
|
meta=dict(_fastmcp=dict(tags=["create", "users"])),
|
|
title=None,
|
|
annotations=None,
|
|
icons=None,
|
|
description=IsStr(regex=r"^Create a new user\..*$", regex_flags=re.DOTALL),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "title": "Name"},
|
|
"active": {"type": "boolean", "title": "Active"},
|
|
},
|
|
"required": ["name", "active"],
|
|
},
|
|
outputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer", "title": "Id"},
|
|
"name": {"type": "string", "title": "Name"},
|
|
"active": {"type": "boolean", "title": "Active"},
|
|
},
|
|
"required": ["id", "name", "active"],
|
|
"title": "User",
|
|
},
|
|
)
|
|
assert tools[1].model_dump() == dict(
|
|
name="update_user_name_users",
|
|
meta=dict(_fastmcp=dict(tags=["update", "users"])),
|
|
title=None,
|
|
annotations=None,
|
|
icons=None,
|
|
description=IsStr(
|
|
regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"user_id": {"type": "integer", "title": "User Id"},
|
|
"name": {"type": "string", "title": "Name"},
|
|
},
|
|
"required": ["user_id", "name"],
|
|
},
|
|
outputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer", "title": "Id"},
|
|
"name": {"type": "string", "title": "Name"},
|
|
"active": {"type": "boolean", "title": "Active"},
|
|
},
|
|
"required": ["id", "name", "active"],
|
|
"title": "User",
|
|
},
|
|
)
|
|
|
|
async def test_call_create_user_tool(
|
|
self,
|
|
fastmcp_openapi_server: FastMCPOpenAPI,
|
|
api_client,
|
|
):
|
|
"""
|
|
The tool created by the OpenAPI server should be the same as the original
|
|
"""
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
tool_response = await client.call_tool(
|
|
"create_user_users_post", {"name": "David", "active": False}
|
|
)
|
|
|
|
expected_user = User(id=4, name="David", active=False)
|
|
# Compare the data content since MCP client creates different class instances
|
|
assert tool_response.data.id == expected_user.id
|
|
assert tool_response.data.name == expected_user.name
|
|
assert tool_response.data.active == expected_user.active
|
|
|
|
# Check that the user was created via API
|
|
response = await api_client.get("/users")
|
|
assert len(response.json()) == 4
|
|
|
|
# Check that the user was created via MCP
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
user_response = await client.read_resource("resource://get_user_users/4")
|
|
response_text = user_response[0].text # type: ignore[attr-defined]
|
|
user = json.loads(response_text)
|
|
assert user == expected_user.model_dump()
|
|
|
|
async def test_call_update_user_name_tool(
|
|
self,
|
|
fastmcp_openapi_server: FastMCPOpenAPI,
|
|
api_client,
|
|
):
|
|
"""
|
|
The tool created by the OpenAPI server should be the same as the original
|
|
"""
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
tool_response = await client.call_tool(
|
|
"update_user_name_users",
|
|
{"user_id": 1, "name": "XYZ"},
|
|
)
|
|
|
|
expected_user = User(id=1, name="XYZ", active=True)
|
|
# Compare the data content since MCP client creates different class instances
|
|
assert tool_response.data.id == expected_user.id
|
|
assert tool_response.data.name == expected_user.name
|
|
assert tool_response.data.active == expected_user.active
|
|
|
|
# Check that the user was updated via API
|
|
response = await api_client.get("/users")
|
|
assert expected_user.model_dump() in response.json()
|
|
|
|
# Check that the user was updated via MCP
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
user_response = await client.read_resource("resource://get_user_users/1")
|
|
response_text = user_response[0].text # type: ignore[attr-defined]
|
|
user = json.loads(response_text)
|
|
assert user == expected_user.model_dump()
|
|
|
|
async def test_call_tool_return_list(
|
|
self,
|
|
fastapi_app: FastAPI,
|
|
api_client: httpx.AsyncClient,
|
|
users_db: dict[int, User],
|
|
):
|
|
"""
|
|
The tool created by the OpenAPI server should return a list of content.
|
|
"""
|
|
openapi_spec = fastapi_app.openapi()
|
|
mcp_server = FastMCPOpenAPI(
|
|
openapi_spec=openapi_spec,
|
|
client=api_client,
|
|
route_maps=[
|
|
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)
|
|
],
|
|
)
|
|
async with Client(mcp_server) as client:
|
|
tool_response = await client.call_tool("get_users_users_get", {})
|
|
# The tool response should now be unwrapped since we have output schema
|
|
assert tool_response.data == [
|
|
user.model_dump()
|
|
for user in sorted(users_db.values(), key=lambda x: x.id)
|
|
]
|
|
|
|
|
|
class TestResources:
|
|
async def test_list_resources(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
|
"""
|
|
By default, resources exclude GET methods without parameters
|
|
"""
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
resources = await client.list_resources()
|
|
assert len(resources) == 4
|
|
assert resources[0].uri == AnyUrl("resource://get_users_users_get")
|
|
assert resources[0].name == "get_users_users_get"
|
|
|
|
async def test_get_resource(
|
|
self,
|
|
fastmcp_openapi_server: FastMCPOpenAPI,
|
|
api_client,
|
|
users_db: dict[int, User],
|
|
):
|
|
"""
|
|
The resource created by the OpenAPI server should be the same as the original
|
|
"""
|
|
|
|
json_users = TypeAdapter(list[User]).dump_python(
|
|
sorted(users_db.values(), key=lambda x: x.id)
|
|
)
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
resource_response = await client.read_resource(
|
|
"resource://get_users_users_get"
|
|
)
|
|
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
resource = json.loads(response_text)
|
|
assert resource == json_users
|
|
response = await api_client.get("/users")
|
|
assert response.json() == json_users
|
|
|
|
async def test_get_bytes_resource(
|
|
self,
|
|
fastmcp_openapi_server: FastMCPOpenAPI,
|
|
api_client,
|
|
):
|
|
"""Test reading a resource that returns bytes."""
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
resource_response = await client.read_resource(
|
|
"resource://ping_bytes_ping_bytes_get"
|
|
)
|
|
assert isinstance(resource_response[0], BlobResourceContents)
|
|
assert base64.b64decode(resource_response[0].blob) == b"pong"
|
|
|
|
async def test_get_str_resource(
|
|
self,
|
|
fastmcp_openapi_server: FastMCPOpenAPI,
|
|
api_client,
|
|
):
|
|
"""Test reading a resource that returns a string."""
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
resource_response = await client.read_resource("resource://ping_ping_get")
|
|
assert resource_response[0].text == "pong" # type: ignore[attr-defined]
|
|
|
|
|
|
class TestResourceTemplates:
|
|
async def test_list_resource_templates(
|
|
self, fastmcp_openapi_server: FastMCPOpenAPI
|
|
):
|
|
"""
|
|
By default, resource templates exclude GET methods without parameters
|
|
"""
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
resource_templates = await client.list_resource_templates()
|
|
assert len(resource_templates) == 2
|
|
assert resource_templates[0].name == "get_user_users"
|
|
assert (
|
|
resource_templates[0].uriTemplate == r"resource://get_user_users/{user_id}"
|
|
)
|
|
assert resource_templates[1].name == "get_user_active_state_users"
|
|
assert (
|
|
resource_templates[1].uriTemplate
|
|
== r"resource://get_user_active_state_users/{is_active}/{user_id}"
|
|
)
|
|
|
|
async def test_get_resource_template(
|
|
self,
|
|
fastmcp_openapi_server: FastMCPOpenAPI,
|
|
api_client,
|
|
users_db: dict[int, User],
|
|
):
|
|
"""
|
|
The resource template created by the OpenAPI server should be the same as the original
|
|
"""
|
|
user_id = 2
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
resource_response = await client.read_resource(
|
|
f"resource://get_user_users/{user_id}"
|
|
)
|
|
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
resource = json.loads(response_text)
|
|
|
|
assert resource == users_db[user_id].model_dump()
|
|
response = await api_client.get(f"/users/{user_id}")
|
|
assert resource == response.json()
|
|
|
|
async def test_get_resource_template_multi_param(
|
|
self,
|
|
fastmcp_openapi_server: FastMCPOpenAPI,
|
|
api_client,
|
|
users_db: dict[int, User],
|
|
):
|
|
"""
|
|
The resource template created by the OpenAPI server should be the same as the original
|
|
"""
|
|
user_id = 2
|
|
is_active = True
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
resource_response = await client.read_resource(
|
|
f"resource://get_user_active_state_users/{is_active}/{user_id}"
|
|
)
|
|
response_text = resource_response[0].text # type: ignore[attr-defined]
|
|
resource = json.loads(response_text)
|
|
|
|
assert resource == users_db[user_id].model_dump()
|
|
response = await api_client.get(f"/users/{user_id}/{is_active}")
|
|
assert resource == response.json()
|
|
|
|
|
|
class TestPrompts:
|
|
async def test_list_prompts(self, fastmcp_openapi_server: FastMCPOpenAPI):
|
|
"""
|
|
By default, there are no prompts.
|
|
"""
|
|
async with Client(fastmcp_openapi_server) as client:
|
|
prompts = await client.list_prompts()
|
|
assert len(prompts) == 0
|