Skip to content

Commit 8200e8b

Browse files
authored
Merge pull request #49 from appwrite/feat/hosted-url-upload
feat: support URL-fetch uploads on the hosted transport
2 parents e1231af + 86c3295 commit 8200e8b

3 files changed

Lines changed: 350 additions & 20 deletions

File tree

src/mcp_server_appwrite/server.py

Lines changed: 186 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
import base64
66
import importlib
77
import inspect
8+
import ipaddress
89
import json
10+
import mimetypes
911
import os
1012
import pkgutil
1113
import re
14+
import socket
1215
import sys
1316
from collections.abc import Mapping
1417
from dataclasses import dataclass
@@ -18,7 +21,9 @@
1821
from pathlib import Path
1922
from types import UnionType
2023
from typing import Any, Union, get_args, get_origin
24+
from urllib.parse import unquote, urlsplit
2125

26+
import httpx
2227
import mcp.server.stdio
2328
import mcp.types as types
2429
from appwrite.client import Client
@@ -331,42 +336,200 @@ def _coerce_enum(enum_type: type[Enum], value: Any, param_name: str) -> Any:
331336
) from exc
332337

333338

339+
# Upload behavior is configured once per server process at build time, since a given
340+
# process serves exactly one transport.
341+
# stdio: local filesystem paths are read directly; URL fetch also allowed.
342+
# http : the server runs remotely with no access to the client's filesystem, so local
343+
# paths are rejected with guidance; uploads come via URL fetch or inline bytes.
344+
_UPLOAD_TRANSPORT: str = "stdio"
345+
346+
_MAX_FETCH_BYTES = 25 * 1024 * 1024 # 25 MB cap on server-fetched files
347+
_MAX_INLINE_BYTES = 256 * 1024 # 256 KB cap on decoded inline content
348+
_FETCH_TIMEOUT_SECONDS = 30.0
349+
_FETCH_MAX_REDIRECTS = 5
350+
351+
_HOSTED_PATH_GUIDANCE = (
352+
"The hosted Appwrite MCP server cannot read local file paths. For '{param}', pass a "
353+
'public URL as {{"url": "https://..."}} (preferred), or a small file inline as '
354+
'{{"filename": "...", "content": "<base64>", "encoding": "base64"}}.'
355+
)
356+
357+
358+
def _configure_uploads(transport: str) -> None:
359+
"""Set the upload mode for this server process. Called once from build_mcp_server."""
360+
global _UPLOAD_TRANSPORT
361+
_UPLOAD_TRANSPORT = transport
362+
363+
364+
def _validate_fetch_url(url: str) -> None:
365+
"""Reject non-http(s) schemes and hosts that resolve to non-public addresses.
366+
367+
This is the SSRF guard for server-side URL fetches: it stops the model from making
368+
the hosted server reach internal services, loopback, or the cloud metadata endpoint
369+
(169.254.169.254). Note the resolve-then-reconnect DNS-rebinding gap is accepted.
370+
"""
371+
parts = urlsplit(url)
372+
if parts.scheme not in ("http", "https"):
373+
raise ValueError(
374+
f"Unsupported URL scheme '{parts.scheme}' — only http and https are allowed."
375+
)
376+
host = parts.hostname
377+
if not host:
378+
raise ValueError("URL is missing a host.")
379+
380+
port = parts.port or (443 if parts.scheme == "https" else 80)
381+
try:
382+
infos = socket.getaddrinfo(host, port)
383+
except socket.gaierror as exc:
384+
raise ValueError(f"Could not resolve host '{host}'.") from exc
385+
386+
for info in infos:
387+
ip = ipaddress.ip_address(info[4][0])
388+
if (
389+
ip.is_private
390+
or ip.is_loopback
391+
or ip.is_link_local
392+
or ip.is_reserved
393+
or ip.is_multicast
394+
or ip.is_unspecified
395+
):
396+
raise ValueError(
397+
"Refusing to fetch a URL that resolves to a private, loopback, or "
398+
"link-local address."
399+
)
400+
401+
402+
def _derive_filename(resp: httpx.Response, url: str) -> str:
403+
"""Best-effort filename from Content-Disposition, then the URL path, then a fallback."""
404+
disposition = resp.headers.get("content-disposition", "")
405+
match = re.search(
406+
r"filename\*?=(?:[^']*'[^']*')?\"?([^\";]+)\"?", disposition, re.IGNORECASE
407+
)
408+
candidate = ""
409+
if match:
410+
candidate = unquote(match.group(1)).strip().strip('"')
411+
if not candidate:
412+
segment = urlsplit(url).path.rstrip("/").rsplit("/", 1)[-1]
413+
candidate = unquote(segment).strip()
414+
# Sanitize to a bare filename — strip any directory components or traversal.
415+
candidate = candidate.replace("\\", "/").rsplit("/", 1)[-1]
416+
if candidate in ("", ".", ".."):
417+
mime_type = (resp.headers.get("content-type") or "").split(";")[0].strip()
418+
extension = mimetypes.guess_extension(mime_type) if mime_type else None
419+
candidate = f"upload{extension}" if extension else "upload"
420+
return candidate
421+
422+
423+
def _fetch_input_file(url: str, param_name: str) -> InputFile:
424+
"""Download a public URL (SSRF-guarded, size-capped) into an in-memory InputFile."""
425+
_validate_fetch_url(url)
426+
try:
427+
with httpx.Client(
428+
timeout=_FETCH_TIMEOUT_SECONDS,
429+
follow_redirects=True,
430+
max_redirects=_FETCH_MAX_REDIRECTS,
431+
limits=httpx.Limits(max_connections=1),
432+
) as client:
433+
with client.stream("GET", url) as resp:
434+
resp.raise_for_status()
435+
# The final URL after redirects must also be public.
436+
_validate_fetch_url(str(resp.url))
437+
438+
declared = resp.headers.get("content-length")
439+
if declared is not None and declared.isdigit():
440+
if int(declared) > _MAX_FETCH_BYTES:
441+
raise ValueError(
442+
f"File at URL for '{param_name}' is too large "
443+
f"({declared} bytes); max is {_MAX_FETCH_BYTES} bytes."
444+
)
445+
446+
chunks: list[bytes] = []
447+
total = 0
448+
for chunk in resp.iter_bytes():
449+
total += len(chunk)
450+
if total > _MAX_FETCH_BYTES:
451+
raise ValueError(
452+
f"File at URL for '{param_name}' exceeds the max of "
453+
f"{_MAX_FETCH_BYTES} bytes."
454+
)
455+
chunks.append(chunk)
456+
457+
data = b"".join(chunks)
458+
mime_type = (
459+
(resp.headers.get("content-type") or "").split(";")[0].strip()
460+
)
461+
filename = _derive_filename(resp, url)
462+
except httpx.HTTPError as exc:
463+
raise ValueError(
464+
f"Failed to fetch file from URL for '{param_name}': {exc}"
465+
) from exc
466+
467+
return InputFile.from_bytes(data, filename, mime_type or None)
468+
469+
470+
def _coerce_inline_content(value: Mapping, param_name: str) -> InputFile:
471+
filename = value.get("filename")
472+
content = value.get("content")
473+
encoding = str(value.get("encoding", "utf-8")).lower()
474+
if encoding == "base64":
475+
try:
476+
data = base64.b64decode(content)
477+
except Exception as exc:
478+
raise ValueError(f"Invalid base64 content for '{param_name}'.") from exc
479+
elif encoding == "utf-8":
480+
data = str(content).encode("utf-8")
481+
else:
482+
raise ValueError(
483+
f"Invalid encoding for '{param_name}'. Expected 'utf-8' or 'base64'."
484+
)
485+
486+
if len(data) > _MAX_INLINE_BYTES:
487+
raise ValueError(
488+
f"Inline content for '{param_name}' is too large "
489+
f"({len(data)} bytes, max {_MAX_INLINE_BYTES}). For larger files pass "
490+
'{"url": "https://..."} so the server can download it directly.'
491+
)
492+
493+
return InputFile.from_bytes(data, str(filename), value.get("mime_type"))
494+
495+
496+
def _coerce_path(path: str, param_name: str) -> InputFile:
497+
if _UPLOAD_TRANSPORT != "stdio":
498+
raise ValueError(_HOSTED_PATH_GUIDANCE.format(param=param_name))
499+
return InputFile.from_path(path)
500+
501+
334502
def _coerce_input_file(value: Any, param_name: str) -> InputFile:
335503
if isinstance(value, InputFile):
336504
return value
337505

