Skip to content

Commit 0ec6972

Browse files
authored
fix: bound hosted binary tool responses (#112)
* fix: bound hosted binary tool responses * fix: guard compressed upstream error bodies
1 parent 85362b8 commit 0ec6972

7 files changed

Lines changed: 440 additions & 6 deletions

File tree

src/mcp_server_appwrite/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ def _resolve_server_version() -> str:
4949
EXCLUDED_SERVICES: frozenset[str] = frozenset()
5050

5151
MAX_FETCH_BYTES = 25 * 1024 * 1024 # 25 MB cap on server-fetched files
52+
# MCP embeds binary tool results as base64 in one JSON-RPC response. Bound the
53+
# source bytes before base64 and JSON encoding create additional in-memory copies.
54+
MAX_HOSTED_BINARY_RESPONSE_BYTES = 25 * 1024 * 1024
5255
# Match Cloud/agent chat attachment max (10 MB). Hosted uploads resolve
5356
# turn attachments to inline base64; keep this at least that large.
5457
MAX_INLINE_BYTES = 10 * 1024 * 1024 # 10 MB cap on decoded inline content

src/mcp_server_appwrite/error_classification.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77

88
from __future__ import annotations
99

10+
import json
1011
from collections.abc import Iterator
11-
from typing import Literal
12+
from typing import Any, Literal
1213

1314
from appwrite_console.exception import AppwriteException
1415

@@ -17,6 +18,7 @@
1718
"appwrite_4xx",
1819
"appwrite_5xx",
1920
"sdk_validation",
21+
"response_too_large",
2022
"internal",
2123
]
2224

@@ -26,6 +28,7 @@
2628
"appwrite_4xx",
2729
"appwrite_5xx",
2830
"sdk_validation",
31+
"response_too_large",
2932
"internal",
3033
}
3134
)
@@ -35,13 +38,43 @@ class WriteConfirmationRequired(RuntimeError):
3538
"""A mutating hidden tool was called without explicit confirmation."""
3639

3740

41+
class HostedBinaryResponseTooLarge(ValueError):
42+
"""A binary Appwrite response exceeded the hosted MCP memory-safe limit."""
43+
44+
def __init__(
45+
self,
46+
tool_name: str,
47+
limit_bytes: int,
48+
*,
49+
content_length: int | None = None,
50+
observed_bytes: int | None = None,
51+
) -> None:
52+
error: dict[str, Any] = {
53+
"code": "hosted_response_too_large",
54+
"tool": tool_name,
55+
"limitBytes": limit_bytes,
56+
"message": (
57+
"The binary response is too large to return through hosted MCP. "
58+
"Use an Appwrite SDK or REST API for larger content."
59+
),
60+
}
61+
if content_length is not None:
62+
error["contentLength"] = content_length
63+
if observed_bytes is not None:
64+
error["observedBytes"] = observed_bytes
65+
super().__init__(json.dumps({"error": error}, separators=(",", ":")))
66+
67+
3868
def classify_tool_error(exc: BaseException) -> ErrorCategory:
3969
"""Return the bounded operational category for an exception chain."""
4070
chain = tuple(_exception_chain(exc))
4171

4272
if any(isinstance(item, WriteConfirmationRequired) for item in chain):
4373
return "write_confirmation"
4474

75+
if any(isinstance(item, HostedBinaryResponseTooLarge) for item in chain):
76+
return "response_too_large"
77+
4578
if any(_is_sdk_validation_error(item) for item in chain):
4679
return "sdk_validation"
4780

