mcp-server-appwrite 2.0.2__tar.gz → 2.1.1__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 (132) hide show
  1. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/AGENTS.md +6 -0
  2. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/PKG-INFO +1 -1
  3. mcp_server_appwrite-2.1.1/docs/apps.md +63 -0
  4. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/flags.md +19 -0
  5. mcp_server_appwrite-2.1.1/src/mcp_server_appwrite/apps/__init__.py +1 -0
  6. mcp_server_appwrite-2.1.1/src/mcp_server_appwrite/apps/protocol.py +80 -0
  7. mcp_server_appwrite-2.1.1/src/mcp_server_appwrite/apps/resource.py +45 -0
  8. mcp_server_appwrite-2.1.1/src/mcp_server_appwrite/apps/views.py +396 -0
  9. mcp_server_appwrite-2.1.1/src/mcp_server_appwrite/assets/view.html +810 -0
  10. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/constants.py +3 -0
  11. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/data/docs_index.npz +1 -1
  12. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/flags.py +10 -1
  13. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/operator.py +52 -13
  14. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/server.py +19 -9
  15. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/support.py +188 -14
  16. mcp_server_appwrite-2.1.1/tests/e2e/test_apps.py +368 -0
  17. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_server.py +7 -7
  18. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.dockerignore +0 -0
  19. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.env.example +0 -0
  20. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.github/workflows/ci.yml +0 -0
  21. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.github/workflows/production.yml +0 -0
  22. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.github/workflows/publish.yml +0 -0
  23. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.github/workflows/staging.yml +0 -0
  24. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.github/workflows/update-docs-index.yml +0 -0
  25. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.gitignore +0 -0
  26. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/.python-version +0 -0
  27. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/CLAUDE.md +0 -0
  28. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/Dockerfile +0 -0
  29. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/LICENSE +0 -0
  30. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/README.md +0 -0
  31. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/compose.yaml +0 -0
  32. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/appwrite-mcp-flow.png +0 -0
  33. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/appwrite-mcp-flow.svg +0 -0
  34. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/authentication.md +0 -0
  35. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/development.md +0 -0
  36. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/documentation-search.md +0 -0
  37. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/events.md +0 -0
  38. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/images/claude-desktop-local-mcp-servers.png +0 -0
  39. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/images/codex-mcp.png +0 -0
  40. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/images/cursor-mcp.png +0 -0
  41. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/self-hosted.md +0 -0
  42. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/docs/tool-surface.md +0 -0
  43. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/glama.json +0 -0
  44. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/pyproject.toml +0 -0
  45. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/scripts/build_docs_index.py +0 -0
  46. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/scripts/docs_release_notes.py +0 -0
  47. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/scripts/render_server_json.py +0 -0
  48. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/server.template.json +0 -0
  49. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/__init__.py +0 -0
  50. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/__main__.py +0 -0
  51. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/assets/__init__.py +0 -0
  52. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/assets/favicon.svg +0 -0
  53. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/assets/instructions.json +0 -0
  54. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/auth.py +0 -0
  55. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/catalog_policy.py +0 -0
  56. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/context.py +0 -0
  57. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/docs.py +0 -0
  58. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/docs_index.py +0 -0
  59. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/docs_search.py +0 -0
  60. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/docs_source.py +0 -0
  61. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/error_classification.py +0 -0
  62. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/error_monitoring.py +0 -0
  63. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/__init__.py +0 -0
  64. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/bus.py +0 -0
  65. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/catalog.py +0 -0
  66. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/delivery.py +0 -0
  67. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/egress.py +0 -0
  68. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/envelope.py +0 -0
  69. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/errors.py +0 -0
  70. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/ingress.py +0 -0
  71. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/projection.py +0 -0
  72. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/protocol.py +0 -0
  73. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/streams.py +0 -0
  74. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/subscriptions.py +0 -0
  75. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/events/webhooks.py +0 -0
  76. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/http_app.py +0 -0
  77. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/instructions.py +0 -0
  78. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/legacy.py +0 -0
  79. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/service.py +0 -0
  80. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/telemetry.py +0 -0
  81. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/src/mcp_server_appwrite/tool_manager.py +0 -0
  82. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/deployment_function_duplicate.json +0 -0
  83. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/deployment_function_failed.json +0 -0
  84. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/deployment_function_ready.json +0 -0
  85. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/deployment_site_ready.json +0 -0
  86. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/execution_async_failed.json +0 -0
  87. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/execution_async_waiting.json +0 -0
  88. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/execution_sync_failed.json +0 -0
  89. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/file_created.json +0 -0
  90. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/function_activation.json +0 -0
  91. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/row_created.json +0 -0
  92. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/fixtures/appwrite/user_created.json +0 -0
  93. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/test_events_ingress.py +0 -0
  94. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/test_events_protocol.py +0 -0
  95. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/test_events_stream.py +0 -0
  96. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/test_events_subscribe.py +0 -0
  97. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/test_stdio.py +0 -0
  98. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/e2e/test_tools.py +0 -0
  99. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/support.py +0 -0
  100. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_avatars.py +0 -0
  101. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_console_sdk_oauth.py +0 -0
  102. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_docs_index.py +0 -0
  103. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_docs_source.py +0 -0
  104. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_functions.py +0 -0
  105. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_locale.py +0 -0
  106. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_messaging.py +0 -0
  107. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_oauth_discovery.py +0 -0
  108. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_operator.py +0 -0
  109. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_sites.py +0 -0
  110. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_storage.py +0 -0
  111. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_tables_db.py +0 -0
  112. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_teams.py +0 -0
  113. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/integration/test_users.py +0 -0
  114. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/test_all.py +0 -0
  115. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/fixtures/jwe-v1.json +0 -0
  116. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_auth.py +0 -0
  117. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_context.py +0 -0
  118. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_docs.py +0 -0
  119. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_docs_search.py +0 -0
  120. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_error_classification.py +0 -0
  121. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_error_monitoring.py +0 -0
  122. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_events_delivery.py +0 -0
  123. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_events_egress.py +0 -0
  124. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_events_envelope.py +0 -0
  125. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_events_protocol.py +0 -0
  126. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_http_app.py +0 -0
  127. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_instructions.py +0 -0
  128. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_operator.py +0 -0
  129. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_release_metadata.py +0 -0
  130. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_service.py +0 -0
  131. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/tests/unit/test_telemetry.py +0 -0
  132. {mcp_server_appwrite-2.0.2 → mcp_server_appwrite-2.1.1}/uv.lock +0 -0
