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.
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.github/workflows/ci.yml +3 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/AGENTS.md +10 -3
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/PKG-INFO +1 -1
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/pyproject.toml +9 -1
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/auth.py +78 -38
- mcp_server_appwrite-0.8.2/src/mcp_server_appwrite/constants.py +144 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/context.py +2 -45
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/docs_search.py +24 -18
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/http_app.py +6 -13
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/operator.py +21 -14
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/server.py +45 -52
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/service.py +2 -2
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/telemetry.py +4 -4
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/tool_manager.py +6 -6
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_auth.py +122 -22
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_docs_search.py +2 -2
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_operator.py +51 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_server.py +4 -2
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/uv.lock +25 -1
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.dockerignore +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.env.example +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.github/workflows/production.yml +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.github/workflows/publish.yml +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.github/workflows/staging.yml +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.gitignore +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/.python-version +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/CLAUDE.md +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/Dockerfile +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/LICENSE +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/README.md +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/compose.yaml +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/authentication.md +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/development.md +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/documentation-search.md +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/self-hosted.md +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/docs/tool-surface.md +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/scripts/build_docs_index.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/server.json +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/__init__.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/__main__.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/data/docs_index.npz +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/data/docs_index_meta.json +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/support.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_avatars.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_functions.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_locale.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_messaging.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_oauth_discovery.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_operator.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_sites.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_storage.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_tables_db.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_teams.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/integration/test_users.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/test_all.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_context.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_http_app.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_service.py +0 -0
- {mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/tests/unit/test_telemetry.py +0 -0
|
@@ -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. **
|
|
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
|
-
|
|
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
|
-
|
|
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
|
[project]
|
|
2
2
|
name = "mcp-server-appwrite"
|
|
3
|
-
version = "0.8.
|
|
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
|
|
31
|
-
DEFAULT_PROJECT_ID
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 =
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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 =
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
"""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
the
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
192
|
+
"""RFC 9728 Protected Resource Metadata, with scopes validated against AS
|
|
193
|
+
discovery."""
|
|
149
194
|
metadata = await authorization_server_metadata()
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
13
|
+
from .constants import REDACTED_KEYS, SERVICE_PROBES
|
|
19
14
|
|
|
20
|
-
|
|
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(
|
{mcp_server_appwrite-0.8.0 → mcp_server_appwrite-0.8.2}/src/mcp_server_appwrite/docs_search.py
RENAMED
|
@@ -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,
|
|
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 =
|
|
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",
|
|
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=
|
|
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":
|
|
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) <
|
|
151
|
+
if len(query) < DOCS_MIN_QUERY_LENGTH:
|
|
152
152
|
raise ValueError(
|
|
153
|
-
f"query must be at least {
|
|
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:
|
|
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
|
|
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=
|
|
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,
|