src/mcp_server_appwrite/server.py

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
FETCH_TIMEOUT_SECONDS,
6161
HOSTED_PATH_GUIDANCE,
6262
MAX_FETCH_BYTES,
63+
MAX_HOSTED_BINARY_RESPONSE_BYTES,
6364
MAX_INLINE_BYTES,
6465
SERVER_ICON_URL,
6566
SERVER_VERSION,
@@ -73,7 +74,7 @@
7374
get_appwrite_context,
7475
)
7576
from .docs_search import DocsSearch
76-
from .error_classification import is_response_parse_error
77+
from .error_classification import HostedBinaryResponseTooLarge, is_response_parse_error
7778
from .operator import Operator, _parse_tool_name
7879
from .service import Service
7980
from .tool_manager import ToolManager
@@ -381,6 +382,11 @@ def register_services(
381382
name,
382383
allowed_methods=allowed_methods,
383384
context_scope=context_scope(name),
385+
binary_response_limit=(
386+
MAX_HOSTED_BINARY_RESPONSE_BYTES
387+
if profile == OAUTH_PROFILE
388+
else None
389+
),
384390
)
385391
)
386392
return tools_manager
@@ -820,6 +826,126 @@ def _prepare_arguments(tool_info: dict, arguments: dict[str, Any]) -> dict[str,
820826
return prepared_arguments
821827

822828

829+
def _raise_bounded_response_error(response: httpx.Response) -> None:
830+
"""Translate an upstream streaming error into the SDK's public exception."""
831+
body = bytearray()
832+
for chunk in response.iter_bytes():
833+
remaining = MAX_INLINE_BYTES - len(body)
834+
if remaining <= 0:
835+
break
836+
body.extend(chunk[:remaining])
837+
text = bytes(body).decode("utf-8", errors="replace")
838+
message = text or response.reason_phrase
839+
error_type = None
840+
try:
841+
payload = json.loads(text)
842+
if isinstance(payload, dict):
843+
message = str(payload.get("message") or message)
844+
raw_type = payload.get("type")
845+
error_type = str(raw_type) if raw_type is not None else None
846+
except (TypeError, ValueError):
847+
pass
848+
raise AppwriteException(message, response.status_code, error_type, text)
849+
850+
851+
def _perform_bounded_binary_client_call(
852+
client: Client,
853+
tool_name: str,
854+
method: str,
855+
path: str = "",
856+
headers: dict[str, Any] | None = None,
857+
params: dict[str, Any] | None = None,
858+
response_type: str = "json",
859+
) -> bytes:
860+
"""Stream one SDK binary call into a bounded buffer for hosted HTTP."""
861+
if method.lower() != "get" or response_type != "json":
862+
raise RuntimeError(f"Unsupported bounded binary request for {tool_name}.")
863+
864+
request_headers = {
865+
key: value
866+
for key, value in {**client._global_headers, **(headers or {})}.items()
867+
if value
868+
}
869+
# Prevent HTTPX from transparently inflating a compressed response into one
870+
# oversized chunk before the decoded-byte limit can run.
871+
request_headers["accept-encoding"] = "identity"
872+
request_params = client.flatten(params or {})
873+
endpoint = client._endpoint.rstrip("/")
874+
875+
with httpx.Client(
876+
verify=not client._self_signed,
877+
timeout=FETCH_TIMEOUT_SECONDS,
878+
follow_redirects=True,
879+
) as http_client:
880+
with http_client.stream(
881+
method, endpoint + path, headers=request_headers, params=request_params
882+
) as response:
883+
# Check before reading success or error bodies: HTTPX decodes
884+
# ``iter_bytes()`` chunks, so either path could otherwise inflate a
885+
# compressed response beyond the limit before we can count it.
886+
content_encoding = response.headers.get("content-encoding", "identity")
887+
if content_encoding.lower().strip() not in {"", "identity"}:
888+
raise ValueError(
889+
"Hosted MCP cannot safely return a compressed binary response. "
890+
"Use an Appwrite SDK or REST API for this content."
891+
)
892+
893+
if response.status_code >= 400:
894+
_raise_bounded_response_error(response)
895+
896+
warning = response.headers.get("x-appwrite-warning")
897+
if warning:
898+
for item in warning.split(";"):
899+
print(f"Warning: {item}", file=sys.stderr)
900+
901+
declared = response.headers.get("content-length")
902+
if declared:
903+
try:
904+
content_length = int(declared)
905+
except ValueError:
906+
content_length = None
907+
if (
908+
content_length is not None
909+
and content_length > MAX_HOSTED_BINARY_RESPONSE_BYTES
910+
):
911+
raise HostedBinaryResponseTooLarge(
912+
tool_name,
913+
MAX_HOSTED_BINARY_RESPONSE_BYTES,
914+
content_length=content_length,
915+
)
916+
917+
body = bytearray()
918+
for chunk in response.iter_bytes():
919+
observed_bytes = len(body) + len(chunk)
920+
if observed_bytes > MAX_HOSTED_BINARY_RESPONSE_BYTES:
921+
raise HostedBinaryResponseTooLarge(
922+
tool_name,
923+
MAX_HOSTED_BINARY_RESPONSE_BYTES,
924+
observed_bytes=observed_bytes,
925+
)
926+
body.extend(chunk)
927+
return bytes(body)
928+
929+
930+
def _bounded_binary_client_call(
931+
client: Client,
932+
tool_name: str,
933+
method: str,
934+
path: str = "",
935+
headers: dict[str, Any] | None = None,
936+
params: dict[str, Any] | None = None,
937+
response_type: str = "json",
938+
) -> bytes:
939+
try:
940+
return _perform_bounded_binary_client_call(
941+
client, tool_name, method, path, headers, params, response_type
942+
)
943+
except httpx.HTTPError as exc:
944+
# Match the generated SDK contract so callers receive the existing
945+
# Appwrite-formatted tool error instead of an internal HTTPX exception.
946+
raise AppwriteException(str(exc)) from exc
947+
948+
823949
def execute_registered_tool(
824950
tools_manager: ToolManager,
825951
name: str,
@@ -843,13 +969,39 @@ def execute_registered_tool(
843969
# Re-bind the SDK method to a client authenticated for the current request.
844970
# An explicit client takes precedence (used by tests); otherwise it is resolved
845971
# from the request's OAuth access token.
972+
hosted = client is None
846973
if client is None:
847974
client = resolve_client(target_project, organization_id)
848975
bound_method = getattr(service_cls(client), method_name)
976+
bounded_binary = (
977+
hosted and inspect.signature(bound_method).return_annotation is bytes
978+
)
849979

850980
parsed = _parse_tool_name(name)
851981
try:
852-
result = bound_method(**prepared_arguments)
982+
if bounded_binary:
983+
original_call = client.call
984+
setattr(
985+
client,
986+
"call",
987+
lambda method, path="", headers=None, params=None, response_type="json": _bounded_binary_client_call(
988+
client,
989+
name,
990+
method,
991+
path,
992+
headers,
993+
params,
994+
response_type,
995+
),
996+
)
997+
try:
998+
result = bound_method(**prepared_arguments)
999+
finally:
1000+
setattr(client, "call", original_call)
1001+
else:
1002+
result = bound_method(**prepared_arguments)
1003+
except HostedBinaryResponseTooLarge:
1004+
raise
8531005
except AppwriteException as exc:
8541006
error_monitoring.capture_appwrite_exception(
8551007
exc,

src/mcp_server_appwrite/service.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ def __init__(
2323
*,
2424
allowed_methods: frozenset[str] | None = None,
2525
context_scope: str = "console",
26+
binary_response_limit: int | None = None,
2627
):
2728
self.service = service_instance
2829
self.service_name = service_name
2930
self.allowed_methods = allowed_methods
3031
self.context_scope = context_scope
32+
self.binary_response_limit = binary_response_limit
3133
self._method_name_overrides = self.get_method_name_overrides()
3234

3335
def get_method_name_overrides(self) -> Dict[str, str]:
@@ -204,9 +206,20 @@ def list_tools(self) -> Dict[str, Dict]:
204206
if param.default is param.empty:
205207
required.append(param_name)
206208

209+
description = docstring.short_description or "No description available"
210+
if (
211+
self.binary_response_limit is not None
212+
and type_hints.get("return") is bytes
213+
):
214+
limit_mib = self.binary_response_limit // (1024 * 1024)
215+
description = (
216+
f"{description} Hosted MCP returns binary responses up to "
217+
f"{limit_mib} MiB; use an Appwrite SDK or REST API for larger content."
218+
)
219+
207220
tool_definition = Tool(
208221
name=tool_name,
209-
description=docstring.short_description or "No description available",
222+
description=description,
210223
input_schema={
211224
"type": "object",
212225
"properties": properties,

tests/unit/test_error_classification.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pydantic import BaseModel, ValidationError
55

66
from mcp_server_appwrite.error_classification import (
7+
HostedBinaryResponseTooLarge,
78
WriteConfirmationRequired,
89
classify_tool_error,
910
is_response_parse_error,
@@ -17,6 +18,10 @@ def test_write_confirmation(self):
1718
"write_confirmation",
1819
)
1920

21+
def test_hosted_binary_response_too_large(self):
22+
error = HostedBinaryResponseTooLarge("storage_get_file_download", 1024)
23+
self.assertEqual(classify_tool_error(error), "response_too_large")
24+
2025
def test_wrapped_appwrite_4xx(self):
2126
for code in (400, 401, 404, 409, 429, 499):
2227
with self.subTest(code=code):

0 commit comments

Comments
 (0)