mcp-server-appwrite 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/PKG-INFO +1 -1
  2. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/apps.md +26 -6
  3. mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/apps/executions.py +188 -0
  4. mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/apps/files.py +210 -0
  5. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/resource.py +2 -1
  6. mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/apps/usage.py +188 -0
  7. mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/apps/users.py +168 -0
  8. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/views.py +107 -11
  9. mcp_server_appwrite-2.2.0/src/mcp_server_appwrite/assets/view.html +1544 -0
  10. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/data/docs_index.npz +1 -1
  11. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/server.py +4 -1
  12. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/support.py +355 -13
  13. mcp_server_appwrite-2.2.0/tests/e2e/test_apps.py +779 -0
  14. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_tools.py +21 -3
  15. mcp_server_appwrite-2.1.2/src/mcp_server_appwrite/assets/view.html +0 -810
  16. mcp_server_appwrite-2.1.2/tests/e2e/test_apps.py +0 -380
  17. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.dockerignore +0 -0
  18. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.env.example +0 -0
  19. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/ci.yml +0 -0
  20. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/production.yml +0 -0
  21. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/publish.yml +0 -0
  22. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/staging.yml +0 -0
  23. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.github/workflows/update-docs-index.yml +0 -0
  24. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.gitignore +0 -0
  25. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/.python-version +0 -0
  26. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/AGENTS.md +0 -0
  27. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/CLAUDE.md +0 -0
  28. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/Dockerfile +0 -0
  29. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/LICENSE +0 -0
  30. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/README.md +0 -0
  31. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/compose.yaml +0 -0
  32. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/appwrite-mcp-flow.png +0 -0
  33. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/appwrite-mcp-flow.svg +0 -0
  34. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/authentication.md +0 -0
  35. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/development.md +0 -0
  36. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/documentation-search.md +0 -0
  37. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/events.md +0 -0
  38. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/flags.md +0 -0
  39. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/images/claude-desktop-local-mcp-servers.png +0 -0
  40. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/images/codex-mcp.png +0 -0
  41. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/images/cursor-mcp.png +0 -0
  42. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/self-hosted.md +0 -0
  43. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/docs/tool-surface.md +0 -0
  44. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/glama.json +0 -0
  45. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/pyproject.toml +0 -0
  46. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/scripts/build_docs_index.py +0 -0
  47. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/scripts/docs_release_notes.py +0 -0
  48. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/scripts/render_server_json.py +0 -0
  49. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/server.template.json +0 -0
  50. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/__init__.py +0 -0
  51. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/__main__.py +0 -0
  52. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/__init__.py +0 -0
  53. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/apps/protocol.py +0 -0
  54. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/assets/__init__.py +0 -0
  55. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/assets/favicon.svg +0 -0
  56. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/assets/instructions.json +0 -0
  57. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/auth.py +0 -0
  58. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/catalog_policy.py +0 -0
  59. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/constants.py +0 -0
  60. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/context.py +0 -0
  61. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/docs.py +0 -0
  62. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/docs_index.py +0 -0
  63. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/docs_search.py +0 -0
  64. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/docs_source.py +0 -0
  65. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/error_classification.py +0 -0
  66. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/error_monitoring.py +0 -0
  67. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/__init__.py +0 -0
  68. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/bus.py +0 -0
  69. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/catalog.py +0 -0
  70. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/delivery.py +0 -0
  71. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/egress.py +0 -0
  72. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/envelope.py +0 -0
  73. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/errors.py +0 -0
  74. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/ingress.py +0 -0
  75. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/projection.py +0 -0
  76. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/protocol.py +0 -0
  77. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/streams.py +0 -0
  78. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/subscriptions.py +0 -0
  79. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/events/webhooks.py +0 -0
  80. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/flags.py +0 -0
  81. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/http_app.py +0 -0
  82. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/instructions.py +0 -0
  83. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/legacy.py +0 -0
  84. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/operator.py +0 -0
  85. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/service.py +0 -0
  86. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/telemetry.py +0 -0
  87. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/src/mcp_server_appwrite/tool_manager.py +0 -0
  88. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/deployment_function_duplicate.json +0 -0
  89. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/deployment_function_failed.json +0 -0
  90. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/deployment_function_ready.json +0 -0
  91. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/deployment_site_ready.json +0 -0
  92. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/execution_async_failed.json +0 -0
  93. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/execution_async_waiting.json +0 -0
  94. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/execution_sync_failed.json +0 -0
  95. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/file_created.json +0 -0
  96. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/function_activation.json +0 -0
  97. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/row_created.json +0 -0
  98. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/fixtures/appwrite/user_created.json +0 -0
  99. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_events_ingress.py +0 -0
  100. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_events_protocol.py +0 -0
  101. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_events_stream.py +0 -0
  102. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_events_subscribe.py +0 -0
  103. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/e2e/test_stdio.py +0 -0
  104. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/support.py +0 -0
  105. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_avatars.py +0 -0
  106. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_console_sdk_oauth.py +0 -0
  107. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_docs_index.py +0 -0
  108. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_docs_source.py +0 -0
  109. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_functions.py +0 -0
  110. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_locale.py +0 -0
  111. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_messaging.py +0 -0
  112. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_oauth_discovery.py +0 -0
  113. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_operator.py +0 -0
  114. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_sites.py +0 -0
  115. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_storage.py +0 -0
  116. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_tables_db.py +0 -0
  117. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_teams.py +0 -0
  118. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/integration/test_users.py +0 -0
  119. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/test_all.py +0 -0
  120. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/fixtures/jwe-v1.json +0 -0
  121. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_auth.py +0 -0
  122. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_context.py +0 -0
  123. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_docs.py +0 -0
  124. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_docs_search.py +0 -0
  125. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_error_classification.py +0 -0
  126. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_error_monitoring.py +0 -0
  127. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_events_delivery.py +0 -0
  128. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_events_egress.py +0 -0
  129. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_events_envelope.py +0 -0
  130. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_events_protocol.py +0 -0
  131. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_http_app.py +0 -0
  132. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_instructions.py +0 -0
  133. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_operator.py +0 -0
  134. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_release_metadata.py +0 -0
  135. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_server.py +0 -0
  136. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_service.py +0 -0
  137. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/tests/unit/test_telemetry.py +0 -0
  138. {mcp_server_appwrite-2.1.2 → mcp_server_appwrite-2.2.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-server-appwrite
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: Build and deploy application backends. Add authentication, PostgreSQL/MySQL databases, vector databases, storage, serverless functions, realtime, messaging, hosting, domains, CDN and firewall.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -9,17 +9,34 @@ interactive view inside the chat. This server offers one view,
9
9
 
10
10
  | Name | Seen by | What it does |
11
11
  | --- | --- | --- |
12
- | `appwrite_show` | model and view | `view=table`: a page of a table's rows, newest first, with `cursor` paging. `view=deployment`: a Function or Site deployment (the latest unless `deployment_id` is given) with the last 200 build log lines. Read-only. |
13
- | `appwrite_view_write` | view only (`visibility: ["app"]`) | Runs one of `tables_db_update_row`, `functions_update_deployment_status`, `sites_update_deployment_status`, only with `confirm_write=true`. |
14
- | `ui://appwrite/view` | host | One HTML document (`assets/view.html`) that renders either view. |
12
+ | `appwrite_show` | model and view | `view=table`: a page of a table's rows, newest first, with `cursor` paging. `view=deployment`: a Function or Site deployment (the latest unless `deployment_id` is given) with the last 200 build log lines. `view=executions`: a Function's executions, newest first, optionally one `status`, with `cursor` paging; `execution_id` also opens one with its request and response headers, status code, duration, and the last 200 lines of logs and of errors. `view=files`: a Storage bucket's files, newest first, with `search` (file name) and `cursor` paging; `file_id` also opens one. `view=users`: the project's Auth users, newest first, with `search` (name, email, phone or ID) and `cursor` paging; `user_id` also opens one with their ten newest sessions. `view=usage`: a project's requests, bandwidth (in and out) and executions per interval over `range` (`24h` hourly, `7d` or `30d` daily), with totals and the newest snapshot of its users, resource counts and storage. Read-only. |
13
+ | `appwrite_view_write` | view only (`visibility: ["app"]`) | Runs one of `tables_db_update_row`, `functions_update_deployment_status`, `sites_update_deployment_status`, `users_update_status`, `users_update_labels`, `users_update_email_verification`, only with `confirm_write=true`. |
14
+ | `ui://appwrite/view` | host | One HTML document (`assets/view.html`) that renders every view. |
15
15
 
16
16
  `appwrite_show` returns a text summary in `content`, which is what the model and
17
17
  hosts without MCP Apps see, and the data in `structuredContent` for the view.
18
18
  The view calls `appwrite_show` again to page, refresh and follow a build (every
19
- 3 seconds until it is ready, failed or canceled). An edited row or a cancelled
19
+ 3 seconds until it is ready, failed or canceled), and to follow executions
20
+ while any on the page, or the opened one, is waiting or processing. Appwrite
21
+ keeps an execution's response body only for the synchronous call that ran it,
22
+ so the view shows it only when present. An edited row or a cancelled
20
23
  build goes through a confirmation dialog in the view, and only then through
