Merge pull request #589 from jlowin/content-length

Ensure content-length is always stripped from client headers
This commit is contained in:
Jeremiah Lowin 2025-05-24 07:47:11 -04:00 committed by GitHub
commit ffa2ee1740
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 65 additions and 74 deletions

View file

@ -23,7 +23,7 @@ from fastmcp import FastMCP
from fastmcp.server.dependencies import get_http_request
from starlette.requests import Request
mcp = FastMCP(name="HTTPRequestDemo")
mcp = FastMCP(name="HTTP Request Demo")
@mcp.tool()
async def user_agent_info() -> dict:
@ -48,32 +48,40 @@ This approach works anywhere within a request's execution flow, not just within
2. You're calling nested functions that need HTTP request data
3. You're working with middleware or other request processing code
## Important Notes
## Accessing HTTP Headers Only
- HTTP requests are only available when FastMCP is running as part of a web application
- Accessing the HTTP request outside of a web request context will raise a `RuntimeError`
- The `get_http_request()` function returns a standard [Starlette Request](https://www.starlette.io/requests/) object
If you only need request headers and want to avoid potential errors, you can use the `get_http_headers()` helper:
## Common Use Cases
```python {2}
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_http_headers
### Accessing Request Headers
```python
from fastmcp.server.dependencies import get_http_request
mcp = FastMCP(name="Headers Demo")
@mcp.tool()
async def get_auth_info() -> dict:
"""Get authentication information from request headers."""
request = get_http_request()
async def safe_header_info() -> dict:
"""Safely get header information without raising errors."""
# Get headers (returns empty dict if no request context)
headers = get_http_headers()
# Get authorization header
auth_header = request.headers.get("authorization", "")
# Check for Bearer token
auth_header = headers.get("authorization", "")
is_bearer = auth_header.startswith("Bearer ")
return {
"user_agent": headers.get("user-agent", "Unknown"),
"content_type": headers.get("content-type", "Unknown"),
"has_auth": bool(auth_header),
"auth_type": "Bearer" if is_bearer else "Other" if auth_header else "None"
"auth_type": "Bearer" if is_bearer else "Other" if auth_header else "None",
"headers_count": len(headers)
}
```
By default, `get_http_headers()` excludes problematic headers like `content-length`. To include all headers, use `get_http_headers(include_all=True)`.
## Important Notes
- HTTP requests are only available when FastMCP is running as part of a web application
- Accessing the HTTP request with `get_http_request()` outside of a web request context will raise a `RuntimeError`
- The `get_http_headers()` function **never raises errors** - it returns an empty dict when no request context is available
- The `get_http_request()` function returns a standard [Starlette Request](https://www.starlette.io/requests/) object

View file

@ -25,7 +25,7 @@ from pydantic import AnyUrl
from typing_extensions import Unpack
from fastmcp.server import FastMCP as FastMCPServer
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.server import FastMCP
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
@ -35,11 +35,6 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
# these headers, when forwarded to the remote server, can cause issues
EXCLUDE_HEADERS = {
"content-length",
}
class SessionKwargs(TypedDict, total=False):
"""Keyword arguments for the MCP ClientSession constructor."""
@ -138,23 +133,12 @@ class SSETransport(ClientTransport):
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
client_kwargs: dict[str, Any] = {
"headers": self.headers,
}
client_kwargs: dict[str, Any] = {}
# load headers from an active HTTP request, if available. This will only be true
# if the client is used in a FastMCP Proxy, in which case the MCP client headers
# need to be forwarded to the remote server.
try:
active_request = get_http_request()
for name, value in active_request.headers.items():
name = name.lower()
if name not in self.headers and name not in {
h.lower() for h in EXCLUDE_HEADERS
}:
client_kwargs["headers"][name] = str(value)
except RuntimeError:
client_kwargs["headers"] = self.headers
client_kwargs["headers"] = get_http_headers() | self.headers
# sse_read_timeout has a default value set, so we can't pass None without overriding it
# instead we simply leave the kwarg out if it's not provided
@ -201,25 +185,12 @@ class StreamableHttpTransport(ClientTransport):
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
client_kwargs: dict[str, Any] = {
"headers": self.headers,
}
client_kwargs: dict[str, Any] = {}
# load headers from an active HTTP request, if available. This will only be true
# if the client is used in a FastMCP Proxy, in which case the MCP client headers
# need to be forwarded to the remote server.
try:
active_request = get_http_request()
for name, value in active_request.headers.items():
name = name.lower()
if name not in self.headers and name not in {
h.lower() for h in EXCLUDE_HEADERS
}:
client_kwargs["headers"][name] = str(value)
except RuntimeError:
client_kwargs["headers"] = self.headers
print(client_kwargs)
client_kwargs["headers"] = get_http_headers() | self.headers
# sse_read_timeout has a default value set, so we can't pass None without overriding it
# instead we simply leave the kwarg out if it's not provided

View file

@ -33,3 +33,35 @@ def get_http_request() -> Request:
if request is None:
raise RuntimeError("No active HTTP request found.")
return request
def get_http_headers(include_all: bool = False) -> dict[str, str]:
"""
Extract headers from the current HTTP request if available.
Never raises an exception, even if there is no active HTTP request (in which case
an empty dict is returned).
By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients.
If `include_all` is True, all headers are returned.
"""
if include_all:
exclude_headers = set()
else:
exclude_headers = {"content-length"}
# ensure all lowercase!
# (just in case)
exclude_headers = {h.lower() for h in exclude_headers}
headers = {}
try:
request = get_http_request()
for name, value in request.headers.items():
lower_name = name.lower()
if lower_name not in exclude_headers:
headers[lower_name] = str(value)
return headers
except RuntimeError:
return {}

View file

@ -18,7 +18,7 @@ from pydantic.networks import AnyUrl
from fastmcp.exceptions import ToolError
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.server import FastMCP
from fastmcp.tools.tool import Tool, _convert_to_content
from fastmcp.utilities import openapi
@ -60,25 +60,6 @@ def _slugify(text: str) -> str:
return slug
def _get_mcp_client_headers() -> dict[str, str]:
"""
Extract headers from the current MCP client HTTP request if available.
These headers will take precedence over OpenAPI-defined headers when both are present.
Returns:
Dictionary of header name-value pairs (lowercased names), or empty dict if no HTTP request is active.
"""
try:
http_request = get_http_request()
return {
name.lower(): str(value) for name, value in http_request.headers.items()
}
except RuntimeError:
# No active HTTP request (e.g., STDIO transport), return empty dict
return {}
# Type definitions for the mapping functions
RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
ComponentFn = Callable[
@ -423,7 +404,7 @@ class OpenAPITool(Tool):
headers.update(openapi_headers)
# Add headers from the current MCP client HTTP request (these take precedence)
mcp_headers = _get_mcp_client_headers()
mcp_headers = get_http_headers()
headers.update(mcp_headers)
# Prepare request body
@ -574,7 +555,7 @@ class OpenAPIResource(Resource):
# Prepare headers from MCP client request if available
headers = {}
mcp_headers = _get_mcp_client_headers()
mcp_headers = get_http_headers()
headers.update(mcp_headers)
response = await self._client.request(

View file

@ -185,7 +185,6 @@ class TestClientHeaders:
Test that client headers are passed through the proxy to the remove server.
"""
async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
await client.ping()
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)