mcp-server-appwrite 0.8.0__tar.gz → 0.8.2__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 (59) hide show
  1. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.github/workflows/ci.yml +3 -0
  2. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/AGENTS.md +10 -3
  3. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/PKG-INFO +1 -1
  4. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/pyproject.toml +9 -1
  5. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/auth.py +78 -38
  6. mcp_server_appwrite-0.8.2/src/mcp_server_appwrite/constants.py +144 -0
  7. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/context.py +2 -45
  8. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/docs_search.py +24 -18
  9. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/http_app.py +6 -13
  10. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/operator.py +21 -14
  11. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/server.py +45 -52
  12. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/service.py +2 -2
  13. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/telemetry.py +4 -4
  14. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/tool_manager.py +6 -6
  15. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_auth.py +122 -22
  16. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_docs_search.py +2 -2
  17. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_operator.py +51 -0
  18. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_server.py +4 -2
  19. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/uv.lock +25 -1
  20. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.dockerignore +0 -0
  21. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.env.example +0 -0
  22. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.github/workflows/production.yml +0 -0
  23. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.github/workflows/publish.yml +0 -0
  24. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.github/workflows/staging.yml +0 -0
  25. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.gitignore +0 -0
  26. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.python-version +0 -0
  27. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/CLAUDE.md +0 -0
  28. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/Dockerfile +0 -0
  29. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/LICENSE +0 -0
  30. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/README.md +0 -0
  31. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/compose.yaml +0 -0
  32. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/authentication.md +0 -0
  33. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/development.md +0 -0
  34. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/documentation-search.md +0 -0
  35. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/self-hosted.md +0 -0
  36. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/tool-surface.md +0 -0
  37. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/scripts/build_docs_index.py +0 -0
  38. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/server.json +0 -0
  39. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/__init__.py +0 -0
  40. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/__main__.py +0 -0
  41. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/data/docs_index.npz +0 -0
  42. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/data/docs_index_meta.json +0 -0
  43. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/support.py +0 -0
  44. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_avatars.py +0 -0
  45. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_functions.py +0 -0
  46. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_locale.py +0 -0
  47. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_messaging.py +0 -0
  48. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_oauth_discovery.py +0 -0
  49. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_operator.py +0 -0
  50. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_sites.py +0 -0
  51. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_storage.py +0 -0
  52. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_tables_db.py +0 -0
  53. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_teams.py +0 -0
  54. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_users.py +0 -0
  55. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/test_all.py +0 -0
  56. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_context.py +0 -0
  57. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_http_app.py +0 -0
  58. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_service.py +0 -0
  59. {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_telemetry.py +0 -0
@@ -33,6 +33,9 @@ jobs:
33
33
  - name: Check formatting
34
34
  run: uv run --group dev black --check src tests
35
35
 
36
+ - name: Type check
37
+ run: uv run --group dev pyright
38
+
36
39
  unit:
37
40
  name: Unit
38
41
  runs-on: ubuntu-latest
@@ -93,20 +93,27 @@ Run these locally before opening a PR. They mirror the `CI` workflow
93
93
  ```
94
94
  Run `uv run --group dev black src tests` (without `--check`) to auto-fix.
95
95
 
96
- 3. **Unit tests** (`unit` job)
96
+ 3. **Type check** (`lint` job)
97
+ ```bash
98
+ uv run --group dev pyright
99
+ ```
100
+ Pyright config lives in `pyproject.toml` (`[tool.pyright]`): basic mode over
101
+ `src/`, Python 3.12, resolving against the project `.venv`.
102
+
103
+ 4. **Unit tests** (`unit` job)
97
104
  ```bash
98
105
  uv sync
99
106
  uv run python -m unittest discover -s tests/unit -v
100
107
  ```
101
108
  Fast, no external services or credentials required.
102
109
 
103
- 4. **Docker build** (`docker` job)
110
+ 5. **Docker build** (`docker` job)
104
111
  ```bash
105
112
  docker build -t appwrite-mcp:ci .
106
113
  ```
107
114
  The hosted HTTP image must build cleanly.
108
115
 
109
- 5. **Integration tests** (`integration` job) — *CI runs these only for pushes and
116
+ 6. **Integration tests** (`integration` job) — *CI runs these only for pushes and
110
117
  for PRs from branches on the same repo (not forks).* They create and delete
111
118
  **real** Appwrite resources, so they need live credentials and are skipped
112
119
  when absent:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-server-appwrite
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: MCP (Model Context Protocol) server for Appwrite
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-server-appwrite"
3
- version = "0.8.0"
3
+ version = "0.8.2"
4
4
  description = "MCP (Model Context Protocol) server for Appwrite"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -32,6 +32,7 @@ integration = [
32
32
  dev = [
33
33
  "black>=25.1.0",
34
34
  "ruff>=0.10.0",
35
+ "pyright>=1.1.390",
35
36
  # Only needed by scripts/build_docs_index.py to (re)build the docs index.
36
37
  "pyyaml>=6.0",
37
38
  ]
@@ -56,6 +57,13 @@ line-length = 88
56
57
  select = ["E", "F", "W", "I"]
57
58
  ignore = ["E501"]
58
59
 
60
+ [tool.pyright]
61
+ pythonVersion = "3.12"
62
+ include = ["src"]
63
+ venvPath = "."
64
+ venv = ".venv"
65
+ typeCheckingMode = "basic"
66
+
59
67
  [build-system]
60
68
  requires = ["hatchling"]
61
69
  build-backend = "hatchling.build"
@@ -19,16 +19,19 @@ import sys
19
19
  import time
20
20
  from urllib.parse import urlsplit, urlunsplit
21
21
 
22
- import anyio
23
22
  import httpx
24
23
  import jwt
24
+ from anyio import to_thread
25
25
  from jwt import PyJWKClient
26
26
  from mcp.server.auth.provider import AccessToken, TokenVerifier
27
27
 
28
28
  from . import telemetry
29
-
30
- DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"
31
- DEFAULT_PROJECT_ID = "console"
29
+ from .constants import (
30
+ DEFAULT_ENDPOINT,
31
+ DEFAULT_PROJECT_ID,
32
+ DISCOVERY_TTL_SECONDS,
33
+ PREFERRED_SCOPES,
34
+ )
32
35
 
33
36
 
34
37
  def _log(message: str) -> None:
@@ -69,10 +72,30 @@ def resource_metadata_url() -> str:
69
72
  return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
70
73
 
71
74
 
72
- # Cache of scopes_supported, keyed by served project id (process lifetime; the
73
- # project OAuth config is effectively static). Failed lookups raise and are not
74
- # cached, so they retry.
75
- _discovery_cache: dict[str, dict] = {}
75
+ def preferred_scopes() -> list[str]:
76
+ override = os.getenv("MCP_OAUTH_SCOPES", "").split()
77
+ return override or list(PREFERRED_SCOPES)
78
+
79
+
80
+ # Discovery cache keyed by served project id: (monotonic fetch time, document).
81
+ # Entries are refreshed after a TTL so authorization-server changes (issuer host,
82
+ # scope model) propagate without a redeploy; if a refresh fails, the stale copy
83
+ # keeps serving so an authorization-server blip doesn't take the MCP down.
84
+ _discovery_cache: dict[str, tuple[float, dict]] = {}
85
+
86
+
87
+ def _cached_discovery(project_id: str, *, allow_stale: bool = False) -> dict | None:
88
+ entry = _discovery_cache.get(project_id)
89
+ if entry is None:
90
+ return None
91
+ fetched_at, document = entry
92
+ if allow_stale or time.monotonic() - fetched_at < DISCOVERY_TTL_SECONDS:
93
+ return document
94
+ return None
95
+
96
+
97
+ def _store_discovery(project_id: str, document: dict) -> None:
98
+ _discovery_cache[project_id] = (time.monotonic(), document)
76
99
 
77
100
 
78
101
  def discovery_url() -> str:
@@ -91,47 +114,68 @@ def _validate_discovery(doc: dict, url: str) -> dict:
91
114
 
92
115
  async def authorization_server_metadata() -> dict:
93
116
  project_id = configured_project_id()
94
- cached = _discovery_cache.get(project_id)
117
+ cached = _cached_discovery(project_id)
95
118
  if cached is not None:
96
119
  return cached
97
120
 
98
121
  url = discovery_url()
99
- async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
100
- resp = await client.get(url)
101
- resp.raise_for_status()
102
- metadata = _validate_discovery(resp.json(), url)
103
-
104
- _discovery_cache[project_id] = metadata
122
+ try:
123
+ async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
124
+ resp = await client.get(url)
125
+ resp.raise_for_status()
126
+ metadata = _validate_discovery(resp.json(), url)
127
+ except Exception as exc:
128
+ stale = _cached_discovery(project_id, allow_stale=True)
129
+ if stale is not None:
130
+ _log(f"Discovery refresh failed ({exc}); serving stale metadata.")
131
+ return stale
132
+ raise
133
+
134
+ _store_discovery(project_id, metadata)
105
135
  return metadata
106
136
 
107
137
 
108
138
  def authorization_server_metadata_sync() -> dict:
109
139
  project_id = configured_project_id()
110
- cached = _discovery_cache.get(project_id)
140
+ cached = _cached_discovery(project_id)
111
141
  if cached is not None:
112
142
  return cached
113
143
 
114
144
  url = discovery_url()
115
- resp = httpx.get(url, timeout=10.0, follow_redirects=True)
116
- resp.raise_for_status()
117
- metadata = _validate_discovery(resp.json(), url)
118
- _discovery_cache[project_id] = metadata
145
+ try:
146
+ resp = httpx.get(url, timeout=10.0, follow_redirects=True)
147
+ resp.raise_for_status()
148
+ metadata = _validate_discovery(resp.json(), url)
149
+ except Exception as exc:
150
+ stale = _cached_discovery(project_id, allow_stale=True)
151
+ if stale is not None:
152
+ _log(f"Discovery refresh failed ({exc}); serving stale metadata.")
153
+ return stale
154
+ raise
155
+
156
+ _store_discovery(project_id, metadata)
119
157
  return metadata
120
158
 
121
159
 
122
- async def supported_scopes() -> list[str]:
123
- """Scopes advertised in the protected-resource metadata, sourced live from the
124
- served project's authorization-server discovery (`scopes_supported`). This is
125
- exactly the set the project's OAuth server will grant, so it never drifts from
126
- the tool surface. Raises if discovery is unreachable or malformed (the
127
- authorization server is the same Appwrite deployment this MCP depends on)."""
128
- metadata = await authorization_server_metadata()
129
- scopes = metadata.get("scopes_supported")
130
- if not isinstance(scopes, list):
160
+ def _advertised_scopes(metadata: dict) -> list[str]:
161
+ """The scope set to advertise: the preferred scopes intersected with the
162
+ authorization server's live ``scopes_supported`` (so a renamed/removed scope
163
+ is never advertised). Falls back to mirroring the full discovery list when
164
+ none of the preferred scopes exist e.g. a self-hosted project with a
165
+ custom, compact scope catalog."""
166
+ discovered = metadata.get("scopes_supported")
167
+ if not isinstance(discovered, list):
131
168
  raise ValueError(
132
169
  f"authorization server discovery missing scopes_supported: {discovery_url()}"
133
170
  )
134
- return scopes
171
+ scopes = [scope for scope in preferred_scopes() if scope in discovered]
172
+ if scopes:
173
+ return scopes
174
+ _log(
175
+ "None of the preferred scopes are in the authorization server's "
176
+ "scopes_supported; advertising the full discovered list."
177
+ )
178
+ return discovered
135
179
 
136
180
 
137
181
  def build_resource_metadata(scopes: list[str], authorization_servers=None) -> dict:
@@ -145,14 +189,10 @@ def build_resource_metadata(scopes: list[str], authorization_servers=None) -> di
145
189
 
146
190
 
147
191
  async def protected_resource_metadata() -> dict:
148
- """RFC 9728 Protected Resource Metadata, with scopes sourced from AS discovery."""
192
+ """RFC 9728 Protected Resource Metadata, with scopes validated against AS
193
+ discovery."""
149
194
  metadata = await authorization_server_metadata()
150
- scopes = metadata.get("scopes_supported")
151
- if not isinstance(scopes, list):
152
- raise ValueError(
153
- f"authorization server discovery missing scopes_supported: {discovery_url()}"
154
- )
155
- return build_resource_metadata(scopes, [metadata["issuer"]])
195
+ return build_resource_metadata(_advertised_scopes(metadata), [metadata["issuer"]])
156
196
 
157
197
 
158
198
  def project_id_from_issuer(iss: str | None) -> str | None:
@@ -286,7 +326,7 @@ class AppwriteTokenVerifier(TokenVerifier):
286
326
 
287
327
  async def verify_token(self, token: str) -> AccessToken | None:
288
328
  start = time.monotonic()
289
- access_token = await anyio.to_thread.run_sync(self._verify_sync, token)
329
+ access_token = await to_thread.run_sync(self._verify_sync, token)
290
330
  duration = time.monotonic() - start
291
331
  if access_token is None:
292
332
  # The specific rejection reason was already counted in _verify_sync;
@@ -0,0 +1,144 @@
1
+ """Single home for the package's constants, grouped by the module that uses them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from appwrite.models.bucket import Bucket
8
+ from appwrite.models.database import Database
9
+ from appwrite.models.function import Function
10
+ from appwrite.models.message import Message
11
+ from appwrite.models.site import Site
12
+ from appwrite.models.team import Team
13
+ from appwrite.models.user import User
14
+
15
+ # --- server ---------------------------------------------------------------
16
+
17
+ SERVER_VERSION = "0.8.2"
18
+
19
+ DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"
20
+ DEFAULT_TRANSPORT = "stdio"
21
+ TRANSPORTS = {"stdio", "http"}
22
+ VALIDATION_SERVICE_ORDER = (
23
+ "tables_db",
24
+ "users",
25
+ "teams",
26
+ "functions",
27
+ "sites",
28
+ "storage",
29
+ "messaging",
30
+ "locale",
31
+ "avatars",
32
+ )
33
+
34
+ # Service modules in the Appwrite SDK to skip (none by default — every service the
35
+ # installed SDK ships is exposed). Add a module name here to hide a service.
36
+ EXCLUDED_SERVICES: frozenset[str] = frozenset()
37
+
38
+ MAX_FETCH_BYTES = 25 * 1024 * 1024 # 25 MB cap on server-fetched files
39
+ MAX_INLINE_BYTES = 256 * 1024 # 256 KB cap on decoded inline content
40
+ FETCH_TIMEOUT_SECONDS = 30.0
41
+ FETCH_MAX_REDIRECTS = 5
42
+
43
+ HOSTED_PATH_GUIDANCE = (
44
+ "The hosted Appwrite MCP server cannot read local file paths. For '{param}', pass a "
45
+ 'public URL as {{"url": "https://..."}} (preferred), or a small file inline as '
46
+ '{{"filename": "...", "content": "<base64>", "encoding": "base64"}}.'
47
+ )
48
+
49
+ # --- auth -----------------------------------------------------------------
50
+
51
+ DEFAULT_PROJECT_ID = "console"
52
+
53
+ PREFERRED_SCOPES = [
54
+ "openid",
55
+ "profile",
56
+ "email",
57
+ "all",
58
+ "project:all",
59
+ "organization:all",
60
+ ]
61
+
62
+ DISCOVERY_TTL_SECONDS = 300.0
63
+
64
+ # --- http_app -------------------------------------------------------------
65
+
66
+ CORS_HEADERS = {
67
+ "Access-Control-Allow-Origin": "*",
68
+ "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
69
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
70
+ "Access-Control-Expose-Headers": "Mcp-Session-Id, WWW-Authenticate",
71
+ }
72
+
73
+ # --- operator -------------------------------------------------------------
74
+
75
+ SEARCH_LIMIT = 8
76
+ PREVIEW_THRESHOLD = 800
77
+ RESULT_STORE_SIZE = 50
78
+ CATALOG_URI = "appwrite://operator/catalog"
79
+ RESULT_URI_TEMPLATE = "appwrite://operator/results/{result_id}"
80
+ VERBS = {"list", "get", "create", "update", "delete"}
81
+ READ_VERBS = {"list", "get"}
82
+ CREATE_HINTS = {"add", "build", "create", "insert", "make", "new", "provision"}
83
+ UPDATE_HINTS = {"change", "edit", "modify", "rename", "set", "update"}
84
+ DELETE_HINTS = {"delete", "destroy", "drop", "remove"}
85
+ READ_HINTS = {"fetch", "find", "get", "list", "read", "search", "show", "view"}
86
+
87
+ # --- docs_search ----------------------------------------------------------
88
+
89
+ DOCS_TOOL_NAME = "appwrite_search_docs"
90
+ EMBED_MODEL = "text-embedding-3-small"
91
+ DOCS_DEFAULT_LIMIT = 5
92
+ DOCS_MAX_LIMIT = 10
93
+ DOCS_DEFAULT_MIN_SCORE = 0.25
94
+ DOCS_MIN_QUERY_LENGTH = 3
95
+
96
+ DATA_DIR = Path(__file__).parent / "data"
97
+ VECTORS_FILE = "docs_index.npz"
98
+ META_FILE = "docs_index_meta.json"
99
+
100
+ # --- context --------------------------------------------------------------
101
+
102
+ SERVICE_PROBES = {
103
+ "tablesdb": {
104
+ "path": "/tablesdb",
105
+ "items_key": "databases",
106
+ "model": Database,
107
+ },
108
+ "users": {
109
+ "path": "/users",
110
+ "items_key": "users",
111
+ "model": User,
112
+ },
113
+ "storage": {
114
+ "path": "/storage/buckets",
115
+ "items_key": "buckets",
116
+ "model": Bucket,
117
+ },
118
+ "functions": {
119
+ "path": "/functions",
120
+ "items_key": "functions",
121
+ "model": Function,
122
+ },
123
+ "sites": {
124
+ "path": "/sites",
125
+ "items_key": "sites",
126
+ "model": Site,
127
+ },
128
+ "messaging": {
129
+ "path": "/messaging/messages",
130
+ "items_key": "messages",
131
+ "model": Message,
132
+ },
133
+ "teams": {
134
+ "path": "/teams",
135
+ "items_key": "teams",
136
+ "model": Team,
137
+ },
138
+ }
139
+
140
+ REDACTED_KEYS = {"password", "secret", "key", "token", "otp", "cookie", "session"}
141
+
142
+ # --- telemetry ------------------------------------------------------------
143
+
144
+ ACTIVE_WINDOW_SECONDS = 300.0 # rolling window for "active users/clients" gauges
@@ -5,57 +5,14 @@ from typing import Any
5
5
 
6
6
  from appwrite.client import Client
7
7
  from appwrite.exception import AppwriteException
8
- from appwrite.models.bucket import Bucket
9
- from appwrite.models.database import Database
10
- from appwrite.models.function import Function
11
- from appwrite.models.message import Message
12
8
  from appwrite.models.project import Project
13
- from appwrite.models.site import Site
14
9
  from appwrite.models.team import Team
15
10
  from appwrite.models.user import User
16
11
  from appwrite.query import Query
17
12
 
18
- ContextClientFactory = Callable[[str | None, str | None], Client]
13
+ from .constants import REDACTED_KEYS, SERVICE_PROBES
19
14
 
20
- SERVICE_PROBES = {
21
- "tablesdb": {
22
- "path": "/tablesdb",
23
- "items_key": "databases",
24
- "model": Database,
25
- },
26
- "users": {
27
- "path": "/users",
28
- "items_key": "users",
29
- "model": User,
30
- },
31
- "storage": {
32
- "path": "/storage/buckets",
33
- "items_key": "buckets",
34
- "model": Bucket,
35
- },
36
- "functions": {
37
- "path": "/functions",
38
- "items_key": "functions",
39
- "model": Function,
40
- },
41
- "sites": {
42
- "path": "/sites",
43
- "items_key": "sites",
44
- "model": Site,
45
- },
46
- "messaging": {
47
- "path": "/messaging/messages",
48
- "items_key": "messages",
49
- "model": Message,
50
- },
51
- "teams": {
52
- "path": "/teams",
53
- "items_key": "teams",
54
- "model": Team,
55
- },
56
- }
57
-
58
- REDACTED_KEYS = {"password", "secret", "key", "token", "otp", "cookie", "session"}
15
+ ContextClientFactory = Callable[[str | None, str | None], Client]
59
16
 
60
17
 
61
18
  def get_appwrite_context(
@@ -22,20 +22,20 @@ from typing import Any, Callable
22
22
  import mcp.types as types
23
23
 
24
24
  from . import telemetry
25
+ from .constants import (
26
+ DATA_DIR,
27
+ DOCS_DEFAULT_LIMIT,
28
+ DOCS_DEFAULT_MIN_SCORE,
29
+ DOCS_MAX_LIMIT,
30
+ DOCS_MIN_QUERY_LENGTH,
31
+ DOCS_TOOL_NAME,
32
+ EMBED_MODEL,
33
+ META_FILE,
34
+ VECTORS_FILE,
35
+ )
25
36
 
26
37
  ToolContent = types.TextContent | types.ImageContent | types.EmbeddedResource
27
38
 
28
- TOOL_NAME = "appwrite_search_docs"
29
- EMBED_MODEL = "text-embedding-3-small"
30
- DEFAULT_LIMIT = 5
31
- MAX_LIMIT = 10
32
- DEFAULT_MIN_SCORE = 0.25
33
- MIN_QUERY_LENGTH = 3
34
-
35
- DATA_DIR = Path(__file__).parent / "data"
36
- VECTORS_FILE = "docs_index.npz"
37
- META_FILE = "docs_index_meta.json"
38
-
39
39
  # An embedder maps a query string to its embedding vector.
40
40
  Embedder = Callable[[str], list[float]]
41
41
 
@@ -63,7 +63,7 @@ def _clamp_limit(value: Any, default: int) -> int:
63
63
  limit = int(value)
64
64
  if limit < 1:
65
65
  raise ValueError("limit must be at least 1.")
66
- return min(limit, MAX_LIMIT)
66
+ return min(limit, DOCS_MAX_LIMIT)
67
67
 
68
68
 
69
69
  class DocsSearch:
@@ -80,14 +80,14 @@ class DocsSearch:
80
80
  data_dir: Path | None = None,
81
81
  embedder: Embedder | None = None,
82
82
  min_score: float | None = None,
83
- default_limit: int = DEFAULT_LIMIT,
83
+ default_limit: int = DOCS_DEFAULT_LIMIT,
84
84
  ):
85
85
  self._data_dir = data_dir or DATA_DIR
86
86
  self._embedder = embedder if embedder is not None else _default_embedder()
87
87
  self._min_score = (
88
88
  min_score
89
89
  if min_score is not None
90
- else float(os.getenv("DOCS_SEARCH_MIN_SCORE", DEFAULT_MIN_SCORE))
90
+ else float(os.getenv("DOCS_SEARCH_MIN_SCORE", DOCS_DEFAULT_MIN_SCORE))
91
91
  )
92
92
  self._default_limit = int(os.getenv("DOCS_SEARCH_LIMIT", default_limit))
93
93
  self._vectors = None # np.ndarray [N, D], L2-normalized
@@ -118,7 +118,7 @@ class DocsSearch:
118
118
 
119
119
  def get_tool(self) -> types.Tool:
120
120
  return types.Tool(
121
- name=TOOL_NAME,
121
+ name=DOCS_TOOL_NAME,
122
122
  description=(
123
123
  "Search the Appwrite documentation with a natural-language query and "
124
124
  "return the most relevant documentation pages with their full content. "
@@ -136,7 +136,7 @@ class DocsSearch:
136
136
  "limit": {
137
137
  "type": "integer",
138
138
  "minimum": 1,
139
- "maximum": MAX_LIMIT,
139
+ "maximum": DOCS_MAX_LIMIT,
140
140
  "description": f"Maximum number of pages to return. Defaults to {self._default_limit}.",
141
141
  },
142
142
  },
@@ -148,9 +148,9 @@ class DocsSearch:
148
148
  def search(self, arguments: dict[str, Any] | None) -> list[ToolContent]:
149
149
  arguments = arguments or {}
150
150
  query = str(arguments.get("query", "")).strip()
151
- if len(query) < MIN_QUERY_LENGTH:
151
+ if len(query) < DOCS_MIN_QUERY_LENGTH:
152
152
  raise ValueError(
153
- f"query must be at least {MIN_QUERY_LENGTH} characters long."
153
+ f"query must be at least {DOCS_MIN_QUERY_LENGTH} characters long."
154
154
  )
155
155
  if not self.available:
156
156
  raise RuntimeError(
@@ -185,6 +185,12 @@ class DocsSearch:
185
185
  """Return the ranked pages and the embedding call's duration in seconds."""
186
186
  import numpy as np
187
187
 
188
+ # _rank is only reachable when the index is loaded and an embedder exists
189
+ # (guarded by `available`); narrow the optionals for the type checker.
190
+ assert self._embedder is not None
191
+ assert self._vectors is not None
192
+ assert self._chunk_page is not None
193
+
188
194
  embed_start = time.monotonic()
189
195
  embedding = np.asarray(self._embedder(query), dtype=np.float32)
190
196
  embedding_duration_s = time.monotonic() - embed_start
@@ -29,7 +29,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware
29
29
  from starlette.requests import Request
30
30
  from starlette.responses import JSONResponse, PlainTextResponse
31
31
  from starlette.routing import Route
32
- from starlette.types import Receive, Scope, Send
32
+ from starlette.types import ASGIApp, Receive, Scope, Send
33
33
 
34
34
  from . import telemetry
35
35
  from .auth import (
@@ -37,20 +37,13 @@ from .auth import (
37
37
  protected_resource_metadata,
38
38
  resource_metadata_url,
39
39
  )
40
+ from .constants import CORS_HEADERS, SERVER_VERSION
40
41
  from .server import (
41
- SERVER_VERSION,
42
42
  build_catalog_tools_manager,
43
43
  build_mcp_server,
44
44
  build_operator,
45
45
  )
46
46
 
47
- _CORS_HEADERS = {
48
- "Access-Control-Allow-Origin": "*",
49
- "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
50
- "Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
51
- "Access-Control-Expose-Headers": "Mcp-Session-Id, WWW-Authenticate",
52
- }
53
-
54
47
 
55
48
  class HealthzAccessLogFilter(logging.Filter):
56
49
  """Drop noisy load-balancer health probes from uvicorn access logs."""
@@ -98,7 +91,7 @@ class RequireBearer:
98
91
  against the token's granted scopes), so the gate only requires a valid token.
99
92
  """
100
93
 
101
- def __init__(self, app: object) -> None:
94
+ def __init__(self, app: ASGIApp) -> None:
102
95
  self.app = app
103
96
 
104
97
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
@@ -123,7 +116,7 @@ class RequireBearer:
123
116
  await self.app(scope, receive, send)
124
117
 
125
118
  async def _preflight(self, send: Send) -> None:
126
- headers = [(k.lower().encode(), v.encode()) for k, v in _CORS_HEADERS.items()]
119
+ headers = [(k.lower().encode(), v.encode()) for k, v in CORS_HEADERS.items()]
127
120
  await send({"type": "http.response.start", "status": 204, "headers": headers})
128
121
  await send({"type": "http.response.body", "body": b""})
129
122
 
@@ -137,7 +130,7 @@ def _has_authorization_header(scope: Scope) -> bool:
137
130
 
138
131
  async def protected_resource_metadata_endpoint(request: Request) -> JSONResponse:
139
132
  metadata = await protected_resource_metadata()
140
- return JSONResponse(metadata, headers=_CORS_HEADERS)
133
+ return JSONResponse(metadata, headers=CORS_HEADERS)
141
134
 
142
135
 
143
136
  async def health_endpoint(request: Request) -> PlainTextResponse:
@@ -147,7 +140,7 @@ async def health_endpoint(request: Request) -> PlainTextResponse:
147
140
  def build_app() -> Starlette:
148
141
  telemetry.init_telemetry("http", SERVER_VERSION)
149
142
  tools_manager = build_catalog_tools_manager()
150
- operator = build_operator(tools_manager)
143
+ operator = build_operator(tools_manager, store_results=False)
151
144
  server = build_mcp_server(operator, transport="http")
152
145
 
153
146
  # Streamable HTTP with SSE responses (the MCP SDK/ecosystem default). Stateless,