21
- `appwrite_view_write`. A successful edit and a finished build are passed to the
22
- model with `ui/update-model-context` when the host supports it.
24
+ `appwrite_view_write`, as do blocking or unblocking a user, marking their
25
+ email verified or not, and changing their labels. A successful change and a
26
+ finished build are passed to the model with `ui/update-model-context` when the
27
+ host supports it.
28
+
29
+ The files view has no network access, so it cannot load a file's preview
30
+ URL. It calls `appwrite_show` again with `thumbnails: true`, and the server
31
+ renders the page's images through Appwrite's image transformations
32
+ (`storage_get_file_preview`, eight at a time) and returns them as data URIs.
33
+ The model's own call leaves them out, since some hosts put
34
+ `structuredContent` in the model's context.
35
+
36
+ The usage view reads the Usage API (`usage_list_events` and
37
+ `usage_list_gauges`). Appwrite keeps usage history for as long as the plan
38
+ allows; a range past it returns Appwrite's own error, which the view shows in
39
+ place of the chart it was switching to.
23
40
 
24
41
  ## Who is offered the view
25
42
 
@@ -42,6 +59,9 @@ gated, because the view's own calls reach the server through the host.
42
59
  because a script injected through that data could call `appwrite_view_write`.
43
60
  The allowlist bounds what such a script could do, and `confirm_write` is
