Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Source lives in `src/mcp_server_appwrite/`:
| `instructions.py` | `serverInfo.description` and the server instructions sent on initialize, rendered from `assets/instructions.json` (a description plus ordered, transport/docs-conditional Markdown sections). Edit the JSON to change the wording; keep the `pyproject.toml` description in sync (a test enforces it). |
| `context.py` | `appwrite_get_context` — workspace summary (project, services, account/org for OAuth, a focused project's environments). |
| `headers.py` | Per-call request headers: `X-Appwrite-Environment` (a project environment, next to the project's ID) and `X-Appwrite-Reason` (why a write is made, validated and percent-encoded as Appwrite expects). |
| `redaction.py` | Redacts the response fields Appwrite's spec marks `format: password` (carried on Console SDK model fields) from tool results, leaving a fingerprint, unless a call sets `reveal_secrets`. |
| `docs_search.py` | In-process semantic docs search (`appwrite_search_docs`) over a prebuilt index. |
| `docs_source.py` | Fetches the published docs (`/docs/llms.txt` + per-page `.md` exports on appwrite.io) and chunks them for the index build. |
| `docs_index.py` | Writes the index artifact deterministically and atomically: page and chunk hashes, reuse of cached chunk vectors, build lock, and the added/changed/removed page report. |
Expand Down
Binary file modified docs/appwrite-mcp-flow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/appwrite-mcp-flow.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/self-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Running your own Appwrite instance? Run the MCP server locally over `stdio` and
authenticate with a project API key instead of OAuth.

The local API-key catalog is intentionally smaller than the hosted OAuth catalog:
it exposes 689 project-key-compatible methods across 27 services. DocumentsDB,
it exposes 720 project-key-compatible methods across 27 services. DocumentsDB,
VectorsDB, and text embeddings are available, but console control-plane services
such as organizations, domains, projects, billing, migrations, dedicated
databases, usage administration, VCS administration, and WAF administration are
Expand Down
16 changes: 12 additions & 4 deletions docs/tool-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ flowchart LR

subgraph CAT[Internal catalog — authentication-aware]
direction LR
K[OAuth: 41 services / 1029 tools<br/>API key: 27 services / 689 tools]
K[OAuth: 41 services / 1065 tools<br/>API key: 27 services / 720 tools]
end

CT -->|large output| R[(MCP resource<br/>preview + URI)]
Expand All @@ -45,6 +45,14 @@ flowchart LR

- **Large outputs** are stored as an MCP resource and returned as preview text
plus a resource URI.
- **Secrets** are redacted from results by default: every response field
Appwrite's API spec marks `format: password` (API key and token secrets,
webhook signing keys, password hashes, OAuth client secrets, MFA recovery
codes, database passwords). A redacted string becomes `[redacted]` with a
sibling `<field>Fingerprint` (a SHA-256 prefix) to tell secrets apart or check
a stored copy, and a note names the redacted fields. `reveal_secrets=true` on
`appwrite_call_tool` or `appwrite_call_write_tool` returns them in full, for
when the user asks to see a secret.
- **Reads and writes** go through separate tools so each tool's annotations
match every call it accepts: `appwrite_call_tool` is read-only and
idempotent, `appwrite_call_write_tool` is destructive. Each search result
Expand Down Expand Up @@ -72,11 +80,11 @@ flowchart LR
`context=organization`, or `context=project`. Hosted calls enforce the required
top-level `organization_id` or `project_id` before making a request.
- **Access** is still gated per-route by the scopes granted to the OAuth token.
- **Hosted OAuth** registers all 41 services and 1029 methods shipped by
`appwrite-console` 0.12.0. This adds console control-plane services including
- **Hosted OAuth** registers all 41 services and 1065 methods shipped by
`appwrite-console` 0.16.0. This adds console control-plane services including
projects, organizations, domains, migrations, dedicated databases, usage, VCS,
vectors, WAF, notifications, and regions.
- **API-key stdio** deliberately registers only the 689 project-key-compatible
- **API-key stdio** deliberately registers only the 720 project-key-compatible
methods across 27 services. It includes the new DocumentsDB, VectorsDB, and
text-embeddings APIs, while console administration methods remain hidden.
- **Registration** remains SDK-driven, with the authentication profile policy
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"anyio>=4.0.0",
"appwrite-console>=0.12.0,<0.13",
"appwrite-console>=0.16.0,<0.17",
"docstring-parser>=0.16",
"mcp[cli]>=2.3,<3",
"python-dotenv>=1.0.1",
Expand Down
5 changes: 3 additions & 2 deletions src/mcp_server_appwrite/apps/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,8 +497,9 @@ def _row(row: dict[str, Any]) -> dict[str, Any]:


def _json(content: list[Any]) -> Any:
text = "\n".join(
item.text for item in content if isinstance(item, types.TextContent)
# The result is the first text item; a note on redacted secrets may follow.
text = next(
(item.text for item in content if isinstance(item, types.TextContent)), ""
)
try:
return json.loads(text)
Expand Down
22 changes: 19 additions & 3 deletions src/mcp_server_appwrite/operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@
from .tool_manager import ToolManager

ToolContent = types.TextContent | types.ImageContent | types.EmbeddedResource
# (tool_name, arguments, project_id, organization_id, environment_id, reason)
# -> content
# (tool_name, arguments, project_id, organization_id, environment_id, reason,
# reveal) -> content
ToolExecutor = Callable[
[str, dict[str, Any], str | None, str | None, str | None, str | None],
[str, dict[str, Any], str | None, str | None, str | None, str | None, bool],
list[ToolContent],
]
ContextProvider = Callable[[dict[str, Any]], dict[str, Any]]
Expand Down Expand Up @@ -668,6 +668,9 @@ def _run_hidden_tool(
f"Tool {tool_name} requires organization_id. Use {CONTEXT_TOOL_NAME} "
"to select an organization, then retry with that organization ID."
)
reveal = bool(
raw_arguments.get("reveal_secrets", raw_arguments.get("revealSecrets"))
)
arguments_object = _normalize_arguments(raw_arguments)
return self._execute_tool(
tool_name,
Expand All @@ -676,6 +679,7 @@ def _run_hidden_tool(
organization_id,
environment_id or None,
reason,
reveal,
)

def _preview_or_store_result(
Expand Down Expand Up @@ -812,6 +816,16 @@ def _call_input_schema(*, write: bool) -> dict[str, Any]:
"log and shown in the Activity tab next to the change."
),
}
properties["reveal_secrets"] = {
"type": "boolean",
"description": (
"Return secrets in full: API key and token secrets, signing keys, "
"client secrets, password hashes and other fields Appwrite marks "
"secret. Off by default, so they are redacted and replaced with a "
"fingerprint. Set true only when the user explicitly asks to see a "
"secret in this conversation."
),
}
properties |= {
"project_id": {
"type": "string",
Expand Down Expand Up @@ -1225,6 +1239,8 @@ def _normalize_arguments(raw_arguments: dict[str, Any]) -> dict[str, Any]:
"environment_id",
"environmentId",
"reason",
"reveal_secrets",
"revealSecrets",
}:
continue
if value is not None:
Expand Down
141 changes: 141 additions & 0 deletions src/mcp_server_appwrite/redaction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Keeps secrets out of tool results.

A tool result goes into the model's context and from there into transcripts,
subagent logs and caches, so a secret Appwrite returns (an API key created or
updated, a webhook's signing key, a user's password hash) would leak the moment
the call returns. Appwrite's API spec marks every such response field with
``format: password``, and the Console SDK carries that on each model field, so
the result is redacted from the SDK model before it is serialized, unless the
call sets ``reveal_secrets``.

A redacted value is replaced with ``[redacted]``. A string also gets a sibling
``<field>Fingerprint``, a short SHA-256 of the value, so the caller can still
tell which secret it got and check a stored copy against it. Empty values are
kept: they only say a secret is unset, or that Appwrite already hid it.

A payload the SDK could not hydrate into its model has no field types to go
by, so it is redacted by name: every field name some SDK model marks secret.
Rows from a ``select`` query are not redacted at all: they are the caller's own
data, not Appwrite's secrets, and a column may share a secret field's name.
"""

import hashlib
from functools import cache
from typing import Any

import appwrite_console.models
from pydantic import BaseModel
from pydantic.fields import FieldInfo

REDACTED = "[redacted]"

SECRET_FORMAT = "password"
"""The OpenAPI format Appwrite's spec marks secret response fields with."""


def redact(result: Any) -> tuple[Any, list[str]]:
"""The result as JSON-ready data with every secret field redacted, and the
names of the fields that were, sorted."""
fields: set[str] = set()
return _clean(result, fields), sorted(fields)


def redact_payload(payload: Any) -> tuple[Any, list[str]]:
"""A raw response payload with every field named like a secret field
redacted, and the names that were, sorted."""
fields: set[str] = set()
return _clean_payload(payload, fields), sorted(fields)


def fingerprint(value: str) -> str:
"""A short SHA-256 of a secret: enough to tell secrets apart, not to recover one."""
return "sha256:" + hashlib.sha256(value.encode()).hexdigest()[:12]


def note(fields: list[str]) -> str:
"""Tells the model what was redacted and how to see it if the user asks."""
return (
f"Secrets redacted from this result: {', '.join(fields)}. Each redacted "
"string has a <field>Fingerprint (a SHA-256 prefix) to compare against a "
"stored copy. Only if the user explicitly asks to see a secret in this "
"conversation, read it again with reveal_secrets=true (do not repeat a "
"create, which would make a new secret)."
)


def _clean(value: Any, fields: set[str]) -> Any:
if isinstance(value, BaseModel):
return _clean_model(value, fields)
if isinstance(value, (list, tuple)):
return [_clean(item, fields) for item in value]
if isinstance(value, dict):
return {key: _clean(item, fields) for key, item in value.items()}
return value


def _clean_model(model: BaseModel, fields: set[str]) -> dict[str, Any]:
data = model.model_dump(by_alias=True, mode="json", exclude_unset=True)
for name, field in type(model).model_fields.items():
key = field.alias or name
if key not in data:
continue
if _secret(field):
_mask(data, key, fields)
continue
item = getattr(model, name)
if _holds_model(item):
data[key] = _clean(item, fields)
return data


def _clean_payload(value: Any, fields: set[str]) -> Any:
if isinstance(value, list):
return [_clean_payload(item, fields) for item in value]
if not isinstance(value, dict):
return value
data = dict(value)
for key, item in value.items():
if key in _secret_names():
_mask(data, key, fields)
else:
data[key] = _clean_payload(item, fields)
return data


def _mask(data: dict[str, Any], key: str, fields: set[str]) -> None:
"""Replace a secret value in place. Empty values stay: they only say the
secret is unset, or that Appwrite already hid it."""
value = data[key]
if value in ("", [], None):
return
fields.add(key)
if isinstance(value, str):
data[f"{key}Fingerprint"] = fingerprint(value)
data[key] = REDACTED


@cache
def _secret_names() -> frozenset[str]:
"""Every field name an SDK model marks secret."""
names: set[str] = set()
for model in vars(appwrite_console.models).values():
if isinstance(model, type) and issubclass(model, BaseModel):
for name, field in model.model_fields.items():
if _secret(field):
names.add(field.alias or name)
return frozenset(names)


def _secret(field: FieldInfo) -> bool:
extra = field.json_schema_extra
return isinstance(extra, dict) and extra.get("format") == SECRET_FORMAT


def _holds_model(value: Any) -> bool:
if isinstance(value, BaseModel):
return True
if isinstance(value, (list, tuple)):
return any(_holds_model(item) for item in value)
if isinstance(value, dict):
return any(_holds_model(item) for item in value.values())
return False
35 changes: 28 additions & 7 deletions src/mcp_server_appwrite/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
from .headers import environment, scoped, set_environment
from .instructions import build_instructions, load_description
from .operator import Operator, ToolOutput, _parse_tool_name
from .redaction import note, redact, redact_payload
from .service import Service
from .tool_manager import ToolManager

Expand Down Expand Up @@ -1108,6 +1109,7 @@ def execute_registered_tool(
organization_id: str | None = None,
environment_id: str | None = None,
reason: str | None = None,
reveal: bool = False,
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
tool_info = tools_manager.get_tool(name)
if not tool_info:
Expand Down Expand Up @@ -1188,7 +1190,9 @@ def execute_registered_tool(
organization_id=organization_id,
environment_id=environment_id,
)
raise RuntimeError(_format_appwrite_error(exc, tool_name=name)) from exc
raise RuntimeError(
_format_appwrite_error(exc, tool_name=name, reveal=reveal)
) from exc
except Exception as exc:
error_monitoring.capture_exception(
exc,
Expand All @@ -1214,7 +1218,14 @@ def execute_registered_tool(
)
raise

return _format_tool_result(name, result, prepared_arguments)
if reveal:
return _format_tool_result(name, result, prepared_arguments)
# Secrets stay out of the model's context unless the call asks for them.
result, redacted = redact(result)
Comment thread
ChiragAgg5k marked this conversation as resolved.
Resolved
content = _format_tool_result(name, result, prepared_arguments)
if redacted:
content.append(types.TextContent(type="text", text=note(redacted)))
return content


def _json_default(value: Any) -> Any:
Expand Down Expand Up @@ -1306,9 +1317,13 @@ def _format_tool_result(
return [types.TextContent(type="text", text=str(result))]


def _format_appwrite_error(exc: AppwriteException, tool_name: str | None = None) -> str:
def _format_appwrite_error(
exc: AppwriteException, tool_name: str | None = None, reveal: bool = False
) -> str:
if is_response_parse_error(exc):
return _format_response_parse_error(tool_name, _parse_error_response(exc))
return _format_response_parse_error(
tool_name, _parse_error_response(exc), reveal
)

details = []
if getattr(exc, "code", None):
Expand All @@ -1331,7 +1346,9 @@ def _parse_error_response(exc: BaseException) -> Any:
return None


def _format_response_parse_error(tool_name: str | None, response: Any = None) -> str:
def _format_response_parse_error(
tool_name: str | None, response: Any = None, reveal: bool = False
) -> str:
"""Explain an SDK deserialization failure without implying the write failed.

``Service._parse_response`` raises this after Appwrite has already accepted
Expand All @@ -1340,11 +1357,14 @@ def _format_response_parse_error(tool_name: str | None, response: Any = None) ->
completed mutation.
"""
if response is not None:
return (
# Untyped, so secrets are redacted by name rather than by model.
response, redacted = (response, []) if reveal else redact_payload(response)
message = (
"Appwrite accepted the request and it succeeded; the SDK could not "
"deserialize the response into its model, so the raw payload "
f"follows. Do not retry.\n{_serialize_result(response)}"
)
return f"{message}\n{note(redacted)}" if redacted else message

verify = (
f" Verify with the matching get or list tool for {tool_name} before retrying."
Expand Down Expand Up @@ -1823,7 +1843,7 @@ def build_operator(

return Operator(
tools_manager,
lambda tool_name, tool_arguments, target_project=None, organization_id=None, environment_id=None, reason=None: (
lambda tool_name, tool_arguments, target_project=None, organization_id=None, environment_id=None, reason=None, reveal=False: (
execute_registered_tool(
tools_manager,
tool_name,
Expand All @@ -1833,6 +1853,7 @@ def build_operator(
organization_id=organization_id,
environment_id=environment_id,
reason=reason,
reveal=reveal,
)
),
context_provider=lambda arguments: _get_context_for_request(arguments, client),
Expand Down
Loading
Loading