@@ -36,6 +36,7 @@ Source lives in `src/mcp_server_appwrite/`:
36
36
  | `docs_source.py` | Fetches the published docs (`/docs/llms.txt` + per-page `.md` exports on appwrite.io) and chunks them for the index build. |
37
37
  | `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. |
38
38
  | `events/` | MCP Events (behind `flags.EVENTS`, HTTP only): `catalog.py` (event catalog), `protocol.py` (capability, `events/list` and the subscribe methods), `subscriptions.py` + `webhooks.py` (`events/subscribe` / `events/unsubscribe`: one managed Appwrite webhook per subscription), `envelope.py` (subscription state sealed with the `mcp-event-subscriptions` JWE profile), `egress.py` + `delivery.py` (SSRF-safe signed delivery), `projection.py` + `ingress.py` (Appwrite webhook ingress at `/appwrite/webhooks/{id}`), `streams.py` + `bus.py` (push delivery: `events/stream`, behind `flags.EVENTS_PUSH`, with events routed between replicas over Redis pub/sub). See [docs/events.md](docs/events.md). |
39
+ | `apps/` | MCP Apps (behind `flags.APPS`): `protocol.py` (the `io.modelcontextprotocol/ui` extension and per-client gating), `views.py` (`appwrite_show` and the view-only `appwrite_view_write`), `resource.py` (`ui://appwrite/view`, served from `assets/view.html`: plain JavaScript, no build step). See [docs/apps.md](docs/apps.md). |
39
40
  | `telemetry.py` | OpenTelemetry metrics layer (OTLP/HTTP). No-op unless an OTLP endpoint is configured and the transport is `http`. |
40
41
  | `data/` | Committed docs index artifact (`docs_index.npz`: vectors, chunk map, chunk hashes, embedded `meta.json`), shipped in the wheel/image. |
41
42
 
@@ -62,6 +63,11 @@ services) stays internal and is searched at runtime. API-key stdio uses a filter
62
63
  tool's annotations match every call it accepts. Large outputs are stored as MCP
63
64
  resources and returned as a preview + resource URI.
64
65
 
66
+ With MCP Apps on (`flags.APPS`), hosts that render them also see
67
+ `appwrite_show`, whose result the host renders as an interactive view
68
+ (`ui://appwrite/view`), and the view can call `appwrite_view_write`, which the
69
+ model never sees. See [docs/apps.md](docs/apps.md).
70
+
65
71
  ## Local development
66
72
 