44
61
  required as for `appwrite_call_write_tool`.
62
+ - User documents carry the password hash, prefs and messaging targets, and
63
+ sessions carry OAuth provider tokens. The users view, and the user writes'
64
+ results, carry only the fields the view shows.
45
65
 
46
66
  ## Trying it in a real host
47
67
 
@@ -0,0 +1,188 @@
1
+ """``view=executions``: a Function's executions, and one execution in full.
2
+
3
+ The list is a page of executions, newest first, optionally narrowed to one
4
+ status. With ``execution_id`` the payload also carries that execution's
5
+ request, response headers, logs and errors (Appwrite keeps a response body
6
+ only for the synchronous call that ran it), so "why did the last run fail?" opens on
7
+ the answer. The view polls while an execution on the page or the opened one
8
+ is still waiting or running.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ from appwrite_console.query import Query
17
+
18
+ from ..constants import SHOW_TOOL_NAME
19
+
20
+ Read = Callable[[str, Any, dict[str, Any]], dict[str, Any]]
21
+ """Runs a hidden read tool: ``(tool name, project ID, arguments)``."""
22
+
23
+ PAGE_SIZE = 25
24
+
25
+ STATUSES = ("waiting", "processing", "completed", "failed", "scheduled")
26
+ RUNNING = frozenset({"waiting", "processing"})
27
+
28
+ LOG_LINES = 200
29
+ """Lines of logs and of errors an execution carries."""
30
+
31
+ BODY_LIMIT = 8192
32
+ """Characters of the response body an execution carries."""
33
+
34
+ SUMMARY_ROWS = 10
35
+ SUMMARY_LOG_LINES = 20
36
+
37
+
38
+ def read(read: Read, arguments: dict[str, Any]) -> dict[str, Any]:
39
+ project_id = arguments.get("project_id")
40
+ function_id = arguments.get("function_id")
41
+ if not isinstance(function_id, str) or not function_id:
42
+ raise ValueError("function_id is required for view=executions.")
43
+ status = arguments.get("status") or None
44
+ if status is not None and status not in STATUSES:
45
+ raise ValueError(f"status must be one of: {', '.join(STATUSES)}.")
46
+ cursor = arguments.get("cursor") or None
47
+ execution_id = arguments.get("execution_id") or None
48
+ target = {"function_id": function_id}
49
+
50
+ function = read("functions_get", project_id, target)
51
+ # One past the page tells whether another page follows.
52
+ queries = [Query.order_desc("$createdAt"), Query.limit(PAGE_SIZE + 1)]
53
+ if status:
54
+ queries.append(Query.equal("status", status))
55
+ if cursor:
56
+ queries.append(Query.cursor_after(str(cursor)))
57
+ page = read("functions_list_executions", project_id, {**target, "queries": queries})
58
+ executions = page.get("executions") or []
59
+ more = len(executions) > PAGE_SIZE
60
+ executions = executions[:PAGE_SIZE]
61
+
62
+ execution = None
63
+ if execution_id:
64
+ execution = _detail(
65
+ read(
66
+ "functions_get_execution",
67
+ project_id,
68
+ {**target, "execution_id": execution_id},
69
+ )
70
+ )
71
+ rows = [_summary(each) for each in executions]
72
+ return {
73
+ "view": "executions",
74
+ "projectId": project_id,
75
+ "functionId": function_id,
76
+ "name": function.get("name") or function_id,
77
+ "runtime": function.get("runtime"),
78
+ "status": status,
79
+ "executions": rows,
80
+ "total": int(page.get("total", len(rows))),
81
+ "cursor": cursor,
82
+ "next": rows[-1]["id"] if more else None,
83
+ "execution": execution,
84
+ "running": any(each["status"] in RUNNING for each in rows)
85
+ or bool(execution and execution["status"] in RUNNING),
86
+ }
87
+
88
+
89
+ def _summary(execution: dict[str, Any]) -> dict[str, Any]:
90
+ return {
91
+ "id": execution.get("$id"),
92
+ "status": execution.get("status") or "unknown",
93
+ "trigger": execution.get("trigger"),
94
+ "method": execution.get("requestMethod") or None,
95
+ "path": execution.get("requestPath") or None,
96
+ "statusCode": int(execution.get("responseStatusCode") or 0),
97
+ "duration": execution.get("duration"),
98
+ "createdAt": execution.get("$createdAt"),
99
+ "scheduledAt": execution.get("scheduledAt") or None,
100
+ }
101
+
102
+
103
+ def _detail(execution: dict[str, Any]) -> dict[str, Any]:
104
+ body = execution.get("responseBody") or ""
105
+ logs = (execution.get("logs") or "").splitlines()
106
+ errors = (execution.get("errors") or "").splitlines()
107
+ return {
108
+ **_summary(execution),
109
+ "deploymentId": execution.get("deploymentId") or None,
110
+ "requestHeaders": _headers(execution.get("requestHeaders")),
111
+ "responseHeaders": _headers(execution.get("responseHeaders")),
112
+ "responseBody": body[:BODY_LIMIT],
113
+ "bodyTruncated": len(body) > BODY_LIMIT,
114
+ "logs": logs[-LOG_LINES:],
115
+ "logsTruncated": len(logs) > LOG_LINES,
116
+ "errors": errors[-LOG_LINES:],
117
+ "errorsTruncated": len(errors) > LOG_LINES,
118
+ }
119
+
120
+
121
+ def _headers(headers: Any) -> list[dict[str, str]]:
122
+ return [
123
+ {"name": str(each.get("name", "")), "value": str(each.get("value", ""))}
124
+ for each in headers or []
125
+ if isinstance(each, dict)
126
+ ]
127
+
128
+
129
+ def _request(execution: dict[str, Any]) -> str:
130
+ if not execution["method"]:
131
+ return execution["trigger"] or ""
132
+ return f"{execution['method']} {execution['path'] or '/'}"
133
+
134
+
135
+ def summary(data: dict[str, Any]) -> str:
136
+ rows: list[dict[str, Any]] = data["executions"]
137
+ scope = f" {data['status']}" if data["status"] else ""
138
+ lines = [
139
+ f"Function {data['name']} ({data['functionId']}): {data['total']}"
140
+ f"{scope} executions, showing {len(rows)} newest"
141
+ + (" after the cursor." if data["cursor"] else ".")
142
+ ]
143
+ if rows:
144
+ lines.append("id | status | trigger | request | code | duration | created")
145
+ for each in rows[:SUMMARY_ROWS]:
146
+ lines.append(
147
+ " | ".join(
148
+ [
149
+ each["id"],
150
+ each["status"],
151
+ each["trigger"] or "",
152
+ _request(each),
153
+ str(each["statusCode"] or ""),
154
+ _duration(each["duration"]),
155
+ each["createdAt"] or "",
156
+ ]
157
+ )
158
+ )
159
+ if len(rows) > SUMMARY_ROWS:
160
+ lines.append(f"… {len(rows) - SUMMARY_ROWS} more shown in the view.")
161
+ if data["next"]:
162
+ lines.append(
163
+ f"Next page: {SHOW_TOOL_NAME} with view=executions and "
164
+ f"cursor={data['next']}."
165
+ )
166
+ execution = data["execution"]
167
+ if execution:
168
+ lines.append(
169
+ f"Execution {execution['id']}: {execution['status']}, "
170
+ f"{_request(execution)} → {execution['statusCode'] or 'no response'} "
171
+ f"in {_duration(execution['duration'])}."
172
+ )
173
+ for label, key in (("Errors", "errors"), ("Logs", "logs")):
174
+ if execution[key]:
175
+ lines.append(f"{label} (last lines):")
176
+ lines.extend(execution[key][-SUMMARY_LOG_LINES:])
177
+ if execution["responseBody"]:
178
+ lines.append("Response body: " + execution["responseBody"][:500])
179
+ return "\n".join(lines)
180
+
181
+
182
+ def _duration(seconds: Any) -> str:
183
+ # Executions that have not run yet report 0.
184
+ if not isinstance(seconds, (int, float)) or seconds <= 0:
185
+ return "–"
186
+ if seconds < 1:
187
+ return f"{seconds * 1000:.0f}ms"
188
+ return f"{seconds:.2f}s"
@@ -0,0 +1,210 @@
1
+ """``view=files``: a Storage bucket's files, with image previews.
2
+
3
+ The list is a page of a bucket's files, newest first, optionally narrowed by a
4
+ search over file names. With ``file_id`` the payload also carries that file in
5
+ full.
6
+
7
+ The view has no network access, so it cannot load a file's preview URL. With
8
+ ``thumbnails`` the payload carries small previews of the page's images (and a
9
+ larger one of the opened file) as data URIs, rendered by Appwrite's image
10
+ transformations through the same hidden catalog read the model could make.
11
+ The model's own call leaves them out, since some hosts put
12
+ ``structuredContent`` in the model's context; the view asks for them itself.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from contextvars import copy_context
20
+ from typing import Any
21
+
22
+ from appwrite_console.query import Query
23
+
24
+ from ..constants import SHOW_TOOL_NAME
25
+
26
+ Read = Callable[[str, Any, dict[str, Any]], dict[str, Any]]
27
+ """Runs a hidden read tool: ``(tool name, project ID, arguments)``."""
28
+
29
+ Preview = Callable[[Any, dict[str, Any]], str | None]
30
+ """Renders ``storage_get_file_preview`` for ``(project ID, arguments)`` as a
31
+ data URI, ``None`` when Appwrite cannot."""
32
+
33
+ PAGE_SIZE = 24
34
+ """Files per page: fills the grid's rows of two, three, four and six."""
35
+
36
+ THUMBNAIL = 240
37
+ """Thumbnail edge in pixels: twice the grid's cell, for dense screens."""
38
+
39
+ PREVIEW = 960
40
+ """Width of the opened file's preview in pixels."""
41
+
42
+ PREVIEWABLE = frozenset(
43
+ {
44
+ "image/jpeg",
45
+ "image/png",
46
+ "image/gif",
47
+ "image/webp",
48
+ "image/avif",
49
+ "image/heic",
50
+ "image/bmp",
51
+ }
52
+ )
53
+ """Types Appwrite's image transformations accept."""
54
+
55
+ PARALLEL = 8
56
+ """Previews rendered at once. Each is one Appwrite request of a few hundred
57
+ milliseconds, so a page of 24 takes about three rounds."""
58
+
59
+ SUMMARY_ROWS = 10
60
+
61
+
62
+ def read(read: Read, preview: Preview, arguments: dict[str, Any]) -> dict[str, Any]:
63
+ project_id = arguments.get("project_id")
64
+ bucket_id = arguments.get("bucket_id")
65
+ if not isinstance(bucket_id, str) or not bucket_id:
66
+ raise ValueError("bucket_id is required for view=files.")
67
+ search = arguments.get("search") or None
68
+ cursor = arguments.get("cursor") or None
69
+ file_id = arguments.get("file_id") or None
70
+ thumbnails = arguments.get("thumbnails") is True
71
+ target = {"bucket_id": bucket_id}
72
+
73
+ bucket = read("storage_get_bucket", project_id, target)
74
+ # One past the page tells whether another page follows.
75
+ queries = [Query.order_desc("$createdAt"), Query.limit(PAGE_SIZE + 1)]
76
+ if cursor:
77
+ queries.append(Query.cursor_after(str(cursor)))
78
+ listing: dict[str, Any] = {**target, "queries": queries}
79
+ if search:
80
+ listing["search"] = str(search)
81
+ page = read("storage_list_files", project_id, listing)
82
+ rows = [_file(each) for each in page.get("files") or []]
83
+ more = len(rows) > PAGE_SIZE
84
+ rows = rows[:PAGE_SIZE]
85
+ transforms = bool(bucket.get("transformations", True))
86
+
87
+ opened = None
88
+ if file_id:
89
+ opened = _file(
90
+ read("storage_get_file", project_id, {**target, "file_id": file_id})
91
+ )
92
+
93
+ if thumbnails and transforms:
94
+ # (file, key, preview arguments) for every image preview to render.
95
+ wanted = [
96
+ (
97
+ row,
98
+ "thumbnail",
99
+ {"width": THUMBNAIL, "height": THUMBNAIL, "quality": 70},
100
+ )
101
+ for row in rows
102
+ if row["mimeType"] in PREVIEWABLE
103
+ ]
104
+ if opened and opened["mimeType"] in PREVIEWABLE:
105
+ wanted.append((opened, "preview", {"width": PREVIEW, "quality": 80}))
106
+ with ThreadPoolExecutor(max_workers=PARALLEL) as pool:
107
+ # Each render runs in a copy of this request's context, which holds
108
+ # the caller's credentials.
109
+ futures = [
110
+ pool.submit(
111
+ copy_context().run,
112
+ preview,
113
+ project_id,
114
+ {**target, "file_id": each["id"], "output": "webp", **options},
115
+ )
116
+ for each, _, options in wanted
117
+ ]
118
+ for (each, key, _), future in zip(wanted, futures):
119
+ each[key] = future.result()
120
+ return {
121
+ "view": "files",
122
+ "projectId": project_id,
123
+ "bucketId": bucket_id,
124
+ "bucket": {
125
+ "name": bucket.get("name") or bucket_id,
126
+ "maximumFileSize": bucket.get("maximumFileSize"),
127
+ "allowedFileExtensions": bucket.get("allowedFileExtensions") or [],
128
+ "encryption": bool(bucket.get("encryption")),
129
+ "antivirus": bool(bucket.get("antivirus")),
130
+ "compression": bucket.get("compression") or "none",
131
+ "fileSecurity": bool(bucket.get("fileSecurity")),
132
+ "transformations": transforms,
133
+ },
134
+ "search": search,
135
+ "files": rows,
136
+ "total": int(page.get("total", len(rows))),
137
+ "cursor": cursor,
138
+ "next": rows[-1]["id"] if more else None,
139
+ "file": opened,
140
+ "thumbnails": thumbnails,
141
+ }
142
+
143
+
144
+ def _file(document: dict[str, Any]) -> dict[str, Any]:
145
+ return {
146
+ "id": document.get("$id"),
147
+ "name": document.get("name") or "",
148
+ "mimeType": document.get("mimeType") or "",
149
+ "size": int(document.get("sizeOriginal") or 0),
150
+ "createdAt": document.get("$createdAt"),
151
+ "updatedAt": document.get("$updatedAt"),
152
+ "folder": document.get("folder") or "",
153
+ "encryption": bool(document.get("encryption")),
154
+ "compression": document.get("compression") or "none",
155
+ "chunksTotal": int(document.get("chunksTotal") or 0),
156
+ "chunksUploaded": int(document.get("chunksUploaded") or 0),
157
+ "permissions": [str(each) for each in document.get("$permissions") or []],
158
+ "thumbnail": None,
159
+ "preview": None,
160
+ }
161
+
162
+
163
+ def size(value: int) -> str:
164
+ if value < 1024:
165
+ return f"{value} B"
166
+ amount = value / 1024
167
+ for unit in ("KB", "MB"):
168
+ if amount < 1024:
169
+ return f"{amount:.1f} {unit}"
170
+ amount /= 1024
171
+ return f"{amount:.1f} GB"
172
+
173
+
174
+ def summary(data: dict[str, Any]) -> str:
175
+ rows: list[dict[str, Any]] = data["files"]
176
+ scope = f" matching {data['search']!r}" if data["search"] else ""
177
+ lines = [
178
+ f"Bucket {data['bucket']['name']} ({data['bucketId']}): {data['total']} "
179
+ f"files{scope}, showing {len(rows)} newest"
180
+ + (" after the cursor." if data["cursor"] else ".")
181
+ ]
182
+ if rows:
183
+ lines.append("id | name | type | size | uploaded")
184
+ for row in rows[:SUMMARY_ROWS]:
185
+ lines.append(
186
+ " | ".join(
187
+ [
188
+ row["id"],
189
+ row["name"],
190
+ row["mimeType"],
191
+ size(row["size"]),
192
+ row["createdAt"] or "",
193
+ ]
194
+ )
195
+ )
196
+ if len(rows) > SUMMARY_ROWS:
197
+ lines.append(f"… {len(rows) - SUMMARY_ROWS} more shown in the view.")
198
+ if data["next"]:
199
+ lines.append(
200
+ f"Next page: {SHOW_TOOL_NAME} with view=files and cursor={data['next']}."
201
+ )
202
+ opened = data["file"]
203
+ if opened:
204
+ lines.append(
205
+ f"File {opened['id']} ({opened['name']}): {opened['mimeType']}, "
206
+ f"{size(opened['size'])}, uploaded {opened['createdAt']}"
207
+ + (", encrypted" if opened["encryption"] else "")
208
+ + "."
209
+ )
210
+ return "\n".join(lines)
@@ -54,7 +54,8 @@ def resource() -> types.Resource:
54
54
  uri=uri(),