338506
if isinstance(value, str):
339-
return InputFile.from_path(value)
507+
if urlsplit(value).scheme in ("http", "https"):
508+
return _fetch_input_file(value, param_name)
509+
return _coerce_path(value, param_name)
340510

341511
if not isinstance(value, Mapping):
342512
raise ValueError(
343-
f"Invalid value for '{param_name}'. Provide a file path string or an object with `path` or `filename` and `content`."
513+
f"Invalid value for '{param_name}'. Provide a public URL string, a `url`, or "
514+
"an object with `filename` and `content`."
344515
)
345516

517+
url = value.get("url")
518+
if url:
519+
return _fetch_input_file(str(url), param_name)
520+
346521
path = value.get("path")
347522
if path:
348-
return InputFile.from_path(str(path))
523+
return _coerce_path(str(path), param_name)
349524

350525
filename = value.get("filename")
351526
content = value.get("content")
352527
if filename and content is not None:
353-
encoding = str(value.get("encoding", "utf-8")).lower()
354-
if encoding == "base64":
355-
try:
356-
data = base64.b64decode(content)
357-
except Exception as exc:
358-
raise ValueError(f"Invalid base64 content for '{param_name}'.") from exc
359-
elif encoding == "utf-8":
360-
data = str(content).encode("utf-8")
361-
else:
362-
raise ValueError(
363-
f"Invalid encoding for '{param_name}'. Expected 'utf-8' or 'base64'."
364-
)
365-
366-
return InputFile.from_bytes(data, str(filename), value.get("mime_type"))
528+
return _coerce_inline_content(value, param_name)
367529