67
73
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-server-appwrite
3
- Version: 2.0.2
3
+ Version: 2.1.1
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
@@ -0,0 +1,63 @@
1
+ # MCP Apps
2
+
3
+ [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview) (SEP-1865,
4
+ extension `io.modelcontextprotocol/ui`) let a tool result render as an
5
+ interactive view inside the chat. This server offers one view,
6
+ `ui://appwrite/view`, behind the `apps` flag (`MCP_APPS=1`, both transports).
7
+
8
+ ## Surface
9
+
10
+ | Name | Seen by | What it does |
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. |
15
+
16
+ `appwrite_show` returns a text summary in `content`, which is what the model and
17
+ hosts without MCP Apps see, and the data in `structuredContent` for the view.
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
20
+ 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.
23
+
24
+ ## Who is offered the view
25
+
26
+ Hosts that declare the extension in their client capabilities get the two tools
27
+ and the resource. Hosts that declare capabilities without it do not. The
28
+ hosted transport is stateless, so a 2025-era client's `tools/list` carries no
29
+ capabilities; it is offered the view, and a host that cannot render it uses the
30
+ text summary. 2026-07-28 clients send `clientCapabilities` with every request,
31
+ and stdio has the handshake, so both are gated exactly. `tools/call` is not
32
+ gated, because the view's own calls reach the server through the host.
33
+
34
+ ## Security
35
+
36
+ - The resource declares no CSP domains, so hosts apply the spec's restrictive
37
+ default: no network access, inline script and style only. The view never
38
+ holds a token; every Appwrite call is a `tools/call` the host proxies to this
39
+ server with the user's own credentials.
40
+ - Everything the view renders comes from Appwrite. It builds the DOM with
41
+ `textContent` and `createElement` only (the e2e flow fails on `innerHTML`),
42
+ because a script injected through that data could call `appwrite_view_write`.
43
+ The allowlist bounds what such a script could do, and `confirm_write` is
44
+ required as for `appwrite_call_write_tool`.
45
+
46
+ ## Trying it in a real host
47
+
48
+ The ext-apps repository ships `basic-host`, the reference host. It connects
49
+ without auth, so point it at a local server whose requests carry a token, for
50
+ example the e2e harness (`tests/e2e/support.py`) with its fake Appwrite:
51
+
52
+ ```bash
53
+ git clone --depth 1 https://github.com/modelcontextprotocol/ext-apps.git
54
+ cd ext-apps/examples/basic-host && npm install
55
+ NODE_ENV=development npx cross-env INPUT=index.html vite build
56
+ NODE_ENV=development npx cross-env INPUT=sandbox.html vite build
57
+ SERVERS='["http://localhost:3001/mcp"]' bun serve.ts # host on :8080
58
+ ```
59
+
60
+ Pick `appwrite_show`, call it with
61
+ `{"view":"table","project_id":"…","database_id":"…","table_id":"…"}`, and the
62
+ view renders under the tool call. The host's theme toggle switches the view
63
+ between light and dark.
@@ -36,6 +36,7 @@ Flags are for testing overrides only — permanent configuration belongs in
36
36
  | `--console-url` | `MCP_CONSOLE_URL` | Send OAuth login/consent to an alternative Appwrite Console (HTTP transport only). |
37
37
  | `--events` | `MCP_EVENTS` | Advertise MCP Events, serve `events/list` and mount the Appwrite webhook ingress (HTTP transport only; needs `MCP_EVENTS_SEALING_KEYS`). See [events.md](events.md). |
