fastmcp/tests/server/providers/openapi/test_openapi_features.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

1029 lines
42 KiB
Python

"""Tests for OpenAPI feature support in OpenAPIProvider."""
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from httpx import Response
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.providers.openapi import OpenAPIProvider
from fastmcp.server.providers.openapi.components import (
_extract_mime_type_from_route,
_redact_headers,
)
from fastmcp.server.providers.openapi.routing import MCPType, RouteMap
from fastmcp.utilities.openapi.models import HTTPRoute, ResponseInfo
def create_openapi_server(
openapi_spec: dict,
client,
name: str = "OpenAPI Server",
) -> FastMCP:
"""Helper to create a FastMCP server with OpenAPIProvider."""
provider = OpenAPIProvider(openapi_spec=openapi_spec, client=client)
mcp = FastMCP(name)
mcp.add_provider(provider)
return mcp
class TestParameterHandling:
"""Test OpenAPI parameter handling features."""
@pytest.fixture
def parameter_spec(self):
"""OpenAPI spec with various parameter types."""
return {
"openapi": "3.0.0",
"info": {"title": "Parameter Test API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/search": {
"get": {
"operationId": "search_items",
"summary": "Search items",
"parameters": [
{
"name": "query",
"in": "query",
"required": True,
"schema": {"type": "string"},
"description": "Search query",
},
{
"name": "limit",
"in": "query",
"required": False,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
},
"description": "Maximum number of results",
},
{
"name": "tags",
"in": "query",
"required": False,
"schema": {
"type": "array",
"items": {"type": "string"},
},
"style": "form",
"explode": True,
"description": "Filter by tags",
},
{
"name": "X-API-Key",
"in": "header",
"required": True,
"schema": {"type": "string"},
"description": "API key for authentication",
},
],
"responses": {
"200": {
"description": "Search results",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "object"},
},
"total": {"type": "integer"},
},
}
}
},
}
},
}
},
"/users/{id}/posts/{post_id}": {
"get": {
"operationId": "get_user_post",
"summary": "Get specific user post",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
"description": "User ID",
},
{
"name": "post_id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
"description": "Post ID",
},
],
"responses": {
"200": {
"description": "User post",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"title": {"type": "string"},
"content": {"type": "string"},
},
}
}
},
}
},
}
},
},
}
async def test_query_parameters_in_tools(self, parameter_spec):
"""Test that query parameters are properly included in tool parameters."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=parameter_spec, client=client, name="Parameter Test Server"
)
async with Client(server) as mcp_client:
tools = await mcp_client.list_tools()
# Find the search tool
search_tool = next(
tool for tool in tools if tool.name == "search_items"
)
assert search_tool is not None
# Check that parameters are included in the tool's input schema
params = search_tool.inputSchema
assert params["type"] == "object"
properties = params["properties"]
# Check that key parameters are present
# (Schema details may vary based on implementation)
assert "query" in properties
assert "limit" in properties
assert "tags" in properties
assert "X-API-Key" in properties
# Check that parameter descriptions are included
assert "description" in properties["query"], (
"Query parameter should have description"
)
assert properties["query"]["description"] == "Search query"
assert "description" in properties["limit"], (
"Limit parameter should have description"
)
assert properties["limit"]["description"] == "Maximum number of results"
assert "description" in properties["tags"], (
"Tags parameter should have description"
)
assert properties["tags"]["description"] == "Filter by tags"
# Check that required parameters are marked as required
required = params.get("required", [])
assert "query" in required
assert "X-API-Key" in required
async def test_path_parameters_in_tools(self, parameter_spec):
"""Test that path parameters are properly included in tool parameters."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=parameter_spec, client=client, name="Parameter Test Server"
)
async with Client(server) as mcp_client:
tools = await mcp_client.list_tools()
# Find the user post tool
user_post_tool = next(
tool for tool in tools if tool.name == "get_user_post"
)
assert user_post_tool is not None
# Check that path parameters are included
params = user_post_tool.inputSchema
properties = params["properties"]
# Check that path parameters are present
assert "id" in properties
assert "post_id" in properties
# Path parameters should be required
required = params.get("required", [])
assert "id" in required
assert "post_id" in required
class TestRequestBodyHandling:
"""Test OpenAPI request body handling."""
@pytest.fixture
def request_body_spec(self):
"""OpenAPI spec with request body."""
return {
"openapi": "3.0.0",
"info": {"title": "Request Body Test API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/users": {
"post": {
"operationId": "create_user",
"summary": "Create a user",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "User's full name",
},
"email": {
"type": "string",
"format": "email",
"description": "User's email address",
},
"age": {
"type": "integer",
"minimum": 0,
"maximum": 150,
"description": "User's age",
},
"preferences": {
"type": "object",
"properties": {
"theme": {"type": "string"},
"notifications": {
"type": "boolean"
},
},
"description": "User preferences",
},
},
"required": ["name", "email"],
}
}
},
},
"responses": {
"201": {
"description": "User created",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"},
},
}
}
},
}
},
}
}
},
}
async def test_request_body_properties_in_tool(self, request_body_spec):
"""Test that request body properties are included in tool parameters."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=request_body_spec,
client=client,
name="Request Body Test Server",
)
async with Client(server) as mcp_client:
tools = await mcp_client.list_tools()
# Find the create user tool
create_tool = next(tool for tool in tools if tool.name == "create_user")
assert create_tool is not None
# Check that request body properties are included
params = create_tool.inputSchema
properties = params["properties"]
# Check that request body properties are present
assert "name" in properties
assert "email" in properties
assert "age" in properties
assert "preferences" in properties
# Check required fields from request body
required = params.get("required", [])
assert "name" in required
assert "email" in required
class TestResponseSchemas:
"""Test OpenAPI response schema handling."""
@pytest.fixture
def response_schema_spec(self):
"""OpenAPI spec with detailed response schemas."""
return {
"openapi": "3.0.0",
"info": {"title": "Response Schema Test API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/users/{id}": {
"get": {
"operationId": "get_user",
"summary": "Get user details",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
}
],
"responses": {
"200": {
"description": "User details retrieved successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"},
"profile": {
"type": "object",
"properties": {
"bio": {"type": "string"},
"avatar_url": {
"type": "string"
},
},
},
},
"required": ["id", "name", "email"],
}
}
},
},
"404": {
"description": "User not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"error": {"type": "string"},
"code": {"type": "integer"},
},
}
}
},
},
},
}
}
},
}
async def test_tool_has_output_schema(self, response_schema_spec):
"""Test that tools have output schemas from response definitions."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
server = create_openapi_server(
openapi_spec=response_schema_spec,
client=client,
name="Response Schema Test Server",
)
async with Client(server) as mcp_client:
tools = await mcp_client.list_tools()
# Find the get user tool
get_user_tool = next(tool for tool in tools if tool.name == "get_user")
assert get_user_tool is not None
# Check that the tool has an output schema
# Note: output schema might be None if not extracted properly
# Let's just check the tool exists and has basic properties
assert get_user_tool.description is not None
assert get_user_tool.name == "get_user"
class TestMimeTypeExtraction:
"""Test MIME type extraction from route responses."""
def test_json_response(self):
"""JSON content type is correctly extracted."""
route = HTTPRoute(
path="/items",
method="GET",
responses={
"200": ResponseInfo(
content_schema={"application/json": {"type": "object"}}
)
},
)
assert _extract_mime_type_from_route(route) == "application/json"
def test_text_plain_response(self):
"""Plain text content type is correctly extracted."""
route = HTTPRoute(
path="/health",
method="GET",
responses={
"200": ResponseInfo(content_schema={"text/plain": {"type": "string"}})
},
)
assert _extract_mime_type_from_route(route) == "text/plain"
def test_text_html_response(self):
"""HTML content type is correctly extracted."""
route = HTTPRoute(
path="/page",
method="GET",
responses={
"200": ResponseInfo(content_schema={"text/html": {"type": "string"}})
},
)
assert _extract_mime_type_from_route(route) == "text/html"
def test_image_response(self):
"""Image content type is correctly extracted."""
route = HTTPRoute(
path="/avatar",
method="GET",
responses={
"200": ResponseInfo(
content_schema={"image/png": {"type": "string", "format": "binary"}}
)
},
)
assert _extract_mime_type_from_route(route) == "image/png"
def test_no_responses_defaults_to_json(self):
"""Empty responses default to application/json."""
route = HTTPRoute(path="/items", method="GET", responses={})
assert _extract_mime_type_from_route(route) == "application/json"
def test_no_content_schema_defaults_to_json(self):
"""Response without content_schema defaults to application/json."""
route = HTTPRoute(
path="/items",
method="GET",
responses={"204": ResponseInfo(description="No content")},
)
assert _extract_mime_type_from_route(route) == "application/json"
def test_prefers_json_when_multiple_types(self):
"""When both JSON and other types exist, JSON is preferred."""
route = HTTPRoute(
path="/items",
method="GET",
responses={
"200": ResponseInfo(
content_schema={
"text/html": {"type": "string"},
"application/json": {"type": "object"},
}
)
},
)
assert _extract_mime_type_from_route(route) == "application/json"
def test_non_standard_2xx_code(self):
"""Falls back to any 2xx status code when standard ones are missing."""
route = HTTPRoute(
path="/items",
method="GET",
responses={
"206": ResponseInfo(
content_schema={
"application/octet-stream": {
"type": "string",
"format": "binary",
}
}
)
},
)
assert _extract_mime_type_from_route(route) == "application/octet-stream"
def test_ignores_error_responses(self):
"""Only error responses (no 2xx) results in default."""
route = HTTPRoute(
path="/items",
method="GET",
responses={
"404": ResponseInfo(
content_schema={"application/json": {"type": "object"}}
)
},
)
assert _extract_mime_type_from_route(route) == "application/json"
def test_201_response(self):
"""201 Created response content type is extracted."""
route = HTTPRoute(
path="/items",
method="POST",
responses={
"201": ResponseInfo(content_schema={"text/plain": {"type": "string"}})
},
)
assert _extract_mime_type_from_route(route) == "text/plain"
def test_media_type_without_schema(self):
"""Media type declared without a schema still infers MIME type."""
route = HTTPRoute(
path="/health",
method="GET",
responses={"200": ResponseInfo(content_schema={"text/plain": {}})},
)
assert _extract_mime_type_from_route(route) == "text/plain"
class TestResourceTemplateMimeType:
"""Test that OpenAPIResourceTemplate uses inferred MIME types."""
@pytest.fixture
def text_plain_spec(self):
"""OpenAPI spec with a text/plain resource template endpoint."""
return {
"openapi": "3.0.0",
"info": {"title": "Text API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/documents/{id}": {
"get": {
"operationId": "get_document",
"summary": "Get document content",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {
"200": {
"description": "Document content",
"content": {
"text/plain": {"schema": {"type": "string"}}
},
}
},
}
}
},
}
@pytest.fixture
def html_spec(self):
"""OpenAPI spec with a text/html resource endpoint."""
return {
"openapi": "3.0.0",
"info": {"title": "HTML API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/pages/{slug}": {
"get": {
"operationId": "get_page",
"summary": "Get HTML page",
"parameters": [
{
"name": "slug",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {
"200": {
"description": "HTML page",
"content": {
"text/html": {"schema": {"type": "string"}}
},
}
},
}
}
},
}
async def test_resource_template_text_plain_mime_type(self, text_plain_spec):
"""Resource template should reflect text/plain from OpenAPI spec."""
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=text_plain_spec, client=client, route_maps=route_maps
)
mcp = FastMCP("Test")
mcp.add_provider(provider)
async with Client(mcp) as mcp_client:
templates = await mcp_client.list_resource_templates()
assert len(templates) == 1
assert templates[0].mimeType == "text/plain"
async def test_resource_template_html_mime_type(self, html_spec):
"""Resource template should reflect text/html from OpenAPI spec."""
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=html_spec, client=client, route_maps=route_maps
)
mcp = FastMCP("Test")
mcp.add_provider(provider)
async with Client(mcp) as mcp_client:
templates = await mcp_client.list_resource_templates()
assert len(templates) == 1
assert templates[0].mimeType == "text/html"
async def test_resource_template_defaults_json_mime_type(self):
"""Resource template defaults to application/json for JSON responses."""
spec = {
"openapi": "3.0.0",
"info": {"title": "JSON API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/users/{id}": {
"get": {
"operationId": "get_user",
"summary": "Get user",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
}
],
"responses": {
"200": {
"description": "User data",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
},
}
}
},
}
},
}
}
},
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec, client=client, route_maps=route_maps
)
mcp = FastMCP("Test")
mcp.add_provider(provider)
async with Client(mcp) as mcp_client:
templates = await mcp_client.list_resource_templates()
assert len(templates) == 1
assert templates[0].mimeType == "application/json"
class TestResourceMimeType:
"""Test that OpenAPIResource uses inferred MIME types."""
async def test_resource_text_plain_mime_type(self):
"""Static resource should reflect text/plain from OpenAPI spec."""
spec = {
"openapi": "3.0.0",
"info": {"title": "Health API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/health": {
"get": {
"operationId": "healthcheck",
"summary": "Health check",
"responses": {
"200": {
"description": "Health status",
"content": {
"text/plain": {"schema": {"type": "string"}}
},
}
},
}
}
},
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)]
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec, client=client, route_maps=route_maps
)
mcp = FastMCP("Test")
mcp.add_provider(provider)
async with Client(mcp) as mcp_client:
resources = await mcp_client.list_resources()
assert len(resources) == 1
assert resources[0].mimeType == "text/plain"
async def test_resource_mime_type_without_schema(self):
"""Resource with media type but no schema still infers MIME type."""
spec = {
"openapi": "3.0.0",
"info": {"title": "Health API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/health": {
"get": {
"operationId": "healthcheck",
"summary": "Health check",
"responses": {
"200": {
"description": "Health status",
"content": {"text/plain": {}},
}
},
}
}
},
}
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE)]
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec, client=client, route_maps=route_maps
)
mcp = FastMCP("Test")
mcp.add_provider(provider)
async with Client(mcp) as mcp_client:
resources = await mcp_client.list_resources()
assert len(resources) == 1
assert resources[0].mimeType == "text/plain"
class TestValidateOutput:
"""Tests for the validate_output option on OpenAPIProvider."""
@pytest.fixture
def spec_with_output_schema(self):
return {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"servers": [{"url": "https://api.example.com"}],
"paths": {
"/users/{id}": {
"get": {
"operationId": "get_user",
"summary": "Get a user",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
}
],
"responses": {
"200": {
"description": "A user",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"},
},
"required": ["id", "name"],
}
}
},
}
},
}
},
"/items": {
"get": {
"operationId": "list_items",
"summary": "List items",
"responses": {
"200": {
"description": "An array of items",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"}
},
},
}
}
},
}
},
}
},
},
}
async def test_validate_output_true_preserves_extracted_schema(
self, spec_with_output_schema
):
"""Default validate_output=True uses the real extracted schema."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec_with_output_schema,
client=client,
)
tool = provider._tools["get_user"]
assert tool.output_schema is not None
assert tool.output_schema.get("type") == "object"
assert "properties" in tool.output_schema
assert "id" in tool.output_schema["properties"]
async def test_validate_output_false_uses_permissive_schema(
self, spec_with_output_schema
):
"""validate_output=False replaces the schema with a permissive one."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec_with_output_schema,
client=client,
validate_output=False,
)
tool = provider._tools["get_user"]
assert tool.output_schema is not None
assert tool.output_schema == {
"type": "object",
"additionalProperties": True,
}
async def test_validate_output_false_preserves_wrap_result_flag(
self, spec_with_output_schema
):
"""validate_output=False preserves x-fastmcp-wrap-result for array responses."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
provider = OpenAPIProvider(
openapi_spec=spec_with_output_schema,
client=client,
validate_output=False,
)
# The list_items endpoint returns an array, so the extracted schema
# would have had x-fastmcp-wrap-result=True
tool = provider._tools["list_items"]
assert tool.output_schema is not None
assert tool.output_schema.get("x-fastmcp-wrap-result") is True
assert tool.output_schema.get("additionalProperties") is True
async def test_validate_output_false_allows_nonconforming_response(
self, spec_with_output_schema
):
"""With validate_output=False, responses that don't match the spec succeed."""
mock_client = Mock(spec=httpx.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
# Return extra fields not in the schema
mock_response = Mock(spec=Response)
mock_response.status_code = 200
mock_response.json.return_value = {
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"unexpected_field": "surprise",
"nested": {"deep": True},
}
mock_response.raise_for_status = Mock()
mock_client.send = AsyncMock(return_value=mock_response)
provider = OpenAPIProvider(
openapi_spec=spec_with_output_schema,
client=mock_client,
validate_output=False,
)
mcp = FastMCP("Test")
mcp.add_provider(provider)
async with Client(mcp) as mcp_client:
result = await mcp_client.call_tool("get_user", {"id": 1})
assert result is not None
# Structured content should have the full response including extra fields
assert result.structured_content is not None
assert result.structured_content["unexpected_field"] == "surprise"
async def test_validate_output_false_wraps_non_dict_response(
self, spec_with_output_schema
):
"""Non-dict responses are wrapped even when schema says object and validate_output=False."""
mock_client = Mock(spec=httpx.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
# Backend returns an array even though schema says object
mock_response = Mock(spec=Response)
mock_response.status_code = 200
mock_response.json.return_value = [{"id": 1}, {"id": 2}]
mock_response.raise_for_status = Mock()
mock_client.send = AsyncMock(return_value=mock_response)
provider = OpenAPIProvider(
openapi_spec=spec_with_output_schema,
client=mock_client,
validate_output=False,
)
mcp = FastMCP("Test")
mcp.add_provider(provider)
async with Client(mcp) as mcp_client:
result = await mcp_client.call_tool("get_user", {"id": 1})
assert result is not None
# Non-dict should be wrapped so structured_content is always a dict
assert result.structured_content is not None
assert isinstance(result.structured_content, dict)
assert result.structured_content["result"] == [{"id": 1}, {"id": 2}]
async def test_from_openapi_threads_validate_output(self, spec_with_output_schema):
"""FastMCP.from_openapi() correctly passes validate_output to the provider."""
mock_client = Mock(spec=httpx.AsyncClient)
mock_client.base_url = "https://api.example.com"
mock_client.headers = None
server = FastMCP.from_openapi(
openapi_spec=spec_with_output_schema,
client=mock_client,
validate_output=False,
)
async with Client(server) as mcp_client:
tools = await mcp_client.list_tools()
get_user = next(t for t in tools if t.name == "get_user")
# With validate_output=False, the outputSchema should be permissive
assert get_user.outputSchema is not None
assert get_user.outputSchema.get("additionalProperties") is True
# Should NOT have specific properties from the original schema
assert "properties" not in get_user.outputSchema
class TestRedactHeaders:
"""Test that non-safe headers are redacted in debug logging."""
def test_known_sensitive_headers_are_redacted(self):
headers = httpx.Headers(
{
"Authorization": "Bearer secret-token",
"X-API-Key": "my-api-key",
"Cookie": "session=abc123",
"Proxy-Authorization": "Basic creds",
"Content-Type": "application/json",
"Accept": "text/html",
}
)
redacted = _redact_headers(headers)
assert redacted["authorization"] == "***"
assert redacted["x-api-key"] == "***"
assert redacted["cookie"] == "***"
assert redacted["proxy-authorization"] == "***"
assert redacted["content-type"] == "application/json"
assert redacted["accept"] == "text/html"
def test_arbitrary_auth_headers_are_redacted(self):
"""Arbitrary header names (e.g. OpenAPI apiKey-in-header) are redacted."""
headers = httpx.Headers(
{
"X-Custom-Token": "secret",
"X-My-Service-Key": "also-secret",
"Content-Type": "application/json",
}
)
redacted = _redact_headers(headers)
assert redacted["x-custom-token"] == "***"
assert redacted["x-my-service-key"] == "***"
assert redacted["content-type"] == "application/json"
def test_safe_only_headers(self):
headers = httpx.Headers({"Content-Type": "application/json"})
redacted = _redact_headers(headers)
assert redacted == {"content-type": "application/json"}