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]]