55
55
  name="Appwrite view",
56
56
  description=(
57
- "Interactive Appwrite view: table rows and deployment build logs."
57
+ "Interactive Appwrite view: table rows, deployment build logs, "
58
+ "function executions, Storage files, Auth users and project usage."
58
59
  ),
59
60
  mime_type=MIME_TYPE,
60
61
  size=len(html().encode("utf-8")),
@@ -0,0 +1,188 @@
1
+ """``view=usage``: a project's traffic, compute and resources over a range.
2
+
3
+ Two reads of the Usage API make the dashboard: ``usage_list_events`` for what
4
+ happened in each interval (requests, bandwidth in and out, executions and
5
+ their compute time) and ``usage_list_gauges`` for where the project stands
6
+ (users, files and deployment storage, and resource counts). Event series are
7
+ filled with zeros for intervals without traffic, so charts keep their shape.
8
+
9
+ Appwrite keeps usage history for as long as the plan allows. A range past it
10
+ fails with Appwrite's own message, which the view shows in place.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime, timedelta
18
+ from typing import Any
19
+
20
+ Read = Callable[[str, Any, dict[str, Any]], dict[str, Any]]
21
+ """Runs a hidden read tool: ``(tool name, project ID, arguments)``."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Range:
26
+ interval: str
27
+ step: timedelta
28
+ points: int
29
+
30
+
31
+ RANGES = {
32
+ "24h": Range("1h", timedelta(hours=1), 24),
33
+ "7d": Range("1d", timedelta(days=1), 7),
34
+ "30d": Range("1d", timedelta(days=1), 30),
35
+ }
36
+
37
+ EVENTS = {
38
+ "requests": "network.requests",
39
+ "inbound": "network.inbound",
40
+ "outbound": "network.outbound",
41
+ "executions": "executions",
42
+ "compute": "executions.compute",
43
+ }
44
+ """Series key → Usage API event metric."""
45
+
46
+ GAUGES = {
47
+ "users": "users",
48
+ "functions": "functions",
49
+ "sites": "sites",
50
+ "databases": "databases",
51
+ "buckets": "buckets",
52
+ "filesStorage": "files.storage",
53
+ "deploymentsStorage": "deployments.storage",
54
+ "buildsStorage": "builds.storage",
55
+ }
56
+ """Resource key → Usage API gauge metric."""
57
+
58
+
59
+ def read(
60
+ read: Read, arguments: dict[str, Any], now: datetime | None = None
61
+ ) -> dict[str, Any]:
62
+ project_id = arguments.get("project_id")
63
+ name = arguments.get("range") or "7d"
64
+ if name not in RANGES:
65
+ raise ValueError(f"range must be one of: {', '.join(RANGES)}.")
66
+ span = RANGES[name]
67
+ end = _floor(now or datetime.now(UTC), span.step)
68
+ start = end - span.step * (span.points - 1)
69
+ window = {
70
+ "interval": span.interval,
71
+ "start_at": start.isoformat(),
72
+ "order_by": "time",
73
+ "order_dir": "asc",
74
+ }
75
+
76
+ events = read(
77
+ "usage_list_events",
78
+ project_id,
79
+ {**window, "metrics": list(EVENTS.values())},
80
+ )
81
+ gauges = read(
82
+ "usage_list_gauges",
83
+ project_id,
84
+ {**window, "metrics": list(GAUGES.values())},
85
+ )
86
+ times = [start + span.step * index for index in range(span.points)]
87
+ points = _points(events)
88
+ series = {
89
+ key: [points.get(metric, {}).get(time, 0) for time in times]
90
+ for key, metric in EVENTS.items()
91
+ }
92
+ latest = _latest(gauges)
93
+ return {
94
+ "view": "usage",
95
+ "projectId": project_id,
96
+ "range": name,
97
+ "interval": span.interval,
98
+ "times": [time.isoformat() for time in times],
99
+ "series": series,
100
+ "totals": {key: sum(values) for key, values in series.items()},
101
+ "resources": {key: latest.get(metric) for key, metric in GAUGES.items()},
102
+ }
103
+
104
+
105
+ def _floor(moment: datetime, step: timedelta) -> datetime:
106
+ """The start of the interval ``moment`` falls in, as Appwrite buckets it."""
107
+ seconds = int(step.total_seconds())
108
+ epoch = int(moment.timestamp())
109
+ return datetime.fromtimestamp(epoch - epoch % seconds, UTC)
110
+
111
+
112
+ def _metrics(response: dict[str, Any]) -> list[dict[str, Any]]:
113
+ return [each for each in response.get("metrics") or [] if isinstance(each, dict)]
114
+
115
+
116
+ def _points(response: dict[str, Any]) -> dict[str, dict[datetime, int]]:
117
+ """Each metric's values by interval start."""
118
+ found: dict[str, dict[datetime, int]] = {}
119
+ for metric in _metrics(response):
120
+ values = found.setdefault(str(metric.get("metric")), {})
121
+ for point in metric.get("points") or []:
122
+ try:
123
+ time = datetime.fromisoformat(str(point["time"])).astimezone(UTC)
124
+ except (KeyError, ValueError):
125
+ continue
126
+ values[time] = values.get(time, 0) + int(point.get("value") or 0)
127
+ return found
128
+
129
+
130
+ def _latest(response: dict[str, Any]) -> dict[str, int]:
131
+ """Each gauge's newest snapshot."""
132
+ found: dict[str, int] = {}
133
+ for metric in _metrics(response):
134
+ points = metric.get("points") or []
135
+ if points:
136
+ newest = max(points, key=lambda point: str(point.get("time")))
137
+ found[str(metric.get("metric"))] = int(newest.get("value") or 0)
138
+ return found
139
+
140
+
141
+ def bytes_text(value: int) -> str:
142
+ amount = float(value)
143
+ for unit in ("B", "KB", "MB", "GB"):
144
+ if amount < 1000 or unit == "GB":
145
+ return f"{amount:.0f} {unit}" if unit == "B" else f"{amount:.1f} {unit}"
146
+ amount /= 1000
147
+ return f"{amount:.1f} TB"
148
+
149
+
150
+ def summary(data: dict[str, Any]) -> str:
151
+ totals = data["totals"]
152
+ resources = data["resources"]
153
+ lines = [
154
+ f"Usage of project {data['projectId']} over the last {data['range']} "
155
+ f"({data['interval']} intervals):",
156
+ f"Requests: {totals['requests']:,}",
157
+ f"Bandwidth: {bytes_text(totals['inbound'] + totals['outbound'])} "
158
+ f"({bytes_text(totals['inbound'])} in, {bytes_text(totals['outbound'])} out)",
159
+ f"Executions: {totals['executions']:,} "
160
+ f"({totals['compute'] / 1000:,.1f} s of compute)",
161
+ ]
162
+ counts = [
163
+ f"{key} {resources[key]:,}"
164
+ for key in ("users", "functions", "sites", "databases", "buckets")
165
+ if resources[key] is not None
166
+ ]
167
+ if counts:
168
+ lines.append("Now: " + ", ".join(counts) + ".")
169
+ storage = [
170
+ f"{label} {bytes_text(resources[key])}"
171
+ for key, label in (
172
+ ("filesStorage", "files"),
173
+ ("deploymentsStorage", "deployments"),
174
+ ("buildsStorage", "builds"),
175
+ )
176
+ if resources[key] is not None
177
+ ]
178
+ if storage:
179
+ lines.append("Storage: " + ", ".join(storage) + ".")
180
+ busiest = max(
181
+ range(len(data["times"])), key=lambda index: data["series"]["requests"][index]
182
+ )
183
+ if totals["requests"]:
184
+ lines.append(
185
+ f"Busiest interval: {data['times'][busiest]} with "
186
+ f"{data['series']['requests'][busiest]:,} requests."
187
+ )
188
+ return "\n".join(lines)