38
38
  | `--events-push` | `MCP_EVENTS_PUSH` | Also offer push delivery (`events/stream`) for MCP Events (needs `--events` and `MCP_EVENTS_REDIS_URL`). See [events.md](events.md#push-delivery). |
39
+ | `--apps` | `MCP_APPS` | Offer MCP Apps: the `appwrite_show` tool and its interactive `ui://appwrite/view`, rendered in the chat by hosts that support MCP Apps. See [apps.md](apps.md). |
39
40
 
40
41
  ### `--console-url` — test OAuth against a pre-release console
41
42
 
@@ -175,3 +176,21 @@ curl -sN http://localhost:8000/ \
175
176
 
176
177
  With the flag off, `events/list` advertises webhook only and `events/stream`
177
178
  returns `-32601` (method not found).
179
+
180
+ ### `--apps` — MCP Apps
181
+
182
+ Adds `appwrite_show` (model and view), the view-only `appwrite_view_write`, and
183
+ the `ui://appwrite/view` resource, and advertises the
184
+ `io.modelcontextprotocol/ui` extension. Clients that declare their
185
+ capabilities without the extension are not offered them. See
186
+ [apps.md](apps.md) for the design and for driving it from a real host.
187
+
188
+ **Enable it:**
189
+
190
+ ```bash
191
+ MCP_PUBLIC_URL=http://localhost:8000 \
192
+ uv run mcp-server-appwrite --transport http --apps 1
193
+ ```
194
+
195
+ With the flag off, `tools/list` is unchanged and `resources/read` of
196
+ `ui://appwrite/view` is an error.
@@ -0,0 +1 @@
1
+ """MCP Apps: interactive Appwrite views rendered in the chat by the host."""
@@ -0,0 +1,80 @@
1
+ """MCP Apps (SEP-1865) negotiation on the low-level SDK server.
2
+
3
+ The extension is ``io.modelcontextprotocol/ui``. A host that supports it reads
4
+ ``_meta.ui.resourceUri`` on a tool, fetches that ``ui://`` resource with
5
+ ``resources/read`` and renders it in a sandboxed iframe next to the tool call.
6
+ Hosts without it ignore ``_meta`` and show the tool's text content, so the
7
+ tools stay usable everywhere.
8
+
9
+ The spec asks servers to offer UI tools only to hosts that declared the
10
+ extension. Whether a host did is known on stdio (the ``initialize`` handshake)
11
+ and on the 2026-07-28 protocol (``clientCapabilities`` in every request's
12
+ ``_meta``). The hosted 2025-era transport is stateless, so its ``tools/list``
13
+ arrives without either; there the tools are offered, since a host that cannot
14
+ render them still gets a text answer. See :func:`supported`.
15
+
16
+ Apps are offered behind the ``apps`` flag (``MCP_APPS``); see :func:`enabled`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Mapping
22
+ from typing import Any
23
+
24
+ from mcp.server import Server, ServerRequestContext
25
+ from mcp.types import CLIENT_CAPABILITIES_META_KEY
26
+
27
+ from .. import flags
28
+
29
+ EXTENSION = "io.modelcontextprotocol/ui"
30
+ """SEP-1865 extension identifier under ``capabilities.extensions``."""
31
+
32
+ MIME_TYPE = "text/html;profile=mcp-app"
33
+ """The only UI resource type the extension defines."""
34
+
35
+ SETTINGS: dict[str, Any] = {"mimeTypes": [MIME_TYPE]}
36
+
37
+
38
+ def enabled() -> bool:
39
+ """Whether this server offers MCP Apps."""
40
+ return flags.enabled(flags.APPS)
41
+
42
+
43
+ def supported(context: ServerRequestContext[Any, Any]) -> bool:
44
+ """Whether the host behind ``context`` may render MCP Apps: ``False`` only
45
+ when its capabilities are known and lack the extension."""
46
+ capabilities = _capabilities(context)
47
+ if capabilities is None:
48
+ return True
49
+ extensions = capabilities.get("extensions")
50
+ if not isinstance(extensions, Mapping):
51
+ return False
52
+ settings = extensions.get(EXTENSION)
53
+ if not isinstance(settings, Mapping):
54
+ return False
55
+ mime_types = settings.get("mimeTypes")
56
+ return not isinstance(mime_types, list) or MIME_TYPE in mime_types
57
+
58
+
59
+ def register(server: Server[Any]) -> None:
60
+ """Advertise the extension in ``server``'s capabilities."""
61
+ server.extensions[EXTENSION] = dict(SETTINGS)
62
+
63
+
64
+ def _capabilities(
65
+ context: ServerRequestContext[Any, Any],
66
+ ) -> Mapping[str, Any] | None:
67
+ """The client capabilities of this request's ``_meta`` (2026-07-28) or of
68
+ the session's handshake, ``None`` when neither is known."""
69
+ meta = context.meta
70
+ if isinstance(meta, Mapping):
71
+ capabilities = meta.get(CLIENT_CAPABILITIES_META_KEY)
72
+ if isinstance(capabilities, Mapping):
73
+ return capabilities
74
+ try:
75
+ params = context.session.client_params
76
+ except Exception:
77
+ return None
78
+ if params is None:
79
+ return None
80
+ return params.capabilities.model_dump(by_alias=True, exclude_none=True)
@@ -0,0 +1,45 @@
1
+ """The Appwrite view's UI resource, ``ui://appwrite/view``.
2
+
3
+ One HTML document (``assets/view.html``, plain JavaScript with no build step)
4
+ renders every view; it picks one from the tool result's ``structuredContent``.
5
+ It declares no CSP domains, so hosts apply SEP-1865's restrictive default: no
6
+ network access and no external scripts. Every Appwrite read and write it makes
7
+ is a ``tools/call`` the host proxies to this server under the user's own
8
+ credentials.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from functools import cache
14
+ from importlib.resources import files
15
+ from typing import Any
16
+
17
+ import mcp.types as types
18
+ from mcp.server.lowlevel.helper_types import ReadResourceContents
19
+
20
+ from ..constants import VIEW_URI
21
+ from .protocol import MIME_TYPE
22
+
23
+ META: dict[str, Any] = {"ui": {"csp": {}, "prefersBorder": True}}
24
+
25
+
26
+ @cache
27
+ def html() -> str:
28
+ return files("mcp_server_appwrite.assets").joinpath("view.html").read_text()
29
+
30
+
31
+ def resource() -> types.Resource:
32
+ return types.Resource(
33
+ uri=VIEW_URI,
34
+ name="Appwrite view",
35
+ description=(
36
+ "Interactive Appwrite view: table rows and deployment build logs."
37
+ ),
38
+ mime_type=MIME_TYPE,
39
+ size=len(html().encode("utf-8")),
40
+ _meta=META,
41
+ )
42
+
43
+
44
+ def read() -> ReadResourceContents:
45
+ return ReadResourceContents(html(), MIME_TYPE, META)
@@ -0,0 +1,396 @@
1
+ """The tools behind the Appwrite view (``ui://appwrite/view``).
2
+
3
+ ``appwrite_show`` is the one tool the model sees. It reads what a view shows
4
+ (a page of a table's rows, or a Function or Site deployment with its build
5
+ logs) through the hidden catalog, and returns it twice: a text summary in
6
+ ``content`` for the model and for hosts without MCP Apps, and the data in
7
+ ``structuredContent`` for the view. The view calls it again to page, refresh
8
+ and follow a build, so every read it makes is one the model could make too.
9
+
10
+ ``appwrite_view_write`` is visible to the view only (``visibility: ["app"]``).
11
+ It runs a short allowlist of catalog writes the view offers buttons for, and
12
+ like ``appwrite_call_write_tool`` it refuses to run without
13
+ ``confirm_write=true``, which the view sends only after the user confirms the
14
+ change in a dialog. Anything the view renders comes from Appwrite, so a
15
+ script injected through that data could call this tool; the allowlist bounds
16
+ what such a script could do.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import re
23
+ from collections.abc import Callable
24
+ from enum import StrEnum
25
+ from typing import Any
26
+
27
+ import mcp.types as types
28
+ from appwrite_console.query import Query
29
+
30
+ from ..constants import SHOW_TOOL_NAME, VIEW_URI, VIEW_WRITE_TOOL_NAME
31
+
32
+ Run = Callable[[dict[str, Any], bool], list[Any]]
33
+ """Runs a hidden catalog tool from raw call arguments (``tool_name``,
34
+ ``arguments``, ``project_id``, ``confirm_write``), as a write or a read."""
35
+
36
+ Handler = Callable[[dict[str, Any]], types.CallToolResult]
37
+
38
+ PAGE_SIZE = 25
39
+ """Rows per page: Appwrite's default list limit."""
40
+
41
+ LOG_LINES = 200
42
+ """Build log lines a view carries. Hosts may put ``structuredContent`` in the
43
+ model's context, and a build log can run to megabytes."""
44
+
45
+ SUMMARY_LOG_LINES = 20
46
+ SUMMARY_ROWS = 10
47
+ SUMMARY_COLUMNS = 4
48
+ SUMMARY_CELL = 24
49
+
50
+ ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
51
+ """Terminal escape sequences (colors, cursor moves) in build logs: the view
52
+ renders the colors, the model's summary drops them."""
53
+
54
+ TERMINAL_STATUSES = frozenset({"ready", "failed", "canceled"})
55
+
56
+ WRITES = (
57
+ "tables_db_update_row",
58
+ "functions_update_deployment_status",
59
+ "sites_update_deployment_status",
60
+ )
61
+ """The writes the view offers: editing a row and cancelling a build."""
62
+
63
+ _SHOW_TITLE = "Show Appwrite view"
64
+ _WRITE_TITLE = "Run Appwrite view action"
65
+
66
+
67
+ class View(StrEnum):
68
+ TABLE = "table"
69
+ DEPLOYMENT = "deployment"
70
+
71
+
72
+ class Views:
73
+ def __init__(self, run: Run):
74
+ self._run = run
75
+
76
+ def tools(self) -> list[tuple[types.Tool, Handler]]:
77
+ return [(_show_tool(), self.show), (_write_tool(), self.write)]
78
+
79
+ def show(self, arguments: dict[str, Any]) -> types.CallToolResult:
80
+ view = arguments.get("view")
81
+ if view == View.TABLE:
82
+ data = self._table(arguments)
83
+ text = _table_summary(data)
84
+ elif view == View.DEPLOYMENT:
85
+ data = self._deployment(arguments)
86
+ text = _deployment_summary(data)
87
+ else:
88
+ raise ValueError(f"view must be one of: {', '.join(View)}. Got {view!r}.")
89
+ return types.CallToolResult(
90
+ content=[types.TextContent(type="text", text=text)],
91
+ structured_content=data,
92
+ )
93
+
94
+ def write(self, arguments: dict[str, Any]) -> types.CallToolResult:
95
+ tool_name = arguments.get("tool_name")
96
+ if tool_name not in WRITES:
97
+ raise ValueError(
98
+ f"{VIEW_WRITE_TOOL_NAME} runs only {', '.join(WRITES)}. "
99
+ f"Got {tool_name!r}."
100
+ )
101
+ # Only the literal true confirms: a string such as "false" is truthy.
102
+ if arguments.get("confirm_write") is not True:
103
+ raise ValueError(
104
+ f"{VIEW_WRITE_TOOL_NAME} runs {tool_name} only with confirm_write=true."
105
+ )
106
+ result = _json(self._run(arguments, True))
107
+ if isinstance(result, dict) and tool_name == "tables_db_update_row":
108
+ result = _row(result)
109
+ return types.CallToolResult(
110
+ content=[types.TextContent(type="text", text=f"{tool_name} succeeded.")],
111
+ structured_content=result if isinstance(result, dict) else None,
112
+ )
113
+
114
+ def _read(
115
+ self, tool_name: str, project_id: Any, arguments: dict[str, Any]
116
+ ) -> dict[str, Any]:
117
+ result = _json(
118
+ self._run(
119
+ {
120
+ "tool_name": tool_name,
121
+ "project_id": project_id,
122
+ "arguments": arguments,
123
+ },
124
+ False,
125
+ )
126
+ )
127
+ if not isinstance(result, dict):
128
+ raise ValueError(f"{tool_name} returned an unexpected response.")
129
+ return result
130
+
131
+ def _table(self, arguments: dict[str, Any]) -> dict[str, Any]:
132
+ project_id = arguments.get("project_id")
133
+ database_id = _required(arguments, "database_id")
134
+ table_id = _required(arguments, "table_id")
135
+ cursor = arguments.get("cursor")
136
+ target = {"database_id": database_id, "table_id": table_id}
137
+
138
+ table = self._read("tables_db_get_table", project_id, target)
139
+ # One row past the page tells whether another page follows.
140
+ queries = [Query.order_desc("$createdAt"), Query.limit(PAGE_SIZE + 1)]
141
+ if cursor:
142
+ queries.append(Query.cursor_after(str(cursor)))
143
+ page = self._read(
144
+ "tables_db_list_rows", project_id, {**target, "queries": queries}
145
+ )
146
+ rows = [_row(each) for each in page.get("rows") or []]
147
+ more = len(rows) > PAGE_SIZE
148
+ rows = rows[:PAGE_SIZE]
149
+ columns = [
150
+ {"key": column.get("key"), "type": column.get("type")}
151
+ for column in table.get("columns") or []
152
+ if column.get("status", "available") == "available"
153
+ ]
154
+ return {
155
+ "view": View.TABLE.value,
156
+ "projectId": project_id,
157
+ "databaseId": database_id,
158
+ "tableId": table_id,
159
+ "name": table.get("name") or table_id,
160
+ "columns": columns,
161
+ "rows": rows,
162
+ "total": int(page.get("total", len(rows))),
163
+ "cursor": cursor,
164
+ "next": rows[-1].get("$id") if more else None,
165
+ }
166
+
167
+ def _deployment(self, arguments: dict[str, Any]) -> dict[str, Any]:
168
+ project_id = arguments.get("project_id")
169
+ function_id = arguments.get("function_id")
170
+ site_id = arguments.get("site_id")
171
+ if bool(function_id) == bool(site_id):
172
+ raise ValueError("Pass exactly one of function_id or site_id.")
173
+ kind, resource_key, resource_id = (
174
+ ("function", "function_id", function_id)
175
+ if function_id
176
+ else ("site", "site_id", site_id)
177
+ )
178
+ service = "functions" if function_id else "sites"
179
+ target = {resource_key: resource_id}
180
+
181
+ deployment_id = arguments.get("deployment_id")
182
+ if deployment_id:
183
+ deployment = self._read(
184
+ f"{service}_get_deployment",
185
+ project_id,
186
+ {**target, "deployment_id": deployment_id},
187
+ )
188
+ else:
189
+ latest = self._read(
190
+ f"{service}_list_deployments",
191
+ project_id,
192
+ {
193
+ **target,
194
+ "queries": [Query.order_desc("$createdAt"), Query.limit(1)],
195
+ },
196
+ )
197
+ deployments = latest.get("deployments") or []
198
+ if not deployments:
199
+ raise ValueError(f"The {kind} {resource_id} has no deployments.")
200
+ deployment = deployments[0]
201
+
202
+ logs = (deployment.get("buildLogs") or "").splitlines()
203
+ status = deployment.get("status") or "unknown"
204
+ return {
205
+ "view": View.DEPLOYMENT.value,
206
+ "projectId": project_id,
207
+ "kind": kind,
208
+ "resourceId": resource_id,
209
+ "deployment": {
210
+ "id": deployment.get("$id"),
211
+ "status": status,
212
+ "createdAt": deployment.get("$createdAt"),
213
+ "updatedAt": deployment.get("$updatedAt"),
214
+ "buildDuration": deployment.get("buildDuration"),
215
+ "sourceSize": deployment.get("sourceSize"),
216
+ "buildSize": deployment.get("buildSize"),
217
+ "activate": deployment.get("activate"),
218
+ "branch": deployment.get("providerBranch") or None,
219
+ "commit": deployment.get("providerCommitHash") or None,
220
+ "commitMessage": deployment.get("providerCommitMessage") or None,
221
+ "logs": logs[-LOG_LINES:],
222
+ "truncated": len(logs) > LOG_LINES,
223
+ },
224
+ "done": status in TERMINAL_STATUSES,
225
+ }
226
+
227
+
228
+ def _show_tool() -> types.Tool:
229
+ return types.Tool(
230
+ name=SHOW_TOOL_NAME,
231
+ title=_SHOW_TITLE,
232
+ annotations=_annotations(_SHOW_TITLE, read_only=True),
233
+ description=(
234
+ "Show an interactive Appwrite view in the chat: view=table pages "
235
+ "through a table's rows and lets the user edit one; view=deployment "
236
+ "follows a Function or Site deployment's status and build logs live. "
237
+ "Use it when the user wants to see or browse this data rather than "
238
+ "reason over it; the result also carries a short text summary. Calls "
239
+ "the Appwrite REST API."
240
+ ),
241
+ input_schema={
242
+ "type": "object",
243
+ "properties": {
244
+ "view": {"type": "string", "enum": [view.value for view in View]},
245
+ "project_id": {
246
+ "type": "string",
247
+ "description": "Appwrite project ID the data lives in.",
248
+ },
249
+ "database_id": {
250
+ "type": "string",
251
+ "description": "view=table: the TablesDB database ID.",
252
+ },
253
+ "table_id": {
254
+ "type": "string",
255
+ "description": "view=table: the table ID.",
256
+ },
257
+ "cursor": {
258
+ "type": "string",
259
+ "description": (
260
+ "view=table: continue after this row ID (the previous "
261
+ "page's `next`). Omit for the newest rows."
262
+ ),
263
+ },
264
+ "function_id": {
265
+ "type": "string",
266
+ "description": "view=deployment: the Function ID (or site_id).",
267
+ },
268
+ "site_id": {
269
+ "type": "string",
270
+ "description": "view=deployment: the Site ID (or function_id).",
271
+ },
272
+ "deployment_id": {
273
+ "type": "string",
274
+ "description": "view=deployment: omit for the latest deployment.",
275
+ },
276
+ },
277
+ "required": ["view"],
278
+ "additionalProperties": False,
279
+ },
280
+ _meta={
281
+ "ui": {"resourceUri": VIEW_URI, "visibility": ["model", "app"]},
282
+ # The flat key hosts read before ``ui.resourceUri``; SEP-1865 drops
283
+ # it at GA.
284
+ "ui/resourceUri": VIEW_URI,
285
+ },
286
+ )
287
+
288
+
289
+ def _write_tool() -> types.Tool:
290
+ return types.Tool(
291
+ name=VIEW_WRITE_TOOL_NAME,
292
+ title=_WRITE_TITLE,
293
+ annotations=_annotations(_WRITE_TITLE, read_only=False),
294
+ description=(
295
+ "Used by the Appwrite view after the user confirms a change in it. "
296
+ "Runs one of a fixed set of Appwrite writes; requires "
297
+ "confirm_write=true. Calls the Appwrite REST API."
298
+ ),
299
+ input_schema={
300
+ "type": "object",
301
+ "properties": {
302
+ "tool_name": {"type": "string", "enum": list(WRITES)},
303
+ "arguments": {"type": "object"},
304
+ "project_id": {"type": "string"},
305
+ "confirm_write": {"type": "boolean"},
306
+ },
307
+ "required": ["tool_name", "arguments", "confirm_write"],
308
+ "additionalProperties": False,
309
+ },
310
+ _meta={"ui": {"resourceUri": VIEW_URI, "visibility": ["app"]}},
311
+ )
312
+
313
+
314
+ def _annotations(title: str, *, read_only: bool) -> types.ToolAnnotations:
315
+ return types.ToolAnnotations(
316
+ title=title,
317
+ read_only_hint=read_only,
318
+ destructive_hint=not read_only,
319
+ idempotent_hint=read_only,
320
+ open_world_hint=True,
321
+ )
322
+
323
+
324
+ def _required(arguments: dict[str, Any], key: str) -> str:
325
+ value = arguments.get(key)
326
+ if not isinstance(value, str) or not value:
327
+ raise ValueError(f"{key} is required for view={arguments.get('view')}.")
328
+ return value
329
+
330
+
331
+ def _row(row: dict[str, Any]) -> dict[str, Any]:
332
+ """A row with its column values beside the system fields. The SDK's ``Row``
333
+ model moves column values into ``data``."""
334
+ values = row.get("data")
335
+ if not isinstance(values, dict):
336
+ return row
337
+ return {key: value for key, value in row.items() if key != "data"} | values
338
+
339
+
340
+ def _json(content: list[Any]) -> Any:
341
+ text = "\n".join(
342
+ item.text for item in content if isinstance(item, types.TextContent)
343
+ )
344
+ try:
345
+ return json.loads(text)
346
+ except ValueError as error:
347
+ raise ValueError("Appwrite returned a response that is not JSON.") from error
348
+
349
+
350
+ def _cell(value: Any) -> str:
351
+ text = value if isinstance(value, str) else json.dumps(value, default=str)
352
+ text = text.replace("\n", " ")
353
+ if len(text) > SUMMARY_CELL:
354
+ return text[: SUMMARY_CELL - 1] + "…"
355
+ return text
356
+
357
+
358
+ def _table_summary(data: dict[str, Any]) -> str:
359
+ rows: list[dict[str, Any]] = data["rows"]
360
+ keys = ["$id"] + [
361
+ column["key"] for column in data["columns"][: SUMMARY_COLUMNS - 1]
362
+ ]
363
+ lines = [
364
+ f"Table {data['name']} ({data['databaseId']}/{data['tableId']}): "
365
+ f"{data['total']} rows, showing {len(rows)} newest"
366
+ + (" after the cursor." if data["cursor"] else ".")
367
+ ]
368
+ if rows:
369
+ lines.append(" | ".join(keys))
370
+ for row in rows[:SUMMARY_ROWS]:
371
+ lines.append(" | ".join(_cell(row.get(key, "")) for key in keys))
372
+ if len(rows) > SUMMARY_ROWS:
373
+ lines.append(f"… {len(rows) - SUMMARY_ROWS} more rows shown in the view.")
374
+ if data["next"]:
375
+ lines.append(
376
+ f"Next page: {SHOW_TOOL_NAME} with view=table and cursor={data['next']}."
377
+ )
378
+ return "\n".join(lines)
379
+
380
+
381
+ def _deployment_summary(data: dict[str, Any]) -> str:
382
+ deployment = data["deployment"]
383
+ lines = [
384
+ f"Deployment {deployment['id']} of {data['kind']} {data['resourceId']} "
385
+ f"is {deployment['status']}."
386
+ ]
387
+ if deployment["commit"]:
388
+ lines.append(
389
+ f"Source: {deployment['branch'] or 'unknown branch'} @ "
390
+ f"{deployment['commit'][:7]}."
391
+ )
392
+ logs = deployment["logs"][-SUMMARY_LOG_LINES:]
393
+ if logs:
394
+ lines.append("Last build log lines:")
395
+ lines.extend(ANSI.sub("", line) for line in logs)
396
+ return "\n".join(lines)