Repository: jlowin/fastmcp

Issue:
Client's Header is getting replaced by mcp_header in OpenAPI implementation when API is deployed remote
### Description

So I am creating a client with headers 

```
headers = {}
    if server_config["auth"]["token"]:
        headers[api_key_header] = f"Bearer {server_config["auth"]["token"]}"

    print(headers)
    
    
    server_url = openapi_spec["servers"][0]["url"]
    http_client = httpx.AsyncClient(base_url=server_url, headers=headers)

```
I can see its getting to the openAPiTool class 



```
INFO:FastMCP.fastmcp.server.openapi:getting clien's header for tools Headers({'accept': '*/*', 'accept-encoding': 'gzip, deflate', 'connection': 'keep-alive', 'user-agent': 'python-httpx/0.28.1', 'authorization': '[secure]'})
```

Now, in the OpenAPITool class the client is creating a request replacing the client's header by the new header parameter

```
try:
            response = await self._client.request(
                method=self._route.method,
                url=path,
                params=query_params,
                headers=headers,
                json=json_data,
                timeout=self._timeout,
            )
```
However when the client is making the request `await self._client.request(` the ` headers=headers,` the parameter `headers` is replacing the http_client's header and this header 
- has a field `host` which is mapped to the host of MCP server host not the actual API server or base_url host for the client; 
```
{'host': '0.0.0.0:9000', 'connection':      openapi.py:410
                             'keep-alive', 'accept': 'text/event-stream', 'accept-language':                  
                             '*', 'sec-fetch-mode': 'cors', 'user-agent': 'node', 'pragma':                   
                             'no-cache', 'cache-control': 'no-cache', 'accept-encoding': 'gzip,               
                             deflate'}      

```


- does not have `authorization` as the http_client's header is getting replaced by the mcp_server header



### Solution 



Two change reuired 
1. similar to openapi_header, mcp_header , we should also append the `self._client.headers` 

2. the host of mcp server is replace the API Base url as the headers also having the host, so that need to remove if the client_base url is different than the mcp host 


here is the sample code to fix it 

```
headers.update(mcp_headers)
## Added fix for adding client header and avoiding issue with the mcp host url and client baseurl
headers.update(self._client.headers)
if hasattr(self._client, "base_url") and self._client.base_url:
    try:
        client_hostname = self._client.base_url.host
        header_host = headers.get("host")
        logger.info(f"{header_host} and {client_hostname} ")
        if header_host and header_host != client_hostname:
            headers["mcp_server_host"] = headers.pop("host")

    except Exception as e:
        logger.error(f"Error parsing base_url: {e}")
```
```

### Example Code

```Python
import asyncio
import httpx

from fastmcp import FastMCP
import json
import os


from fastmcp.server.openapi import RouteMap, MCPType


async def load_custom_mappings_from_json(json_data: str | list[dict]) -> list[RouteMap]:
    """
    Convert JSON-formatted route mappings into a list of RouteMap objects.

    Args:
        json_data: JSON string or already-parsed list of dicts

    Returns:
        List[RouteMap]
    """
    if isinstance(json_data, str):
        mappings = json.loads(json_data)
    else:
        mappings = json_data

    route_maps = []
    for mapping in mappings:
        methods = mapping.get("methods", "*")
        pattern = mapping["pattern"]
        mcp_type_str = mapping["mcp_type"].upper()

        try:
            mcp_type = MCPType[mcp_type_str]
        except KeyError:
            raise ValueError(f"Invalid MCP type: {mcp_type_str}")

        route_maps.append(RouteMap(methods=methods, pattern=pattern, mcp_type=mcp_type))

    return route_maps

async def load_json(filepath):
    
    with open(filepath, 'r', encoding='utf-8-sig') as file:
        data = json.load(file)        
        return data


async def test_client(http_client):
    response = await http_client.get("/categories/")
    print("Status Code:", response.status_code)
    if response.status_code == 200:
        print("Success: Categories fetched")
        print("Response JSON:", response.json())
    else:
        print("Failed")
        print("Response:", response.text)

async def main():
    filepath="eventbrite-openapi.json"
    openapi_spec = await load_json(filepath)
    
    api_key_header: str = "Authorization"
   

    headers = {}
 
    headers[api_key_header] = f"Bearer <token>"

    print(headers)
    
    
    server_url = openapi_spec["servers"][0]["url"]
    http_client = httpx.AsyncClient(base_url=server_url, headers=headers)
    
    # test client 
    #test_client(http_client)
    
    config_mapping = await load_json("custom_mapping.json")
    print(config_mapping)
    route_maps = await load_custom_mappings_from_json(config_mapping)
    
    # You can now pass this to your tool generator
    mcp = FastMCP.from_openapi(openapi_spec, 
                               client=http_client, 
                               route_maps=route_maps)
    
    # Start MCP with SSE transport
    await mcp.run_sse_async(
        host="127.0.0.1",
        port=9000,
        log_level="debug"
    )


if __name__ == "__main__":
    print("\n--- Starting FastMCP Server for via __main__ ---")
    asyncio.run(main())


