diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 6edd0d4..82fd0f5 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -14,6 +14,11 @@ COPY pyproject.toml README.md ./ COPY src ./src RUN pip install --upgrade pip && pip install . +# httpx 0.28 ships a regression that raises +# "Attempted to send an sync request with an AsyncClient instance" +# on multipart POSTs (used by bookfusion's /uploads/init). Pin to 0.27.x +# until the upstream fix lands. +RUN pip install --no-cache-dir "httpx>=0.27,<0.28" # Non-root user for read-only library access. RUN useradd -r -u 1000 -m bookfusion diff --git a/src/bookfusion_mcp/bookfusion_api.py b/src/bookfusion_mcp/bookfusion_api.py index 894a086..02b728c 100644 --- a/src/bookfusion_mcp/bookfusion_api.py +++ b/src/bookfusion_mcp/bookfusion_api.py @@ -139,14 +139,34 @@ class BookFusionClient: expect_json: bool = True, ) -> Any: url = url_override or self._url(path) + # httpx builds an IteratorByteStream (sync) whenever `data` is a list + # of tuples — even when files are present — and AsyncClient rejects + # those at send time. We must always pass a dict; duplicate keys are + # collapsed to a list value, which httpx encodes as repeated fields + # (form-urlencoded or multipart, depending on `files`). + req_data: Any = data + if data is not None: + req_data = {} + for k, v in data: + if k in req_data: + existing = req_data[k] + req_data[k] = existing + [v] if isinstance(existing, list) else [existing, v] + else: + req_data[k] = v try: - resp = await self._client.request( + req = self._client.build_request( method, url, params=params, - data=data, + data=req_data, files=files, ) + # Presigned upload URLs (e.g. S3) embed their own signature in + # the query string and reject any extra Authorization header. + # Our BookFusion-wide Basic auth must not leak there. + if url_override: + req.headers.pop("Authorization", None) + resp = await self._client.send(req) except httpx.HTTPError as exc: raise BookFusionError(f"Network error calling {method} {url}: {exc}") from exc