diff --git a/AGENTS.md b/AGENTS.md
index fa4e757..7ddcaac 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,6 +34,7 @@ Source lives in `src/mcp_server_appwrite/`:
| `instructions.py` | `serverInfo.description` and the server instructions sent on initialize, rendered from `assets/instructions.json` (a description plus ordered, transport/docs-conditional Markdown sections). Edit the JSON to change the wording; keep the `pyproject.toml` description in sync (a test enforces it). |
| `context.py` | `appwrite_get_context` — workspace summary (project, services, account/org for OAuth, a focused project's environments). |
| `headers.py` | Per-call request headers: `X-Appwrite-Environment` (a project environment, next to the project's ID) and `X-Appwrite-Reason` (why a write is made, validated and percent-encoded as Appwrite expects). |
+| `redaction.py` | Redacts the response fields Appwrite's spec marks `format: password` (carried on Console SDK model fields) from tool results, leaving a fingerprint, unless a call sets `reveal_secrets`. |
| `docs_search.py` | In-process semantic docs search (`appwrite_search_docs`) over a prebuilt index. |
| `docs_source.py` | Fetches the published docs (`/docs/llms.txt` + per-page `.md` exports on appwrite.io) and chunks them for the index build. |
| `docs_index.py` | Writes the index artifact deterministically and atomically: page and chunk hashes, reuse of cached chunk vectors, build lock, and the added/changed/removed page report. |
diff --git a/docs/appwrite-mcp-flow.png b/docs/appwrite-mcp-flow.png
index b3e6fd7..e67e400 100644
Binary files a/docs/appwrite-mcp-flow.png and b/docs/appwrite-mcp-flow.png differ
diff --git a/docs/appwrite-mcp-flow.svg b/docs/appwrite-mcp-flow.svg
index 836315b..2357079 100644
--- a/docs/appwrite-mcp-flow.svg
+++ b/docs/appwrite-mcp-flow.svg
@@ -115,7 +115,7 @@
search_docs
- hosted catalog · 41 services · 1029 tools
+ hosted catalog · 41 services · 1065 tools
writes need confirm_write: true
diff --git a/docs/self-hosted.md b/docs/self-hosted.md
index 240666f..b10069b 100644
--- a/docs/self-hosted.md
+++ b/docs/self-hosted.md
@@ -4,7 +4,7 @@ Running your own Appwrite instance? Run the MCP server locally over `stdio` and
authenticate with a project API key instead of OAuth.
The local API-key catalog is intentionally smaller than the hosted OAuth catalog:
-it exposes 689 project-key-compatible methods across 27 services. DocumentsDB,
+it exposes 720 project-key-compatible methods across 27 services. DocumentsDB,
VectorsDB, and text embeddings are available, but console control-plane services
such as organizations, domains, projects, billing, migrations, dedicated
databases, usage administration, VCS administration, and WAF administration are
diff --git a/docs/tool-surface.md b/docs/tool-surface.md
index fbeeb1c..8dd986d 100644
--- a/docs/tool-surface.md
+++ b/docs/tool-surface.md
@@ -22,7 +22,7 @@ flowchart LR
subgraph CAT[Internal catalog — authentication-aware]
direction LR
- K[OAuth: 41 services / 1029 tools
API key: 27 services / 689 tools]
+ K[OAuth: 41 services / 1065 tools
API key: 27 services / 720 tools]
end
CT -->|large output| R[(MCP resource
preview + URI)]
@@ -45,6 +45,14 @@ flowchart LR
- **Large outputs** are stored as an MCP resource and returned as preview text
plus a resource URI.
+- **Secrets** are redacted from results by default: every response field
+ Appwrite's API spec marks `format: password` (API key and token secrets,
+ webhook signing keys, password hashes, OAuth client secrets, MFA recovery
+ codes, database passwords). A redacted string becomes `[redacted]` with a
+ sibling `Fingerprint` (a SHA-256 prefix) to tell secrets apart or check
+ a stored copy, and a note names the redacted fields. `reveal_secrets=true` on
+ `appwrite_call_tool` or `appwrite_call_write_tool` returns them in full, for
+ when the user asks to see a secret.
- **Reads and writes** go through separate tools so each tool's annotations
match every call it accepts: `appwrite_call_tool` is read-only and
idempotent, `appwrite_call_write_tool` is destructive. Each search result
@@ -72,11 +80,11 @@ flowchart LR
`context=organization`, or `context=project`. Hosted calls enforce the required
top-level `organization_id` or `project_id` before making a request.
- **Access** is still gated per-route by the scopes granted to the OAuth token.
-- **Hosted OAuth** registers all 41 services and 1029 methods shipped by
- `appwrite-console` 0.12.0. This adds console control-plane services including
+- **Hosted OAuth** registers all 41 services and 1065 methods shipped by
+ `appwrite-console` 0.16.0. This adds console control-plane services including
projects, organizations, domains, migrations, dedicated databases, usage, VCS,
vectors, WAF, notifications, and regions.
-- **API-key stdio** deliberately registers only the 689 project-key-compatible
+- **API-key stdio** deliberately registers only the 720 project-key-compatible
methods across 27 services. It includes the new DocumentsDB, VectorsDB, and
text-embeddings APIs, while console administration methods remain hidden.
- **Registration** remains SDK-driven, with the authentication profile policy
diff --git a/pyproject.toml b/pyproject.toml
index df1a822..e9552fa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"anyio>=4.0.0",
- "appwrite-console>=0.12.0,<0.13",
+ "appwrite-console>=0.16.0,<0.17",
"docstring-parser>=0.16",
"mcp[cli]>=2.3,<3",
"python-dotenv>=1.0.1",
diff --git a/src/mcp_server_appwrite/apps/views.py b/src/mcp_server_appwrite/apps/views.py
index dc070a1..31dc80e 100644
--- a/src/mcp_server_appwrite/apps/views.py
+++ b/src/mcp_server_appwrite/apps/views.py
@@ -497,8 +497,9 @@ def _row(row: dict[str, Any]) -> dict[str, Any]:
def _json(content: list[Any]) -> Any:
- text = "\n".join(
- item.text for item in content if isinstance(item, types.TextContent)
+ # The result is the first text item; a note on redacted secrets may follow.
+ text = next(
+ (item.text for item in content if isinstance(item, types.TextContent)), ""
)
try:
return json.loads(text)
diff --git a/src/mcp_server_appwrite/operator.py b/src/mcp_server_appwrite/operator.py
index ebf6b85..2c9f8e2 100644
--- a/src/mcp_server_appwrite/operator.py
+++ b/src/mcp_server_appwrite/operator.py
@@ -47,10 +47,10 @@
from .tool_manager import ToolManager
ToolContent = types.TextContent | types.ImageContent | types.EmbeddedResource
-# (tool_name, arguments, project_id, organization_id, environment_id, reason)
-# -> content
+# (tool_name, arguments, project_id, organization_id, environment_id, reason,
+# reveal) -> content
ToolExecutor = Callable[
- [str, dict[str, Any], str | None, str | None, str | None, str | None],
+ [str, dict[str, Any], str | None, str | None, str | None, str | None, bool],
list[ToolContent],
]
ContextProvider = Callable[[dict[str, Any]], dict[str, Any]]
@@ -668,6 +668,9 @@ def _run_hidden_tool(
f"Tool {tool_name} requires organization_id. Use {CONTEXT_TOOL_NAME} "
"to select an organization, then retry with that organization ID."
)
+ reveal = bool(
+ raw_arguments.get("reveal_secrets", raw_arguments.get("revealSecrets"))
+ )
arguments_object = _normalize_arguments(raw_arguments)
return self._execute_tool(
tool_name,
@@ -676,6 +679,7 @@ def _run_hidden_tool(
organization_id,
environment_id or None,
reason,
+ reveal,
)
def _preview_or_store_result(
@@ -812,6 +816,16 @@ def _call_input_schema(*, write: bool) -> dict[str, Any]:
"log and shown in the Activity tab next to the change."
),
}
+ properties["reveal_secrets"] = {
+ "type": "boolean",
+ "description": (
+ "Return secrets in full: API key and token secrets, signing keys, "
+ "client secrets, password hashes and other fields Appwrite marks "
+ "secret. Off by default, so they are redacted and replaced with a "
+ "fingerprint. Set true only when the user explicitly asks to see a "
+ "secret in this conversation."
+ ),
+ }
properties |= {
"project_id": {
"type": "string",
@@ -1225,6 +1239,8 @@ def _normalize_arguments(raw_arguments: dict[str, Any]) -> dict[str, Any]:
"environment_id",
"environmentId",
"reason",
+ "reveal_secrets",
+ "revealSecrets",
}:
continue
if value is not None:
diff --git a/src/mcp_server_appwrite/redaction.py b/src/mcp_server_appwrite/redaction.py
new file mode 100644
index 0000000..4c35901
--- /dev/null
+++ b/src/mcp_server_appwrite/redaction.py
@@ -0,0 +1,141 @@
+"""Keeps secrets out of tool results.
+
+A tool result goes into the model's context and from there into transcripts,
+subagent logs and caches, so a secret Appwrite returns (an API key created or
+updated, a webhook's signing key, a user's password hash) would leak the moment
+the call returns. Appwrite's API spec marks every such response field with
+``format: password``, and the Console SDK carries that on each model field, so
+the result is redacted from the SDK model before it is serialized, unless the
+call sets ``reveal_secrets``.
+
+A redacted value is replaced with ``[redacted]``. A string also gets a sibling
+``Fingerprint``, a short SHA-256 of the value, so the caller can still
+tell which secret it got and check a stored copy against it. Empty values are
+kept: they only say a secret is unset, or that Appwrite already hid it.
+
+A payload the SDK could not hydrate into its model has no field types to go
+by, so it is redacted by name: every field name some SDK model marks secret.
+Rows from a ``select`` query are not redacted at all: they are the caller's own
+data, not Appwrite's secrets, and a column may share a secret field's name.
+"""
+
+import hashlib
+from functools import cache
+from typing import Any
+
+import appwrite_console.models
+from pydantic import BaseModel
+from pydantic.fields import FieldInfo
+
+REDACTED = "[redacted]"
+
+SECRET_FORMAT = "password"
+"""The OpenAPI format Appwrite's spec marks secret response fields with."""
+
+
+def redact(result: Any) -> tuple[Any, list[str]]:
+ """The result as JSON-ready data with every secret field redacted, and the
+ names of the fields that were, sorted."""
+ fields: set[str] = set()
+ return _clean(result, fields), sorted(fields)
+
+
+def redact_payload(payload: Any) -> tuple[Any, list[str]]:
+ """A raw response payload with every field named like a secret field
+ redacted, and the names that were, sorted."""
+ fields: set[str] = set()
+ return _clean_payload(payload, fields), sorted(fields)
+
+
+def fingerprint(value: str) -> str:
+ """A short SHA-256 of a secret: enough to tell secrets apart, not to recover one."""
+ return "sha256:" + hashlib.sha256(value.encode()).hexdigest()[:12]
+
+
+def note(fields: list[str]) -> str:
+ """Tells the model what was redacted and how to see it if the user asks."""
+ return (
+ f"Secrets redacted from this result: {', '.join(fields)}. Each redacted "
+ "string has a Fingerprint (a SHA-256 prefix) to compare against a "
+ "stored copy. Only if the user explicitly asks to see a secret in this "
+ "conversation, read it again with reveal_secrets=true (do not repeat a "
+ "create, which would make a new secret)."
+ )
+
+
+def _clean(value: Any, fields: set[str]) -> Any:
+ if isinstance(value, BaseModel):
+ return _clean_model(value, fields)
+ if isinstance(value, (list, tuple)):
+ return [_clean(item, fields) for item in value]
+ if isinstance(value, dict):
+ return {key: _clean(item, fields) for key, item in value.items()}
+ return value
+
+
+def _clean_model(model: BaseModel, fields: set[str]) -> dict[str, Any]:
+ data = model.model_dump(by_alias=True, mode="json", exclude_unset=True)
+ for name, field in type(model).model_fields.items():
+ key = field.alias or name
+ if key not in data:
+ continue
+ if _secret(field):
+ _mask(data, key, fields)
+ continue
+ item = getattr(model, name)
+ if _holds_model(item):
+ data[key] = _clean(item, fields)
+ return data
+
+
+def _clean_payload(value: Any, fields: set[str]) -> Any:
+ if isinstance(value, list):
+ return [_clean_payload(item, fields) for item in value]
+ if not isinstance(value, dict):
+ return value
+ data = dict(value)
+ for key, item in value.items():
+ if key in _secret_names():
+ _mask(data, key, fields)
+ else:
+ data[key] = _clean_payload(item, fields)
+ return data
+
+
+def _mask(data: dict[str, Any], key: str, fields: set[str]) -> None:
+ """Replace a secret value in place. Empty values stay: they only say the
+ secret is unset, or that Appwrite already hid it."""
+ value = data[key]
+ if value in ("", [], None):
+ return
+ fields.add(key)
+ if isinstance(value, str):
+ data[f"{key}Fingerprint"] = fingerprint(value)
+ data[key] = REDACTED
+
+
+@cache
+def _secret_names() -> frozenset[str]:
+ """Every field name an SDK model marks secret."""
+ names: set[str] = set()
+ for model in vars(appwrite_console.models).values():
+ if isinstance(model, type) and issubclass(model, BaseModel):
+ for name, field in model.model_fields.items():
+ if _secret(field):
+ names.add(field.alias or name)
+ return frozenset(names)
+
+
+def _secret(field: FieldInfo) -> bool:
+ extra = field.json_schema_extra
+ return isinstance(extra, dict) and extra.get("format") == SECRET_FORMAT
+
+
+def _holds_model(value: Any) -> bool:
+ if isinstance(value, BaseModel):
+ return True
+ if isinstance(value, (list, tuple)):
+ return any(_holds_model(item) for item in value)
+ if isinstance(value, dict):
+ return any(_holds_model(item) for item in value.values())
+ return False
diff --git a/src/mcp_server_appwrite/server.py b/src/mcp_server_appwrite/server.py
index e680eea..a9af7d4 100644
--- a/src/mcp_server_appwrite/server.py
+++ b/src/mcp_server_appwrite/server.py
@@ -83,6 +83,7 @@
from .headers import environment, scoped, set_environment
from .instructions import build_instructions, load_description
from .operator import Operator, ToolOutput, _parse_tool_name
+from .redaction import note, redact, redact_payload
from .service import Service
from .tool_manager import ToolManager
@@ -1108,6 +1109,7 @@ def execute_registered_tool(
organization_id: str | None = None,
environment_id: str | None = None,
reason: str | None = None,
+ reveal: bool = False,
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
tool_info = tools_manager.get_tool(name)
if not tool_info:
@@ -1188,7 +1190,9 @@ def execute_registered_tool(
organization_id=organization_id,
environment_id=environment_id,
)
- raise RuntimeError(_format_appwrite_error(exc, tool_name=name)) from exc
+ raise RuntimeError(
+ _format_appwrite_error(exc, tool_name=name, reveal=reveal)
+ ) from exc
except Exception as exc:
error_monitoring.capture_exception(
exc,
@@ -1214,7 +1218,14 @@ def execute_registered_tool(
)
raise
- return _format_tool_result(name, result, prepared_arguments)
+ if reveal:
+ return _format_tool_result(name, result, prepared_arguments)
+ # Secrets stay out of the model's context unless the call asks for them.
+ result, redacted = redact(result)
+ content = _format_tool_result(name, result, prepared_arguments)
+ if redacted:
+ content.append(types.TextContent(type="text", text=note(redacted)))
+ return content
def _json_default(value: Any) -> Any:
@@ -1306,9 +1317,13 @@ def _format_tool_result(
return [types.TextContent(type="text", text=str(result))]
-def _format_appwrite_error(exc: AppwriteException, tool_name: str | None = None) -> str:
+def _format_appwrite_error(
+ exc: AppwriteException, tool_name: str | None = None, reveal: bool = False
+) -> str:
if is_response_parse_error(exc):
- return _format_response_parse_error(tool_name, _parse_error_response(exc))
+ return _format_response_parse_error(
+ tool_name, _parse_error_response(exc), reveal
+ )
details = []
if getattr(exc, "code", None):
@@ -1331,7 +1346,9 @@ def _parse_error_response(exc: BaseException) -> Any:
return None
-def _format_response_parse_error(tool_name: str | None, response: Any = None) -> str:
+def _format_response_parse_error(
+ tool_name: str | None, response: Any = None, reveal: bool = False
+) -> str:
"""Explain an SDK deserialization failure without implying the write failed.
``Service._parse_response`` raises this after Appwrite has already accepted
@@ -1340,11 +1357,14 @@ def _format_response_parse_error(tool_name: str | None, response: Any = None) ->
completed mutation.
"""
if response is not None:
- return (
+ # Untyped, so secrets are redacted by name rather than by model.
+ response, redacted = (response, []) if reveal else redact_payload(response)
+ message = (
"Appwrite accepted the request and it succeeded; the SDK could not "
"deserialize the response into its model, so the raw payload "
f"follows. Do not retry.\n{_serialize_result(response)}"
)
+ return f"{message}\n{note(redacted)}" if redacted else message
verify = (
f" Verify with the matching get or list tool for {tool_name} before retrying."
@@ -1823,7 +1843,7 @@ def build_operator(
return Operator(
tools_manager,
- lambda tool_name, tool_arguments, target_project=None, organization_id=None, environment_id=None, reason=None: (
+ lambda tool_name, tool_arguments, target_project=None, organization_id=None, environment_id=None, reason=None, reveal=False: (
execute_registered_tool(
tools_manager,
tool_name,
@@ -1833,6 +1853,7 @@ def build_operator(
organization_id=organization_id,
environment_id=environment_id,
reason=reason,
+ reveal=reveal,
)
),
context_provider=lambda arguments: _get_context_for_request(arguments, client),
diff --git a/tests/e2e/test_stdio.py b/tests/e2e/test_stdio.py
index 8dedb86..0d5fb5a 100644
--- a/tests/e2e/test_stdio.py
+++ b/tests/e2e/test_stdio.py
@@ -169,6 +169,12 @@ async def script(client: ClientSession) -> dict[str, Any]:
# Every request is in the environment; the reason went with its write alone.
self.assertFalse(session["read"].is_error, session["read"])
+ # Password hashes never reach the model, only their fingerprints.
+ listed_users = session["read"].content[0].text
+ self.assertNotIn("$argon2", listed_users)
+ for each in json.loads(listed_users)["users"]:
+ self.assertEqual(each["password"], "[redacted]")
+ self.assertTrue(each["passwordFingerprint"].startswith("sha256:"))
listed = self.cloud.headers("GET", "/v1/users")
self.assertTrue(listed)
for headers in listed:
diff --git a/tests/e2e/test_tools.py b/tests/e2e/test_tools.py
index 9f2b7f1..76396d5 100644
--- a/tests/e2e/test_tools.py
+++ b/tests/e2e/test_tools.py
@@ -16,6 +16,7 @@
"""
import base64
+import hashlib
import json
import unittest
from typing import Any
@@ -310,7 +311,37 @@ def test_reads_and_writes_run_only_through_their_own_tool(self):
)
self.assertFalse(result.get("isError"), result)
self.assertEqual(cloud.calls("POST", "/v1/webhooks"), created + 1)
- self.assertIn(WEBHOOK["webhook_id"], cloud.documents(PROJECT))
+ signing = cloud.documents(PROJECT)[WEBHOOK["webhook_id"]]["signatureKey"]
+
+ # The signing key Appwrite returns stays out of the model's context:
+ # only a fingerprint of it, and a note on how to see it if asked.
+ self.assertNotIn(signing, json.dumps(result["content"]))
+ webhook = json.loads(self.text(result))
+ self.assertEqual(webhook["secret"], "[redacted]")
+ self.assertEqual(
+ webhook["secretFingerprint"],
+ "sha256:" + hashlib.sha256(signing.encode()).hexdigest()[:12],
+ )
+ self.assertIn(
+ "Secrets redacted from this result: secret.", result["content"][1]["text"]
+ )
+
+ # Asked for, a rotated key comes back in full.
+ result = self.call(
+ "appwrite_call_write_tool",
+ {
+ "tool_name": "webhooks_update_secret",
+ "project_id": PROJECT,
+ "confirm_write": True,
+ "reason": "Show the user the rotated signing key",
+ "reveal_secrets": True,
+ "arguments": {"webhook_id": WEBHOOK["webhook_id"]},
+ },
+ )
+ self.assertFalse(result.get("isError"), result)
+ signing = cloud.documents(PROJECT)[WEBHOOK["webhook_id"]]["signatureKey"]
+ self.assertEqual(json.loads(self.text(result))["secret"], signing)
+ self.assertEqual(len(result["content"]), 1)
self.assertEqual(
collector().wait(
diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py
index 6fbd20c..6e993f6 100644
--- a/tests/unit/test_server.py
+++ b/tests/unit/test_server.py
@@ -876,7 +876,11 @@ def test_format_tool_result_keeps_nested_row_columns(self):
def test_format_appwrite_error_returns_the_unhydrated_payload(self):
exc = AppwriteException(
"Unable to parse response into Project: 1 validation error",
- response={"$id": "proj-1", "name": "New Project"},
+ response={
+ "$id": "proj-1",
+ "name": "New Project",
+ "keys": [{"$id": "key-1", "secret": "standard_0123456789abcdef"}],
+ },
)
message = _format_appwrite_error(exc, tool_name="projects_create")
@@ -887,6 +891,15 @@ def test_format_appwrite_error_returns_the_unhydrated_payload(self):
self.assertIn("Do not retry", message)
self.assertIn('"$id": "proj-1"', message)
self.assertNotIn("validation error", message)
+ # With no model to go by, secrets are still redacted, by field name.
+ self.assertNotIn("standard_0123456789abcdef", message)
+ self.assertIn('"secret": "[redacted]"', message)
+ self.assertIn("Secrets redacted from this result: secret.", message)
+
+ # Asked for, a one-time secret is not lost to a parse failure.
+ revealed = _format_appwrite_error(exc, tool_name="projects_create", reveal=True)
+ self.assertIn("standard_0123456789abcdef", revealed)
+ self.assertNotIn("Secrets redacted", revealed)
def test_format_appwrite_error_does_not_report_parse_failure_as_failure(self):
exc = AppwriteException(
@@ -1060,7 +1073,7 @@ def test_register_services_returns_fresh_manager(self):
set(SERVICE_CLASSES),
)
self.assertEqual(len(manager_a.services), 41)
- self.assertEqual(len(manager_a.get_all_tools()), 1029)
+ self.assertEqual(len(manager_a.get_all_tools()), 1065)
def test_console_sdk_0_7_surface(self):
manager = register_services(object(), profile=OAUTH_PROFILE)
@@ -1182,7 +1195,7 @@ def test_api_key_profile_only_advertises_server_capabilities(self):
tool_names = {tool.name for tool in manager.get_all_tools()}
self.assertEqual(len(manager.services), 27)
- self.assertEqual(len(tool_names), 689)
+ self.assertEqual(len(tool_names), 720)
self.assertIn("waf_list_rules", tool_names)
self.assertIn("waf_create_deny_rule", tool_names)
self.assertIn("avatars_get_photo", tool_names)
@@ -1365,6 +1378,7 @@ def fake_execute(
organization_id=None,
environment_id=None,
reason=None,
+ reveal=False,
):
seen["client"] = client
seen["target_project"] = target_project
@@ -1534,8 +1548,9 @@ def get_file_download(self) -> bytes:
self.assertIsInstance(result[0], types.EmbeddedResource)
def test_execute_registered_tool_returns_selected_attributes_untyped(self):
- # A select query returns partial documents that the SDK's full models
- # reject (Sentry MCP-12/MCP-13).
+ # A select query returns partial documents. They are passed through as
+ # Appwrite sent them, not filled with the full model's defaults
+ # (Sentry MCP-12/MCP-13).
manager = register_services(object(), profile=OAUTH_PROFILE)
partial = {
"total": 1,
@@ -1555,15 +1570,6 @@ def test_execute_registered_tool_returns_selected_attributes_untyped(self):
self.assertIsInstance(result[0], types.TextContent)
self.assertEqual(json.loads(result[0].text), partial)
- with patch.object(server_module.error_monitoring, "capture_appwrite_exception"):
- with self.assertRaises(RuntimeError):
- execute_registered_tool(
- manager,
- "functions_list_deployments",
- {"function_id": "function"},
- client=client,
- )
-
def test_execute_registered_tool_captures_publishable_appwrite_error(self):
tool = types.Tool(
name="users_list",
diff --git a/uv.lock b/uv.lock
index 9d35e65..0a199bd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 5
+revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -39,15 +39,16 @@ wheels = [
[[package]]
name = "appwrite-console"
-version = "0.12.0"
+version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "requests" },
+ { name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ce/f5/4197f42d80391b6cef115fdb528677f79d8a5417369345d98977b1fdfb2e/appwrite_console-0.12.0.tar.gz", hash = "sha256:d6e90c3d38ac626a04a1ca40d787215c739702fdbfb109db33f5ae37a4ec132a", size = 329956, upload-time = "2026-10-12T17:06:36.173Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4f/56/1bcae0f5ffd77632d19693b85b0ae231e770c995804dc2d85ef7b974c2c7/appwrite_console-0.16.0.tar.gz", hash = "sha256:04807fa1403fa2fdcc41fe38fabca6ebc3f50819245763da380645b02e95b5d7", size = 376725, upload-time = "2026-11-12T15:44:58.206Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b6/ad/bd7eabeab53ff663e5fcb13330c6918ebf6fa6934e9d30ee446e60796850/appwrite_console-0.12.0-py3-none-any.whl", hash = "sha256:c237fdee8f8492ab2e42754a85585f06effae69315efea1746d90c01536d94d9", size = 569953, upload-time = "2026-10-12T17:06:34.356Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b5/94005d0c095b652fb193d808a233165915b8128abb1632e8a3f0d2445118/appwrite_console-0.16.0-py3-none-any.whl", hash = "sha256:edd87050757f5ab78efc0a095b99599537c5e890722d0461a5ffe479a2e8d7f6", size = 683918, upload-time = "2026-11-12T15:44:56.626Z" },
]
[[package]]
@@ -751,7 +752,7 @@ e2e = [
[package.metadata]
requires-dist = [
{ name = "anyio", specifier = ">=4.0.0" },
- { name = "appwrite-console", specifier = ">=0.12.0,<0.13" },
+ { name = "appwrite-console", specifier = ">=0.16.0,<0.17" },
{ name = "argon2-cffi", marker = "extra == 'integration'", specifier = ">=23.1.0" },
{ name = "bcrypt", marker = "extra == 'integration'", specifier = ">=4.1.2" },
{ name = "cryptography", specifier = ">=44.0.0" },
@@ -1362,7 +1363,7 @@ wheels = [
[[package]]
name = "requests"
-version = "2.32.3"
+version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1370,9 +1371,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
@@ -1653,11 +1654,11 @@ wheels = [
[[package]]
name = "urllib3"
-version = "2.3.0"
+version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" },
+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]