368530
raise ValueError(
369-
f"Invalid value for '{param_name}'. Provide `path`, or both `filename` and `content`."
531+
f"Invalid value for '{param_name}'. Provide `url`, or both `filename` and "
532+
"`content`."
370533
)
371534

372535

@@ -680,11 +843,16 @@ def build_instructions(transport: str = "http") -> str:
680843
"target project: use appwrite_get_context first, then pass the selected "
681844
"project id as project_id to appwrite_call_tool. "
682845
"Organization-scoped console tools (e.g. creating a project) need organization_id. "
846+
"File/image uploads: pass a public URL as the file argument (e.g. "
847+
'{"url": "https://..."}) so the server downloads it directly; for very small '
848+
'files you may pass inline base64 ({"filename": ..., "content": ..., '
849+
'"encoding": "base64"}). '
683850
f"{common}"
684851
)
685852

686853

687854
def build_mcp_server(operator: Operator, *, transport: str = "http") -> Server:
855+
_configure_uploads(transport)
688856
instructions = build_instructions(transport)
689857

690858
server = Server("Appwrite MCP Server", instructions=instructions)

src/mcp_server_appwrite/service.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,18 @@ def _input_file_schema(self) -> dict:
4545
"oneOf": [
4646
{
4747
"type": "string",
48-
"description": "Path to a local file on the machine running the MCP server.",
48+
"description": "A public http(s) URL the server downloads the file from, or a local file path (paths only work when the server runs locally).",
4949
},
5050
{
5151
"type": "object",
5252
"properties": {
53+
"url": {
54+
"type": "string",
55+
"description": "Public http(s) URL the server downloads the file from. Preferred for images and any non-trivial file on the hosted server.",
56+
},
5357
"path": {
5458
"type": "string",
55-
"description": "Path to a local file on the machine running the MCP server.",
59+
"description": "Path to a local file. Only works when the server runs locally; on the hosted server use `url` instead.",
5660
},
5761
"filename": {
5862
"type": "string",

0 commit comments

Comments
 (0)