|
5 | 5 | import base64 |
6 | 6 | import importlib |
7 | 7 | import inspect |
| 8 | +import ipaddress |
8 | 9 | import json |
| 10 | +import mimetypes |
9 | 11 | import os |
10 | 12 | import pkgutil |
11 | 13 | import re |
| 14 | +import socket |
12 | 15 | import sys |
13 | 16 | from collections.abc import Mapping |
14 | 17 | from dataclasses import dataclass |
|
18 | 21 | from pathlib import Path |
19 | 22 | from types import UnionType |
20 | 23 | from typing import Any, Union, get_args, get_origin |
| 24 | +from urllib.parse import unquote, urlsplit |
21 | 25 |
|
| 26 | +import httpx |
22 | 27 | import mcp.server.stdio |
23 | 28 | import mcp.types as types |
24 | 29 | from appwrite.client import Client |
@@ -331,42 +336,200 @@ def _coerce_enum(enum_type: type[Enum], value: Any, param_name: str) -> Any: |
331 | 336 | ) from exc |
332 | 337 |
|
333 | 338 |
|
| 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 | + |
334 | 502 | def _coerce_input_file(value: Any, param_name: str) -> InputFile: |
335 | 503 | if isinstance(value, InputFile): |
336 | 504 | return value |
337 | 505 |
|
338 | 506 | 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) |
340 | 510 |
|
341 | 511 | if not isinstance(value, Mapping): |
342 | 512 | 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`." |
344 | 515 | ) |
345 | 516 |
|
| 517 | + url = value.get("url") |
| 518 | + if url: |
| 519 | + return _fetch_input_file(str(url), param_name) |
| 520 | + |
346 | 521 | path = value.get("path") |
347 | 522 | if path: |
348 | | - return InputFile.from_path(str(path)) |
| 523 | + return _coerce_path(str(path), param_name) |
349 | 524 |
|
350 | 525 | filename = value.get("filename") |
351 | 526 | content = value.get("content") |
352 | 527 | 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) |
367 | 529 |
|
368 | 530 | 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`." |
370 | 533 | ) |
371 | 534 |
|
372 | 535 |
|
@@ -680,11 +843,16 @@ def build_instructions(transport: str = "http") -> str: |
680 | 843 | "target project: use appwrite_get_context first, then pass the selected " |
681 | 844 | "project id as project_id to appwrite_call_tool. " |
682 | 845 | "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"}). ' |
683 | 850 | f"{common}" |
684 | 851 | ) |
685 | 852 |
|
686 | 853 |
|
687 | 854 | def build_mcp_server(operator: Operator, *, transport: str = "http") -> Server: |
| 855 | + _configure_uploads(transport) |
688 | 856 | instructions = build_instructions(transport) |
689 | 857 |
|
690 | 858 | server = Server("Appwrite MCP Server", instructions=instructions) |
|
0 commit comments