Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
c14bd76
1
Parent(s): b917b3e
Ensure content-length is always stripped from client headers
Browse files- docs/patterns/http-requests.mdx +25 -17
- src/fastmcp/client/transports.py +5 -34
- src/fastmcp/server/dependencies.py +27 -0
- src/fastmcp/server/openapi.py +3 -22
- tests/client/test_openapi.py +0 -1
docs/patterns/http-requests.mdx
CHANGED
|
@@ -23,7 +23,7 @@ from fastmcp import FastMCP
|
|
| 23 |
from fastmcp.server.dependencies import get_http_request
|
| 24 |
from starlette.requests import Request
|
| 25 |
|
| 26 |
-
mcp = FastMCP(name="
|
| 27 |
|
| 28 |
@mcp.tool()
|
| 29 |
async def user_agent_info() -> dict:
|
|
@@ -48,32 +48,40 @@ This approach works anywhere within a request's execution flow, not just within
|
|
| 48 |
2. You're calling nested functions that need HTTP request data
|
| 49 |
3. You're working with middleware or other request processing code
|
| 50 |
|
| 51 |
-
##
|
| 52 |
-
|
| 53 |
-
- HTTP requests are only available when FastMCP is running as part of a web application
|
| 54 |
-
- Accessing the HTTP request outside of a web request context will raise a `RuntimeError`
|
| 55 |
-
- The `get_http_request()` function returns a standard [Starlette Request](https://www.starlette.io/requests/) object
|
| 56 |
|
| 57 |
-
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
from fastmcp.server.dependencies import get_http_request
|
| 63 |
|
| 64 |
@mcp.tool()
|
| 65 |
-
async def
|
| 66 |
-
"""
|
| 67 |
-
|
|
|
|
| 68 |
|
| 69 |
# Get authorization header
|
| 70 |
-
auth_header =
|
| 71 |
-
|
| 72 |
-
# Check for Bearer token
|
| 73 |
is_bearer = auth_header.startswith("Bearer ")
|
| 74 |
|
| 75 |
return {
|
|
|
|
|
|
|
| 76 |
"has_auth": bool(auth_header),
|
| 77 |
-
"auth_type": "Bearer" if is_bearer else "Other" if auth_header else "None"
|
|
|
|
| 78 |
}
|
| 79 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
from fastmcp.server.dependencies import get_http_request
|
| 24 |
from starlette.requests import Request
|
| 25 |
|
| 26 |
+
mcp = FastMCP(name="HTTP Request Demo")
|
| 27 |
|
| 28 |
@mcp.tool()
|
| 29 |
async def user_agent_info() -> dict:
|
|
|
|
| 48 |
2. You're calling nested functions that need HTTP request data
|
| 49 |
3. You're working with middleware or other request processing code
|
| 50 |
|
| 51 |
+
## Accessing HTTP Headers Only
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
If you only need request headers and want to avoid potential errors, you can use the `get_http_headers()` helper:
|
| 54 |
|
| 55 |
+
```python {2}
|
| 56 |
+
from fastmcp import FastMCP
|
| 57 |
+
from fastmcp.server.dependencies import get_http_headers
|
| 58 |
|
| 59 |
+
mcp = FastMCP(name="Headers Demo")
|
|
|
|
| 60 |
|
| 61 |
@mcp.tool()
|
| 62 |
+
async def safe_header_info() -> dict:
|
| 63 |
+
"""Safely get header information without raising errors."""
|
| 64 |
+
# Get headers (returns empty dict if no request context)
|
| 65 |
+
headers = get_http_headers()
|
| 66 |
|
| 67 |
# Get authorization header
|
| 68 |
+
auth_header = headers.get("authorization", "")
|
|
|
|
|
|
|
| 69 |
is_bearer = auth_header.startswith("Bearer ")
|
| 70 |
|
| 71 |
return {
|
| 72 |
+
"user_agent": headers.get("user-agent", "Unknown"),
|
| 73 |
+
"content_type": headers.get("content-type", "Unknown"),
|
| 74 |
"has_auth": bool(auth_header),
|
| 75 |
+
"auth_type": "Bearer" if is_bearer else "Other" if auth_header else "None",
|
| 76 |
+
"headers_count": len(headers)
|
| 77 |
}
|
| 78 |
```
|
| 79 |
+
|
| 80 |
+
By default, `get_http_headers()` excludes problematic headers like `content-length`. To include all headers, use `get_http_headers(include_all=True)`.
|
| 81 |
+
|
| 82 |
+
## Important Notes
|
| 83 |
+
|
| 84 |
+
- HTTP requests are only available when FastMCP is running as part of a web application
|
| 85 |
+
- Accessing the HTTP request with `get_http_request()` outside of a web request context will raise a `RuntimeError`
|
| 86 |
+
- The `get_http_headers()` function **never raises errors** - it returns an empty dict when no request context is available
|
| 87 |
+
- The `get_http_request()` function returns a standard [Starlette Request](https://www.starlette.io/requests/) object
|
src/fastmcp/client/transports.py
CHANGED
|
@@ -25,7 +25,7 @@ from pydantic import AnyUrl
|
|
| 25 |
from typing_extensions import Unpack
|
| 26 |
|
| 27 |
from fastmcp.server import FastMCP as FastMCPServer
|
| 28 |
-
from fastmcp.server.dependencies import
|
| 29 |
from fastmcp.server.server import FastMCP
|
| 30 |
from fastmcp.utilities.logging import get_logger
|
| 31 |
from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
|
|
@@ -35,11 +35,6 @@ if TYPE_CHECKING:
|
|
| 35 |
|
| 36 |
logger = get_logger(__name__)
|
| 37 |
|
| 38 |
-
# these headers, when forwarded to the remote server, can cause issues
|
| 39 |
-
EXCLUDE_HEADERS = {
|
| 40 |
-
"content-length",
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
|
| 44 |
class SessionKwargs(TypedDict, total=False):
|
| 45 |
"""Keyword arguments for the MCP ClientSession constructor."""
|
|
@@ -138,23 +133,12 @@ class SSETransport(ClientTransport):
|
|
| 138 |
async def connect_session(
|
| 139 |
self, **session_kwargs: Unpack[SessionKwargs]
|
| 140 |
) -> AsyncIterator[ClientSession]:
|
| 141 |
-
client_kwargs: dict[str, Any] = {
|
| 142 |
-
"headers": self.headers,
|
| 143 |
-
}
|
| 144 |
|
| 145 |
# load headers from an active HTTP request, if available. This will only be true
|
| 146 |
# if the client is used in a FastMCP Proxy, in which case the MCP client headers
|
| 147 |
# need to be forwarded to the remote server.
|
| 148 |
-
|
| 149 |
-
active_request = get_http_request()
|
| 150 |
-
for name, value in active_request.headers.items():
|
| 151 |
-
name = name.lower()
|
| 152 |
-
if name not in self.headers and name not in {
|
| 153 |
-
h.lower() for h in EXCLUDE_HEADERS
|
| 154 |
-
}:
|
| 155 |
-
client_kwargs["headers"][name] = str(value)
|
| 156 |
-
except RuntimeError:
|
| 157 |
-
client_kwargs["headers"] = self.headers
|
| 158 |
|
| 159 |
# sse_read_timeout has a default value set, so we can't pass None without overriding it
|
| 160 |
# instead we simply leave the kwarg out if it's not provided
|
|
@@ -201,25 +185,12 @@ class StreamableHttpTransport(ClientTransport):
|
|
| 201 |
async def connect_session(
|
| 202 |
self, **session_kwargs: Unpack[SessionKwargs]
|
| 203 |
) -> AsyncIterator[ClientSession]:
|
| 204 |
-
client_kwargs: dict[str, Any] = {
|
| 205 |
-
"headers": self.headers,
|
| 206 |
-
}
|
| 207 |
|
| 208 |
# load headers from an active HTTP request, if available. This will only be true
|
| 209 |
# if the client is used in a FastMCP Proxy, in which case the MCP client headers
|
| 210 |
# need to be forwarded to the remote server.
|
| 211 |
-
|
| 212 |
-
active_request = get_http_request()
|
| 213 |
-
for name, value in active_request.headers.items():
|
| 214 |
-
name = name.lower()
|
| 215 |
-
if name not in self.headers and name not in {
|
| 216 |
-
h.lower() for h in EXCLUDE_HEADERS
|
| 217 |
-
}:
|
| 218 |
-
client_kwargs["headers"][name] = str(value)
|
| 219 |
-
|
| 220 |
-
except RuntimeError:
|
| 221 |
-
client_kwargs["headers"] = self.headers
|
| 222 |
-
print(client_kwargs)
|
| 223 |
|
| 224 |
# sse_read_timeout has a default value set, so we can't pass None without overriding it
|
| 225 |
# instead we simply leave the kwarg out if it's not provided
|
|
|
|
| 25 |
from typing_extensions import Unpack
|
| 26 |
|
| 27 |
from fastmcp.server import FastMCP as FastMCPServer
|
| 28 |
+
from fastmcp.server.dependencies import get_http_headers
|
| 29 |
from fastmcp.server.server import FastMCP
|
| 30 |
from fastmcp.utilities.logging import get_logger
|
| 31 |
from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
|
|
|
|
| 35 |
|
| 36 |
logger = get_logger(__name__)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
class SessionKwargs(TypedDict, total=False):
|
| 40 |
"""Keyword arguments for the MCP ClientSession constructor."""
|
|
|
|
| 133 |
async def connect_session(
|
| 134 |
self, **session_kwargs: Unpack[SessionKwargs]
|
| 135 |
) -> AsyncIterator[ClientSession]:
|
| 136 |
+
client_kwargs: dict[str, Any] = {}
|
|
|
|
|
|
|
| 137 |
|
| 138 |
# load headers from an active HTTP request, if available. This will only be true
|
| 139 |
# if the client is used in a FastMCP Proxy, in which case the MCP client headers
|
| 140 |
# need to be forwarded to the remote server.
|
| 141 |
+
client_kwargs["headers"] = get_http_headers() | self.headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
# sse_read_timeout has a default value set, so we can't pass None without overriding it
|
| 144 |
# instead we simply leave the kwarg out if it's not provided
|
|
|
|
| 185 |
async def connect_session(
|
| 186 |
self, **session_kwargs: Unpack[SessionKwargs]
|
| 187 |
) -> AsyncIterator[ClientSession]:
|
| 188 |
+
client_kwargs: dict[str, Any] = {}
|
|
|
|
|
|
|
| 189 |
|
| 190 |
# load headers from an active HTTP request, if available. This will only be true
|
| 191 |
# if the client is used in a FastMCP Proxy, in which case the MCP client headers
|
| 192 |
# need to be forwarded to the remote server.
|
| 193 |
+
client_kwargs["headers"] = get_http_headers() | self.headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
# sse_read_timeout has a default value set, so we can't pass None without overriding it
|
| 196 |
# instead we simply leave the kwarg out if it's not provided
|
src/fastmcp/server/dependencies.py
CHANGED
|
@@ -33,3 +33,30 @@ def get_http_request() -> Request:
|
|
| 33 |
if request is None:
|
| 34 |
raise RuntimeError("No active HTTP request found.")
|
| 35 |
return request
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if request is None:
|
| 34 |
raise RuntimeError("No active HTTP request found.")
|
| 35 |
return request
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_http_headers(include_all: bool = False) -> dict[str, str]:
|
| 39 |
+
"""
|
| 40 |
+
Extract headers from the current HTTP request if available.
|
| 41 |
+
|
| 42 |
+
Never raises an exception, even if there is no active HTTP request (in which case
|
| 43 |
+
an empty dict is returned).
|
| 44 |
+
|
| 45 |
+
By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients.
|
| 46 |
+
If `include_all` is True, all headers are returned.
|
| 47 |
+
"""
|
| 48 |
+
if include_all:
|
| 49 |
+
exclude_headers = set()
|
| 50 |
+
else:
|
| 51 |
+
exclude_headers = {"content-length"}
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
request = get_http_request()
|
| 55 |
+
headers = {
|
| 56 |
+
name.lower(): str(value)
|
| 57 |
+
for name, value in request.headers.items()
|
| 58 |
+
if name not in exclude_headers
|
| 59 |
+
}
|
| 60 |
+
return headers
|
| 61 |
+
except RuntimeError:
|
| 62 |
+
return {}
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -18,7 +18,7 @@ from pydantic.networks import AnyUrl
|
|
| 18 |
|
| 19 |
from fastmcp.exceptions import ToolError
|
| 20 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 21 |
-
from fastmcp.server.dependencies import
|
| 22 |
from fastmcp.server.server import FastMCP
|
| 23 |
from fastmcp.tools.tool import Tool, _convert_to_content
|
| 24 |
from fastmcp.utilities import openapi
|
|
@@ -60,25 +60,6 @@ def _slugify(text: str) -> str:
|
|
| 60 |
return slug
|
| 61 |
|
| 62 |
|
| 63 |
-
def _get_mcp_client_headers() -> dict[str, str]:
|
| 64 |
-
"""
|
| 65 |
-
Extract headers from the current MCP client HTTP request if available.
|
| 66 |
-
|
| 67 |
-
These headers will take precedence over OpenAPI-defined headers when both are present.
|
| 68 |
-
|
| 69 |
-
Returns:
|
| 70 |
-
Dictionary of header name-value pairs (lowercased names), or empty dict if no HTTP request is active.
|
| 71 |
-
"""
|
| 72 |
-
try:
|
| 73 |
-
http_request = get_http_request()
|
| 74 |
-
return {
|
| 75 |
-
name.lower(): str(value) for name, value in http_request.headers.items()
|
| 76 |
-
}
|
| 77 |
-
except RuntimeError:
|
| 78 |
-
# No active HTTP request (e.g., STDIO transport), return empty dict
|
| 79 |
-
return {}
|
| 80 |
-
|
| 81 |
-
|
| 82 |
# Type definitions for the mapping functions
|
| 83 |
RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
|
| 84 |
ComponentFn = Callable[
|
|
@@ -423,7 +404,7 @@ class OpenAPITool(Tool):
|
|
| 423 |
headers.update(openapi_headers)
|
| 424 |
|
| 425 |
# Add headers from the current MCP client HTTP request (these take precedence)
|
| 426 |
-
mcp_headers =
|
| 427 |
headers.update(mcp_headers)
|
| 428 |
|
| 429 |
# Prepare request body
|
|
@@ -574,7 +555,7 @@ class OpenAPIResource(Resource):
|
|
| 574 |
|
| 575 |
# Prepare headers from MCP client request if available
|
| 576 |
headers = {}
|
| 577 |
-
mcp_headers =
|
| 578 |
headers.update(mcp_headers)
|
| 579 |
|
| 580 |
response = await self._client.request(
|
|
|
|
| 18 |
|
| 19 |
from fastmcp.exceptions import ToolError
|
| 20 |
from fastmcp.resources import Resource, ResourceTemplate
|
| 21 |
+
from fastmcp.server.dependencies import get_http_headers
|
| 22 |
from fastmcp.server.server import FastMCP
|
| 23 |
from fastmcp.tools.tool import Tool, _convert_to_content
|
| 24 |
from fastmcp.utilities import openapi
|
|
|
|
| 60 |
return slug
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
# Type definitions for the mapping functions
|
| 64 |
RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
|
| 65 |
ComponentFn = Callable[
|
|
|
|
| 404 |
headers.update(openapi_headers)
|
| 405 |
|
| 406 |
# Add headers from the current MCP client HTTP request (these take precedence)
|
| 407 |
+
mcp_headers = get_http_headers()
|
| 408 |
headers.update(mcp_headers)
|
| 409 |
|
| 410 |
# Prepare request body
|
|
|
|
| 555 |
|
| 556 |
# Prepare headers from MCP client request if available
|
| 557 |
headers = {}
|
| 558 |
+
mcp_headers = get_http_headers()
|
| 559 |
headers.update(mcp_headers)
|
| 560 |
|
| 561 |
response = await self._client.request(
|
tests/client/test_openapi.py
CHANGED
|
@@ -185,7 +185,6 @@ class TestClientHeaders:
|
|
| 185 |
Test that client headers are passed through the proxy to the remove server.
|
| 186 |
"""
|
| 187 |
async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
|
| 188 |
-
await client.ping()
|
| 189 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 190 |
assert isinstance(result[0], TextResourceContents)
|
| 191 |
headers = json.loads(result[0].text)
|
|
|
|
| 185 |
Test that client headers are passed through the proxy to the remove server.
|
| 186 |
"""
|
| 187 |
async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
|
|
|
|
| 188 |
result = await client.read_resource("resource://get_headers_headers_get")
|
| 189 |
assert isinstance(result[0], TextResourceContents)
|
| 190 |
headers = json.loads(result[0].text)
|