custom_mapping.json
====
[
  {
    "methods": ["GET"],
    "pattern": ".*/users/.*",
    "mcp_type": "RESOURCE_TEMPLATE"
  },
  {
    "methods": ["GET"],
    "pattern": ".*/events/.*",
    "mcp_type": "TOOL"
  },
  {
    "methods": ["GET"],
    "pattern": ".*/reports/.*",
    "mcp_type": "TOOL"
  },
  
  {
    "methods": ["POST"],
    "pattern": ".*/",
    "mcp_type": "EXCLUDE"
  }
]
```

### Version Information

```Text
fastmcp version 2.5.1
```

### Additional Context

_No response_


Discussion:
I think this needs a more precise description. As far as I know, httpx always uses the base client's headers and we have tests that explicitly check if the client headers are overwritten as the bug report describes: https://github.com/jlowin/fastmcp/blob/main/tests/client/test_openapi.py

Are you saying that a specific `host` header is being overwritten by a different `host` header? The MCP client headers always overwrite the headers stored on the OpenAPI server. Do you want a way to prevent that from happening on certain headers?
Are you saying that a specific host header is being overwritten by a different host header? - No 

The httpx client has base url `https://www.eventbrite.com/api/v3` which is the base url where the api is hosted and header only has Authorization. httpx client does not have any **host** field in the header. 

So I can have a curl command like 
```
curl --location 'https://www.eventbriteapi.com/v3/venues/263493303/events/' \
--header 'Authorization: ••••••' 
```


The mcp inspector or mcp server has header and it has host which is  as follows and has the host for the mcp server runining on local machine 
```
{'host': '0.0.0.0:9000', 'connection': 'keep-alive', 'accept': 'application/json, text/event-stream', 'content-type': 'application/json', 'accept-language': '*', 'sec-fetch-mode': 'cors', 'user-agent': 'node', 'accept-encoding': 'gzip, deflate'}
```

'host': '0.0.0.0:9000'

When the `self._client.request` has the right base URL, the authorization is updated accordingly, so everything is good. But the header has an additional field 'host': '0.0.0.0:9000' which is contradicting 

The test case is passing as its also running localhost. However every case the open API will be running on a separate host than the mcp server. So every time this issue will block the call and throw a 403 exception. 

Please let me know if you need more details. 
Ok, based on that I think the core problem is that `host` is a bad header to forward from clients to remote servers. We already have an exclude list, so I will add it and I think that should resolve the issue



Patch:
diff --git a/docs/patterns/http-requests.mdx b/docs/patterns/http-requests.mdx
index dd39deab..ceb333e1 100644
--- a/docs/patterns/http-requests.mdx
+++ b/docs/patterns/http-requests.mdx
@@ -77,7 +77,7 @@ async def safe_header_info() -> dict:
     }
 ```
 
-By default, `get_http_headers()` excludes problematic headers like `content-length`. To include all headers, use `get_http_headers(include_all=True)`.
+By default, `get_http_headers()` excludes problematic headers like `host` and `content-length`. To include all headers, use `get_http_headers(include_all=True)`.
 
 ## Important Notes
 
diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py
index 8448a283..e2d279dc 100644
--- a/src/fastmcp/server/dependencies.py
+++ b/src/fastmcp/server/dependencies.py
@@ -48,12 +48,23 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]:
     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}
-
+        exclude_headers = {
+            "host",
+            "content-length",
+            "connection",
+            "transfer-encoding",
+            "upgrade",
+            "te",
+            "keep-alive",
+            "expect",
+            # Proxy-related headers
+            "proxy-authenticate",
+            "proxy-authorization",
+            "proxy-connection",
+        }
+        # (just in case)
+        if not all(h.lower() == h for h in exclude_headers):
+            raise ValueError("Excluded headers must be lowercase")
     headers = {}
 
     try:


Test patch:
diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py
index be2334cf..0a05bb00 100644
--- a/tests/client/test_openapi.py
+++ b/tests/client/test_openapi.py
@@ -28,7 +28,8 @@ def post_headers(request: Request):
         return request.headers
 
     mcp = FastMCP.from_fastapi(
-        app, httpx_client_kwargs={"headers": {"X-SERVER": "test-abc"}}
+        app,
+        httpx_client_kwargs={"headers": {"x-server-header": "test-abc"}},
     )
 
     return mcp
@@ -172,13 +173,30 @@ async def test_client_headers_shttp_tool(self, shttp_server: str):
     async def test_client_overrides_server_headers(self, shttp_server: str):
         async with Client(
             transport=StreamableHttpTransport(
-                shttp_server, headers={"X-SERVER": "test-client"}
+                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"] == "test-client"
+            assert headers["x-server-header"] == "test-client"
+
+    async def test_client_with_excluded_header_is_ignored(self, 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"
 
     async def test_client_headers_proxy(self, proxy_server: str):
         """
@@ -188,4 +206,4 @@ async def test_client_headers_proxy(self, proxy_server: str):
             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"] == "test-abc"
+            assert headers["x-server-header"] == "test-abc"