mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-10 06:53:19 +02:00
`RequestDirector` builds each upstream request from the route's parameter map, which records where every declared parameter belongs. This change makes the map the single source of argument locations for every route, including operations that declare no parameters, so each request contains exactly what the OpenAPI operation declares. Arguments outside the map are dropped with a warning. Routes constructed by hand without a map get one from the same builder the parser uses, and they accept `<name>__<location>` for parameters declared at that location. If a map can't be built, the call fails before any request is sent. Resource and resource-template reads now follow the same header rule as tools: headers forwarded from the incoming MCP request fill in only headers the OpenAPI HTTP client hasn't already set. The headers you configure on the client are what the upstream receives. Co-authored-by: Bill Easton <williamseaston@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
203 lines
7.4 KiB
Python
203 lines
7.4 KiB
Python
import json
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, Request
|
|
from mcp_types import TextResourceContents
|
|
|
|
from fastmcp import Client, FastMCP
|
|
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
from fastmcp.server import create_proxy
|
|
from fastmcp.server.providers.openapi import MCPType, RouteMap
|
|
from fastmcp.utilities.tests import run_server_async
|
|
|
|
|
|
def create_fastmcp_server_for_headers() -> FastMCP:
|
|
"""Create a FastMCP server from FastAPI app with experimental parser."""
|
|
app = FastAPI()
|
|
|
|
@app.get("/headers")
|
|
def get_headers(request: Request):
|
|
return request.headers
|
|
|
|
@app.get("/headers/{header_name}")
|
|
def get_header_by_name(header_name: str, request: Request):
|
|
return request.headers[header_name]
|
|
|
|
@app.post("/headers")
|
|
def post_headers(request: Request):
|
|
return request.headers
|
|
|
|
mcp = FastMCP.from_fastapi(
|
|
app,
|
|
httpx_client_kwargs={"headers": {"x-server-header": "test-abc"}},
|
|
route_maps=[
|
|
# GET requests with path parameters go to ResourceTemplate
|
|
RouteMap(
|
|
methods=["GET"],
|
|
pattern=r".*\{.*\}.*",
|
|
mcp_type=MCPType.RESOURCE_TEMPLATE,
|
|
),
|
|
# GET requests without path parameters go to Resource
|
|
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
|
|
],
|
|
)
|
|
|
|
return mcp
|
|
|
|
|
|
@pytest.fixture
|
|
async def shttp_server():
|
|
"""Start a test server with StreamableHttp transport."""
|
|
server = create_fastmcp_server_for_headers()
|
|
async with run_server_async(server, transport="http") as url:
|
|
yield url
|
|
|
|
|
|
@pytest.fixture
|
|
async def sse_server():
|
|
"""Start a test server with SSE transport."""
|
|
server = create_fastmcp_server_for_headers()
|
|
async with run_server_async(server, transport="sse") as url:
|
|
yield url
|
|
|
|
|
|
@pytest.fixture
|
|
async def proxy_server(shttp_server: str):
|
|
"""Start a proxy server."""
|
|
proxy = create_proxy(StreamableHttpTransport(shttp_server))
|
|
async with run_server_async(proxy, transport="http") as url:
|
|
yield url
|
|
|
|
|
|
async def test_fastapi_client_headers_streamable_http_resource(shttp_server: str):
|
|
async with Client(transport=StreamableHttpTransport(shttp_server)) as client:
|
|
result = await client.read_resource("resource://get_headers_headers_get")
|
|
assert isinstance(result[0], TextResourceContents)
|
|
headers = json.loads(result[0].text)
|
|
assert headers["x-server-header"] == "test-abc"
|
|
|
|
|
|
async def test_fastapi_client_headers_sse_resource(sse_server: str):
|
|
async with Client(transport=SSETransport(sse_server)) as client:
|
|
result = await client.read_resource("resource://get_headers_headers_get")
|
|
assert isinstance(result[0], TextResourceContents)
|
|
headers = json.loads(result[0].text)
|
|
assert headers["x-server-header"] == "test-abc"
|
|
|
|
|
|
async def test_fastapi_client_headers_streamable_http_tool(shttp_server: str):
|
|
async with Client(transport=StreamableHttpTransport(shttp_server)) as client:
|
|
result = await client.call_tool("post_headers_headers_post")
|
|
headers: dict[str, str] = result.data
|
|
assert headers["x-server-header"] == "test-abc"
|
|
|
|
|
|
async def test_fastapi_client_headers_sse_tool(sse_server: str):
|
|
async with Client(transport=SSETransport(sse_server)) as client:
|
|
result = await client.call_tool("post_headers_headers_post")
|
|
headers: dict[str, str] = result.data
|
|
assert headers["x-server-header"] == "test-abc"
|
|
|
|
|
|
async def test_client_headers_sse_resource(sse_server: str):
|
|
async with Client(
|
|
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
|
) as client:
|
|
result = await client.read_resource("resource://get_headers_headers_get")
|
|
assert isinstance(result[0], TextResourceContents)
|
|
headers = json.loads(result[0].text)
|
|
assert headers["x-test"] == "test-123"
|
|
|
|
|
|
async def test_client_headers_shttp_resource(shttp_server: str):
|
|
async with Client(
|
|
transport=StreamableHttpTransport(shttp_server, headers={"X-TEST": "test-123"})
|
|
) as client:
|
|
result = await client.read_resource("resource://get_headers_headers_get")
|
|
assert isinstance(result[0], TextResourceContents)
|
|
headers = json.loads(result[0].text)
|
|
assert headers["x-test"] == "test-123"
|
|
|
|
|
|
async def test_client_headers_sse_resource_template(sse_server: str):
|
|
async with Client(
|
|
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
|
) as client:
|
|
result = await client.read_resource(
|
|
"resource://get_header_by_name_headers/x-test"
|
|
)
|
|
assert isinstance(result[0], TextResourceContents)
|
|
header = json.loads(result[0].text)
|
|
assert header == "test-123"
|
|
|
|
|
|
async def test_client_headers_shttp_resource_template(shttp_server: str):
|
|
async with Client(
|
|
transport=StreamableHttpTransport(shttp_server, headers={"X-TEST": "test-123"})
|
|
) as client:
|
|
result = await client.read_resource(
|
|
"resource://get_header_by_name_headers/x-test"
|
|
)
|
|
assert isinstance(result[0], TextResourceContents)
|
|
header = json.loads(result[0].text)
|
|
assert header == "test-123"
|
|
|
|
|
|
async def test_client_headers_sse_tool(sse_server: str):
|
|
async with Client(
|
|
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
|
) as client:
|
|
result = await client.call_tool("post_headers_headers_post")
|
|
headers: dict[str, str] = result.data
|
|
assert headers["x-test"] == "test-123"
|
|
|
|
|
|
async def test_client_headers_shttp_tool(shttp_server: str):
|
|
async with Client(
|
|
transport=StreamableHttpTransport(shttp_server, headers={"X-TEST": "test-123"})
|
|
) as client:
|
|
result = await client.call_tool("post_headers_headers_post")
|
|
headers: dict[str, str] = result.data
|
|
assert headers["x-test"] == "test-123"
|
|
|
|
|
|
async def test_server_headers_take_precedence_over_client_headers(shttp_server: str):
|
|
async with Client(
|
|
transport=StreamableHttpTransport(
|
|
shttp_server, headers={"x-server-header": "test-client"}
|
|
)
|
|
) as client:
|
|
result = await client.read_resource("resource://get_headers_headers_get")
|
|
assert isinstance(result[0], TextResourceContents)
|
|
headers = json.loads(result[0].text)
|
|
assert headers["x-server-header"] == "test-abc"
|
|
|
|
|
|
async def test_client_with_excluded_header_is_ignored(sse_server: str):
|
|
async with Client(
|
|
transport=SSETransport(
|
|
sse_server,
|
|
headers={
|
|
"x-server-header": "test-client",
|
|
"host": "1.2.3.4",
|
|
"not-host": "1.2.3.4",
|
|
},
|
|
)
|
|
) as client:
|
|
result = await client.read_resource("resource://get_headers_headers_get")
|
|
assert isinstance(result[0], TextResourceContents)
|
|
headers = json.loads(result[0].text)
|
|
assert headers["not-host"] == "1.2.3.4"
|
|
assert headers["host"] == "fastapi"
|
|
|
|
|
|
@pytest.mark.flaky(retries=2, delay=1)
|
|
async def test_client_headers_proxy(proxy_server: str):
|
|
"""
|
|
Test that client headers are passed through the proxy to the remove server.
|
|
"""
|
|
async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
|
|
result = await client.read_resource("resource://get_headers_headers_get")
|
|
assert isinstance(result[0], TextResourceContents)
|
|
headers = json.loads(result[0].text)
|
|
assert headers["x-server-header"] == "test-abc"
|