From eaa8cebbd450e632a71234aebc3a1ca4ec53e525 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 17:27:00 +1200 Subject: [PATCH 01/31] Make local cargo builds reproducible --- .cargo/config.toml | 5 +++++ .cargo/protoc-wrapper.sh | 11 +++++++++++ .gitignore | 1 + rust-toolchain.toml | 2 ++ 4 files changed, 19 insertions(+) create mode 100755 .cargo/protoc-wrapper.sh create mode 100644 rust-toolchain.toml diff --git a/.cargo/config.toml b/.cargo/config.toml index 753ee7dd2..4808933e8 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -14,3 +14,8 @@ [env] TS_RS_EXPORT_DIR = { value = "./crates/assets/js/bindings", relative = true } +LIBCLANG_PATH = { value = "./.dev-tools/libclang-18/usr/lib/llvm-18/lib", relative = true } +CLANG_PATH = { value = "./.dev-tools/libclang-18/usr/bin/clang-18", relative = true } +PKG_CONFIG_PATH = { value = "./.dev-tools/geos/usr/lib/x86_64-linux-gnu/pkgconfig", relative = true } +PKG_CONFIG_SYSROOT_DIR = { value = "./.dev-tools/geos", relative = true } +PROTOC = { value = "./.cargo/protoc-wrapper.sh", relative = true } diff --git a/.cargo/protoc-wrapper.sh b/.cargo/protoc-wrapper.sh new file mode 100755 index 000000000..49652bdb4 --- /dev/null +++ b/.cargo/protoc-wrapper.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd) +PROTOBUF_DIR="${REPO_ROOT}/.dev-tools/protobuf" + +export LD_LIBRARY_PATH="${PROTOBUF_DIR}/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" +exec "${PROTOBUF_DIR}/usr/bin/protoc" \ + -I"${PROTOBUF_DIR}/usr/include" \ + "$@" diff --git a/.gitignore b/.gitignore index f7c55395a..d9d7e3696 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ target/ node_modules/ dist/ +.dev-tools/ # Dart workspace artifacts .dart_tool diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..5d56faf9a --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" From bd1e4dc28d9a0620d6338c1a6a5014c839256b34 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 17:31:16 +1200 Subject: [PATCH 02/31] Add FastMCP TrailBase sidecar --- .gitignore | 4 + README.md | 15 +++ docker-compose.yml | 18 +++ mcp/Dockerfile | 13 +++ mcp/README.md | 62 ++++++++++ mcp/pyproject.toml | 26 +++++ mcp/src/trailbase_mcp/__init__.py | 6 + mcp/src/trailbase_mcp/client.py | 186 ++++++++++++++++++++++++++++++ mcp/src/trailbase_mcp/server.py | 129 +++++++++++++++++++++ mcp/tests/test_client.py | 39 +++++++ 10 files changed, 498 insertions(+) create mode 100644 mcp/Dockerfile create mode 100644 mcp/README.md create mode 100644 mcp/pyproject.toml create mode 100644 mcp/src/trailbase_mcp/__init__.py create mode 100644 mcp/src/trailbase_mcp/client.py create mode 100644 mcp/src/trailbase_mcp/server.py create mode 100644 mcp/tests/test_client.py diff --git a/.gitignore b/.gitignore index d9d7e3696..f6e6f85de 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ target/ node_modules/ dist/ .dev-tools/ +.venv*/ +.python-*/ +__pycache__/ +.pytest_cache/ # Dart workspace artifacts .dart_tool diff --git a/README.md b/README.md index f23eadeb9..4dbca963e 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,21 @@ trail components add trailbase/auth_ui endpoints, e.g. [http://localhost:4000/\_/auth/login](http://localhost:4000/_/auth/login). +## MCP sidecar + +This fork includes a FastMCP sidecar in [`mcp/`](mcp/) that exposes TrailBase's +admin and record APIs as MCP tools. It can run over stdio for local MCP clients +or as an HTTP sidecar in Docker Compose. + +```sh +# Start TrailBase plus the MCP sidecar at http://localhost:8000/mcp. +TRAILBASE_AUTH_TOKEN=your-admin-token docker compose --profile mcp up --build +``` + +Write-capable tools are disabled by default. Set +`TRAILBASE_MCP_ENABLE_WRITES=true` for the MCP process to allow create, update, +delete, or mutating SQL tools. + ## Building If you have all the necessary build dependencies (Rust, node.js, geos, diff --git a/docker-compose.yml b/docker-compose.yml index 5ab74cfce..465991803 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,3 +15,21 @@ services: environment: RUST_BACKTRACE: "1" # command: "/app/trail --data-dir /app/traildepot run --address 0.0.0.0:4000" + + mcp: + profiles: + - mcp + build: + context: ./mcp + depends_on: + - trail + ports: + - "${MCP_PORT:-8000}:8000" + restart: unless-stopped + environment: + TRAILBASE_URL: "http://trail:4000" + TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN:-}" + TRAILBASE_MCP_ENABLE_WRITES: "${TRAILBASE_MCP_ENABLE_WRITES:-false}" + MCP_TRANSPORT: "http" + MCP_HOST: "0.0.0.0" + MCP_PORT: "8000" diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 000000000..974be3d49 --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src ./src + +RUN pip install --no-cache-dir . + +EXPOSE 8000 +CMD ["python", "-m", "trailbase_mcp.server"] diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..a413f7be9 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,62 @@ +# TrailBase MCP sidecar + +This package exposes TrailBase through a FastMCP server. It talks to TrailBase +over HTTP and uses the existing admin and record APIs. + +## Configuration + +Environment variables: + +- `TRAILBASE_URL`: TrailBase base URL. Defaults to `http://localhost:4000`. +- `TRAILBASE_AUTH_TOKEN` or `TRAILBASE_TOKEN`: bearer token used for admin and + protected record APIs. +- `TRAILBASE_MCP_ENABLE_WRITES`: set to `true` to enable create/update/delete + tools and mutating SQL. +- `MCP_TRANSPORT`: `stdio` by default; set to `http` for remote MCP. +- `MCP_HOST`: HTTP bind host, default `127.0.0.1`. +- `MCP_PORT`: HTTP bind port, default `8000`. + +Mint an admin bearer token with TrailBase: + +```sh +cargo run --bin trail -- --data-dir ./traildepot user mint-token admin@localhost +``` + +## Run with stdio + +```sh +cd mcp +python -m venv .venv +. .venv/bin/activate +pip install -e . +TRAILBASE_URL=http://localhost:4000 \ +TRAILBASE_AUTH_TOKEN='Bearer token without the Bearer prefix' \ +python -m trailbase_mcp.server +``` + +Example MCP client config: + +```json +{ + "mcpServers": { + "trailbase": { + "command": "python", + "args": ["-m", "trailbase_mcp.server"], + "env": { + "TRAILBASE_URL": "http://localhost:4000", + "TRAILBASE_AUTH_TOKEN": "your-token" + } + } + } +} +``` + +## Run with Docker Compose + +The root `docker-compose.yml` includes an opt-in `mcp` profile: + +```sh +TRAILBASE_AUTH_TOKEN=your-token docker compose --profile mcp up --build +``` + +The MCP HTTP endpoint is exposed at `http://localhost:8000/mcp`. diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml new file mode 100644 index 000000000..e6b571c19 --- /dev/null +++ b/mcp/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "trailbase-mcp" +version = "0.1.0" +description = "FastMCP sidecar server for TrailBase" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastmcp>=3.4.4", + "httpx>=0.28.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.4.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/trailbase_mcp"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/mcp/src/trailbase_mcp/__init__.py b/mcp/src/trailbase_mcp/__init__.py new file mode 100644 index 000000000..32bbb2942 --- /dev/null +++ b/mcp/src/trailbase_mcp/__init__.py @@ -0,0 +1,6 @@ +"""FastMCP integration for TrailBase.""" + +from .client import TrailBaseClient +from .server import mcp + +__all__ = ["TrailBaseClient", "mcp"] diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py new file mode 100644 index 000000000..2df77ba30 --- /dev/null +++ b/mcp/src/trailbase_mcp/client.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +import httpx + + +TRUE_VALUES = {"1", "true", "yes", "on"} +READONLY_SQL_STARTERS = {"select", "with", "pragma", "explain"} + + +def env_flag(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in TRUE_VALUES + + +def quote_segment(value: str) -> str: + return quote(value, safe="") + + +def _strip_leading_sql_comments(statement: str) -> str: + sql = statement.strip() + while True: + if sql.startswith("--"): + _, _, rest = sql.partition("\n") + sql = rest.strip() + continue + if sql.startswith("/*"): + _, sep, rest = sql.partition("*/") + if not sep: + return "" + sql = rest.strip() + continue + return sql + + +def is_readonly_sql(query: str) -> bool: + statements = [ + _strip_leading_sql_comments(stmt) + for stmt in query.split(";") + if _strip_leading_sql_comments(stmt) + ] + if not statements: + return False + + for statement in statements: + match = re.match(r"([A-Za-z_]+)", statement) + if not match or match.group(1).lower() not in READONLY_SQL_STARTERS: + return False + + return True + + +@dataclass(slots=True) +class TrailBaseClient: + base_url: str + auth_token: str | None = None + timeout: float = 30.0 + transport: httpx.BaseTransport | None = None + + @classmethod + def from_env(cls) -> "TrailBaseClient": + return cls( + base_url=os.getenv("TRAILBASE_URL", "http://localhost:4000"), + auth_token=os.getenv("TRAILBASE_AUTH_TOKEN") or os.getenv("TRAILBASE_TOKEN"), + timeout=float(os.getenv("TRAILBASE_MCP_TIMEOUT", "30")), + ) + + def _headers(self) -> dict[str, str]: + headers = { + "accept": "application/json", + "content-type": "application/json", + } + if self.auth_token: + headers["authorization"] = f"Bearer {self.auth_token}" + return headers + + def request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any | None = None, + ) -> Any: + base_url = self.base_url.rstrip("/") + path = path if path.startswith("/") else f"/{path}" + + with httpx.Client( + base_url=base_url, + headers=self._headers(), + timeout=self.timeout, + transport=self.transport, + ) as client: + response = client.request(method, path, params=params, json=json) + + if response.is_error: + body = response.text.strip() + raise RuntimeError( + f"TrailBase {method.upper()} {path} failed with " + f"HTTP {response.status_code}: {body}" + ) + + if response.status_code == 204 or not response.content: + return {"ok": True, "status_code": response.status_code} + + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + return response.json() + + return { + "ok": True, + "status_code": response.status_code, + "body": response.text, + } + + def admin_info(self) -> Any: + return self.request("GET", "/api/_admin/info") + + def admin_config(self) -> Any: + return self.request("GET", "/api/_admin/config") + + def list_tables(self) -> Any: + return self.request("GET", "/api/_admin/tables") + + def execute_sql(self, query: str, attached_databases: list[str] | None = None) -> Any: + payload: dict[str, Any] = {"query": query} + if attached_databases: + payload["attached_databases"] = attached_databases + return self.request("POST", "/api/_admin/query", json=payload) + + def api_json_schema(self, api_name: str) -> Any: + return self.request( + "GET", + f"/api/_admin/schema/{quote_segment(api_name)}/schema.json", + ) + + def list_records( + self, + api_name: str, + query: dict[str, Any] | None = None, + ) -> Any: + return self.request( + "GET", + f"/api/records/v1/{quote_segment(api_name)}", + params=query, + ) + + def get_record( + self, + api_name: str, + record_id: str, + expand: str | None = None, + ) -> Any: + params = {"expand": expand} if expand else None + return self.request( + "GET", + f"/api/records/v1/{quote_segment(api_name)}/{quote_segment(record_id)}", + params=params, + ) + + def create_record(self, api_name: str, record: dict[str, Any] | list[dict[str, Any]]) -> Any: + return self.request( + "POST", + f"/api/records/v1/{quote_segment(api_name)}", + json=record, + ) + + def update_record(self, api_name: str, record_id: str, record: dict[str, Any]) -> Any: + return self.request( + "PATCH", + f"/api/records/v1/{quote_segment(api_name)}/{quote_segment(record_id)}", + json=record, + ) + + def delete_record(self, api_name: str, record_id: str) -> Any: + return self.request( + "DELETE", + f"/api/records/v1/{quote_segment(api_name)}/{quote_segment(record_id)}", + ) diff --git a/mcp/src/trailbase_mcp/server.py b/mcp/src/trailbase_mcp/server.py new file mode 100644 index 000000000..fda549c95 --- /dev/null +++ b/mcp/src/trailbase_mcp/server.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import os +from typing import Any + +from fastmcp import FastMCP + +from .client import TrailBaseClient, env_flag, is_readonly_sql + + +mcp = FastMCP("TrailBase") + + +def _client() -> TrailBaseClient: + return TrailBaseClient.from_env() + + +def _require_writes_enabled() -> None: + if not env_flag("TRAILBASE_MCP_ENABLE_WRITES"): + raise RuntimeError( + "Write operations are disabled. Set TRAILBASE_MCP_ENABLE_WRITES=true " + "for this MCP server process to enable mutating tools." + ) + + +@mcp.tool +def trailbase_info() -> Any: + """Return TrailBase server build/runtime metadata.""" + return _client().admin_info() + + +@mcp.tool +def trailbase_config() -> Any: + """Return TrailBase configuration, including configured record APIs.""" + return _client().admin_config() + + +@mcp.tool +def list_record_apis() -> Any: + """List configured TrailBase record APIs from the server config.""" + config = _client().admin_config() + return config.get("record_apis", []) + + +@mcp.tool +def list_tables() -> Any: + """List TrailBase tables, views, indexes, and triggers.""" + return _client().list_tables() + + +@mcp.tool +def get_api_json_schema(api_name: str) -> Any: + """Return the JSON Schema for a configured TrailBase record API.""" + return _client().api_json_schema(api_name) + + +@mcp.tool +def execute_sql( + query: str, + attached_databases: list[str] | None = None, + allow_mutation: bool = False, +) -> Any: + """Execute SQL through TrailBase's admin query endpoint. + + By default this accepts only read-oriented statements. Mutations require both + allow_mutation=True and TRAILBASE_MCP_ENABLE_WRITES=true. + """ + if not allow_mutation and not is_readonly_sql(query): + raise RuntimeError( + "Only SELECT/WITH/PRAGMA/EXPLAIN statements are allowed by default. " + "Set allow_mutation=True and TRAILBASE_MCP_ENABLE_WRITES=true to run mutations." + ) + if allow_mutation: + _require_writes_enabled() + + return _client().execute_sql(query, attached_databases) + + +@mcp.tool +def list_records(api_name: str, query: dict[str, Any] | None = None) -> Any: + """List records for a TrailBase record API. + + The optional query object is passed as URL query parameters, e.g. + {"limit": 20, "count": true}. + """ + return _client().list_records(api_name, query) + + +@mcp.tool +def get_record(api_name: str, record_id: str, expand: str | None = None) -> Any: + """Fetch one record from a TrailBase record API.""" + return _client().get_record(api_name, record_id, expand) + + +@mcp.tool +def create_record(api_name: str, record: dict[str, Any] | list[dict[str, Any]]) -> Any: + """Create one or more records. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" + _require_writes_enabled() + return _client().create_record(api_name, record) + + +@mcp.tool +def update_record(api_name: str, record_id: str, record: dict[str, Any]) -> Any: + """Update one record. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" + _require_writes_enabled() + return _client().update_record(api_name, record_id, record) + + +@mcp.tool +def delete_record(api_name: str, record_id: str) -> Any: + """Delete one record. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" + _require_writes_enabled() + return _client().delete_record(api_name, record_id) + + +def main() -> None: + transport = os.getenv("MCP_TRANSPORT", "stdio") + if transport == "http": + mcp.run( + transport="http", + host=os.getenv("MCP_HOST", "127.0.0.1"), + port=int(os.getenv("MCP_PORT", "8000")), + ) + else: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py new file mode 100644 index 000000000..30bdfdddf --- /dev/null +++ b/mcp/tests/test_client.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import httpx +import pytest + +from trailbase_mcp.client import TrailBaseClient, is_readonly_sql + + +def test_readonly_sql_detection() -> None: + assert is_readonly_sql("select * from users") + assert is_readonly_sql("-- comment\nWITH x AS (select 1) select * from x") + assert is_readonly_sql("/* comment */ pragma table_info(users)") + assert not is_readonly_sql("insert into users values (1)") + assert not is_readonly_sql("select 1; delete from users") + + +def test_client_sends_bearer_token_and_quotes_path_segments() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["authorization"] == "Bearer test-token" + assert request.url.raw_path == b"/api/records/v1/chat%20messages/id%2F1" + return httpx.Response(200, json={"id": "id/1"}) + + client = TrailBaseClient( + base_url="http://trailbase.test", + auth_token="test-token", + transport=httpx.MockTransport(handler), + ) + + assert client.get_record("chat messages", "id/1") == {"id": "id/1"} + + +def test_client_raises_with_response_body() -> None: + client = TrailBaseClient( + base_url="http://trailbase.test", + transport=httpx.MockTransport(lambda _request: httpx.Response(401, text="nope")), + ) + + with pytest.raises(RuntimeError, match="HTTP 401: nope"): + client.admin_info() From 60527eaccbd7cb9eecc84fdf4ab1a6963512008c Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 17:32:46 +1200 Subject: [PATCH 03/31] Document local build dependency bootstrap --- README.md | 9 ++++ scripts/bootstrap-local-dev-tools.sh | 63 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100755 scripts/bootstrap-local-dev-tools.sh diff --git a/README.md b/README.md index 4dbca963e..4a4e92f33 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,15 @@ pnpm install cargo build --bin trail ``` +On Debian/Ubuntu-style systems without sudo access to install build packages, +this fork can populate the repo-local `.dev-tools/` cache used by +`.cargo/config.toml`: + +```sh +scripts/bootstrap-local-dev-tools.sh +cargo check --workspace --all-targets +``` + Alternatively, if you want to build a Docker image or don't want to deal with build dependencies, you can simply run: diff --git a/scripts/bootstrap-local-dev-tools.sh b/scripts/bootstrap-local-dev-tools.sh new file mode 100755 index 000000000..a49ce1952 --- /dev/null +++ b/scripts/bootstrap-local-dev-tools.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +DEV_TOOLS_DIR="${ROOT_DIR}/.dev-tools" +DEB_CACHE_DIR="${DEV_TOOLS_DIR}/debs" + +if ! command -v apt-get >/dev/null 2>&1 || ! command -v dpkg-deb >/dev/null 2>&1; then + echo "This bootstrap script currently supports Debian/Ubuntu-style systems with apt-get and dpkg-deb." >&2 + exit 1 +fi + +mkdir -p "${DEB_CACHE_DIR}" + +download_and_extract() { + local target_dir="$1" + shift + + mkdir -p "${target_dir}" + ( + cd "${DEB_CACHE_DIR}" + apt-get download "$@" + for package in "$@"; do + for deb in "${package}"_*.deb; do + dpkg-deb -x "${deb}" "${target_dir}" + done + done + ) +} + +download_and_extract \ + "${DEV_TOOLS_DIR}/libclang-18" \ + libclang-18-dev \ + libclang1-18 \ + libclang-common-18-dev \ + clang-18 + +download_and_extract \ + "${DEV_TOOLS_DIR}/geos" \ + libgeos-dev \ + libgeos-c1t64 + +download_and_extract \ + "${DEV_TOOLS_DIR}/protobuf" \ + protobuf-compiler \ + libprotobuf32t64 \ + libprotoc32t64 \ + libprotobuf-dev + +if command -v corepack >/dev/null 2>&1; then + corepack enable pnpm +fi + +if command -v pnpm >/dev/null 2>&1; then + ( + cd "${ROOT_DIR}" + pnpm install --prefer-frozen-lockfile + ) +else + echo "pnpm not found. Install pnpm or enable it through corepack before running cargo." >&2 +fi + +echo "Local dev tools bootstrapped in ${DEV_TOOLS_DIR}." From 92a1f2b96fe1ab9059ae82a26bb677afe26dd1df Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 17:50:33 +1200 Subject: [PATCH 04/31] Fix MCP admin CSRF handling --- mcp/README.md | 4 +++- mcp/src/trailbase_mcp/client.py | 24 ++++++++++++++++++++++++ mcp/tests/test_client.py | 19 ++++++++++++++++++- scripts/bootstrap-local-dev-tools.sh | 3 ++- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index a413f7be9..6102f2072 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -9,7 +9,9 @@ Environment variables: - `TRAILBASE_URL`: TrailBase base URL. Defaults to `http://localhost:4000`. - `TRAILBASE_AUTH_TOKEN` or `TRAILBASE_TOKEN`: bearer token used for admin and - protected record APIs. + protected record APIs. Pass the raw JWT without the `Bearer ` prefix. +- `TRAILBASE_CSRF_TOKEN`: optional explicit CSRF token. If omitted, the sidecar + derives it from TrailBase JWTs for admin API calls. - `TRAILBASE_MCP_ENABLE_WRITES`: set to `true` to enable create/update/delete tools and mutating SQL. - `MCP_TRANSPORT`: `stdio` by default; set to `http` for remote MCP. diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py index 2df77ba30..f34547c4a 100644 --- a/mcp/src/trailbase_mcp/client.py +++ b/mcp/src/trailbase_mcp/client.py @@ -2,6 +2,8 @@ import os import re +import base64 +import json from dataclasses import dataclass from typing import Any from urllib.parse import quote @@ -24,6 +26,25 @@ def quote_segment(value: str) -> str: return quote(value, safe="") +def csrf_token_from_jwt(token: str | None) -> str | None: + if not token: + return None + + parts = token.split(".") + if len(parts) != 3: + return None + + payload = parts[1] + payload += "=" * (-len(payload) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (ValueError, TypeError): + return None + + csrf_token = claims.get("csrf_token") + return csrf_token if isinstance(csrf_token, str) and csrf_token else None + + def _strip_leading_sql_comments(statement: str) -> str: sql = statement.strip() while True: @@ -79,6 +100,9 @@ def _headers(self) -> dict[str, str]: } if self.auth_token: headers["authorization"] = f"Bearer {self.auth_token}" + csrf_token = os.getenv("TRAILBASE_CSRF_TOKEN") or csrf_token_from_jwt(self.auth_token) + if csrf_token: + headers["csrf-token"] = csrf_token return headers def request( diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index 30bdfdddf..a769fa3a8 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -3,7 +3,7 @@ import httpx import pytest -from trailbase_mcp.client import TrailBaseClient, is_readonly_sql +from trailbase_mcp.client import TrailBaseClient, csrf_token_from_jwt, is_readonly_sql def test_readonly_sql_detection() -> None: @@ -29,6 +29,23 @@ def handler(request: httpx.Request) -> httpx.Response: assert client.get_record("chat messages", "id/1") == {"id": "id/1"} +def test_client_derives_csrf_header_from_jwt() -> None: + token = "header.eyJjc3JmX3Rva2VuIjoiY3NyZi0xMjMifQ.signature" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["csrf-token"] == "csrf-123" + return httpx.Response(200, json={"ok": True}) + + assert csrf_token_from_jwt(token) == "csrf-123" + client = TrailBaseClient( + base_url="http://trailbase.test", + auth_token=token, + transport=httpx.MockTransport(handler), + ) + + assert client.admin_info() == {"ok": True} + + def test_client_raises_with_response_body() -> None: client = TrailBaseClient( base_url="http://trailbase.test", diff --git a/scripts/bootstrap-local-dev-tools.sh b/scripts/bootstrap-local-dev-tools.sh index a49ce1952..30df278c0 100755 --- a/scripts/bootstrap-local-dev-tools.sh +++ b/scripts/bootstrap-local-dev-tools.sh @@ -38,7 +38,8 @@ download_and_extract \ download_and_extract \ "${DEV_TOOLS_DIR}/geos" \ libgeos-dev \ - libgeos-c1t64 + libgeos-c1t64 \ + libgeos3.12.1t64 download_and_extract \ "${DEV_TOOLS_DIR}/protobuf" \ From 143bcc01c7d2d70cf717132199ce63dcd58d1289 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 18:09:14 +1200 Subject: [PATCH 05/31] Support TrailBase config updates in MCP --- mcp/pyproject.toml | 1 + mcp/src/trailbase_mcp/client.py | 58 ++++++++++++++- mcp/src/trailbase_mcp/proto/__init__.py | 0 mcp/src/trailbase_mcp/proto/config_api_pb2.py | 28 ++++++++ mcp/src/trailbase_mcp/proto/config_pb2.py | 71 +++++++++++++++++++ mcp/src/trailbase_mcp/server.py | 13 +++- mcp/tests/test_client.py | 43 +++++++++++ 7 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 mcp/src/trailbase_mcp/proto/__init__.py create mode 100644 mcp/src/trailbase_mcp/proto/config_api_pb2.py create mode 100644 mcp/src/trailbase_mcp/proto/config_pb2.py diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index e6b571c19..125d93c52 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.11" dependencies = [ "fastmcp>=3.4.4", "httpx>=0.28.1", + "protobuf>=5.29.0", ] [project.optional-dependencies] diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py index f34547c4a..439dfb4ed 100644 --- a/mcp/src/trailbase_mcp/client.py +++ b/mcp/src/trailbase_mcp/client.py @@ -9,6 +9,9 @@ from urllib.parse import quote import httpx +from google.protobuf.json_format import MessageToDict, ParseDict + +from .proto import config_api_pb2 TRUE_VALUES = {"1", "true", "yes", "on"} @@ -137,6 +140,10 @@ def request( content_type = response.headers.get("content-type", "") if "application/json" in content_type: return response.json() + try: + return response.json() + except ValueError: + pass return { "ok": True, @@ -144,11 +151,60 @@ def request( "body": response.text, } + def request_bytes( + self, + method: str, + path: str, + *, + body: bytes | None = None, + ) -> bytes: + base_url = self.base_url.rstrip("/") + path = path if path.startswith("/") else f"/{path}" + + headers = self._headers() + headers["content-type"] = "application/protobuf" + headers["accept"] = "application/protobuf" + + with httpx.Client( + base_url=base_url, + headers=headers, + timeout=self.timeout, + transport=self.transport, + ) as client: + response = client.request(method, path, content=body) + + if response.is_error: + body_text = response.text.strip() + raise RuntimeError( + f"TrailBase {method.upper()} {path} failed with " + f"HTTP {response.status_code}: {body_text}" + ) + + return response.content + def admin_info(self) -> Any: return self.request("GET", "/api/_admin/info") def admin_config(self) -> Any: - return self.request("GET", "/api/_admin/config") + response = config_api_pb2.GetConfigResponse() + response.ParseFromString(self.request_bytes("GET", "/api/_admin/config")) + return MessageToDict( + response, + preserving_proto_field_name=True, + use_integers_for_enums=True, + ) + + def update_config(self, config: dict[str, Any], hash: str) -> Any: + request = ParseDict( + {"config": config, "hash": hash}, + config_api_pb2.UpdateConfigRequest(), + ) + self.request_bytes( + "POST", + "/api/_admin/config", + body=request.SerializeToString(), + ) + return {"ok": True} def list_tables(self) -> Any: return self.request("GET", "/api/_admin/tables") diff --git a/mcp/src/trailbase_mcp/proto/__init__.py b/mcp/src/trailbase_mcp/proto/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mcp/src/trailbase_mcp/proto/config_api_pb2.py b/mcp/src/trailbase_mcp/proto/config_api_pb2.py new file mode 100644 index 000000000..5ff7e45a4 --- /dev/null +++ b/mcp/src/trailbase_mcp/proto/config_api_pb2.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: config_api.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from . import config_pb2 as config__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10\x63onfig_api.proto\x12\x06\x63onfig\x1a\x0c\x63onfig.proto\"A\n\x11GetConfigResponse\x12\x1e\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x0e.config.Config\x12\x0c\n\x04hash\x18\x02 \x01(\t\"C\n\x13UpdateConfigRequest\x12\x1e\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x0e.config.Config\x12\x0c\n\x04hash\x18\x02 \x01(\t') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'config_api_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _GETCONFIGRESPONSE._serialized_start=42 + _GETCONFIGRESPONSE._serialized_end=107 + _UPDATECONFIGREQUEST._serialized_start=109 + _UPDATECONFIGREQUEST._serialized_end=176 +# @@protoc_insertion_point(module_scope) diff --git a/mcp/src/trailbase_mcp/proto/config_pb2.py b/mcp/src/trailbase_mcp/proto/config_pb2.py new file mode 100644 index 000000000..85eb641dd --- /dev/null +++ b/mcp/src/trailbase_mcp/proto/config_pb2.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: config.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63onfig.proto\x12\x06\x63onfig\x1a google/protobuf/descriptor.proto\".\n\rEmailTemplate\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\"\x9b\x03\n\x0b\x45mailConfig\x12\x11\n\tsmtp_host\x18\x01 \x01(\t\x12\x11\n\tsmtp_port\x18\x02 \x01(\r\x12\x15\n\rsmtp_username\x18\x03 \x01(\t\x12\x1b\n\rsmtp_password\x18\x04 \x01(\tB\x04\x80\xb5\x18\x01\x12/\n\x0fsmtp_encryption\x18\x05 \x01(\x0e\x32\x16.config.SmtpEncryption\x12\x13\n\x0bsender_name\x18\x0b \x01(\t\x12\x16\n\x0esender_address\x18\x0c \x01(\t\x12\x39\n\x1auser_verification_template\x18\x15 \x01(\x0b\x32\x15.config.EmailTemplate\x12\x36\n\x17password_reset_template\x18\x16 \x01(\x0b\x32\x15.config.EmailTemplate\x12\x34\n\x15\x63hange_email_template\x18\x17 \x01(\x0b\x32\x15.config.EmailTemplate\x12+\n\x0cotp_template\x18\x18 \x01(\x0b\x32\x15.config.EmailTemplate\"\xc4\x01\n\x13OAuthProviderConfig\x12\x11\n\tclient_id\x18\x01 \x01(\t\x12\x1b\n\rclient_secret\x18\x02 \x01(\tB\x04\x80\xb5\x18\x01\x12,\n\x0bprovider_id\x18\x03 \x01(\x0e\x32\x17.config.OAuthProviderId\x12\x14\n\x0c\x64isplay_name\x18\x0b \x01(\t\x12\x10\n\x08\x61uth_url\x18\x0c \x01(\t\x12\x11\n\ttoken_url\x18\r \x01(\t\x12\x14\n\x0cuser_api_url\x18\x0e \x01(\t\"\xfa\x04\n\nAuthConfig\x12\x1a\n\x12\x61uth_token_ttl_sec\x18\x01 \x01(\x03\x12\x1d\n\x15refresh_token_ttl_sec\x18\x02 \x01(\x03\x12\'\n\x1f\x61nonymous_refresh_token_ttl_sec\x18\t \x01(\x03\x12\x1d\n\x15\x64isable_password_auth\x18\x03 \x01(\x08\x12\x19\n\x11\x65nable_otp_signin\x18\x08 \x01(\x08\x12\x1f\n\x17\x65nable_anonymous_signin\x18\x0c \x01(\x08\x12\x1f\n\x17password_minimal_length\x18\x04 \x01(\r\x12\x32\n*password_must_contain_upper_and_lower_case\x18\x05 \x01(\x08\x12$\n\x1cpassword_must_contain_digits\x18\x06 \x01(\x08\x12\x30\n(password_must_contain_special_characters\x18\x07 \x01(\x08\x12?\n\x0foauth_providers\x18\x0b \x03(\x0b\x32&.config.AuthConfig.OauthProvidersEntry\x12\x1a\n\x12\x63ustom_uri_schemes\x18\x15 \x03(\t\x12\x1e\n\x16redirect_uri_allowlist\x18\x16 \x03(\t\x12/\n\x0fuser_identifier\x18\x1f \x01(\x0e\x32\x16.config.UserIdentifier\x1aR\n\x13OauthProvidersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0b\x32\x1b.config.OAuthProviderConfig:\x02\x38\x01\"}\n\x0fS3StorageConfig\x12\x10\n\x08\x65ndpoint\x18\x01 \x01(\t\x12\x0e\n\x06region\x18\x02 \x01(\t\x12\x13\n\x0b\x62ucket_name\x18\x05 \x01(\t\x12\x12\n\naccess_key\x18\x08 \x01(\t\x12\x1f\n\x11secret_access_key\x18\t \x01(\tB\x04\x80\xb5\x18\x01\"\x88\x02\n\x0cServerConfig\x12\x18\n\x10\x61pplication_name\x18\x01 \x01(\t\x12\x10\n\x08site_url\x18\x02 \x01(\t\x12\x1a\n\x12logs_retention_sec\x18\x0b \x01(\x03\x12\x32\n\x11s3_storage_config\x18\r \x01(\x0b\x32\x17.config.S3StorageConfig\x12\"\n\x1a\x65nable_record_transactions\x18\x0e \x01(\x08\x12 \n\x18request_size_limit_bytes\x18\x0f \x01(\x04\x12\x1a\n\x12\x61uth_ip_rate_limit\x18\x10 \x01(\r\x12\x1a\n\x12\x62\x61\x63kup_window_size\x18\x11 \x01(\x04\"P\n\tSystemJob\x12\x1f\n\x02id\x18\x01 \x01(\x0e\x32\x13.config.SystemJobId\x12\x10\n\x08schedule\x18\x02 \x01(\t\x12\x10\n\x08\x64isabled\x18\x03 \x01(\x08\"4\n\nJobsConfig\x12&\n\x0bsystem_jobs\x18\x01 \x03(\x0b\x32\x11.config.SystemJob\"\x86\x04\n\x0fRecordApiConfig\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntable_name\x18\x02 \x01(\t\x12\x1a\n\x12\x61ttached_databases\x18\x03 \x03(\t\x12?\n\x13\x63onflict_resolution\x18\x05 \x01(\x0e\x32\".config.ConflictResolutionStrategy\x12(\n autofill_missing_user_id_columns\x18\x06 \x01(\x08\x12\x1c\n\x14\x65nable_subscriptions\x18\t \x01(\x08\x12)\n\tacl_world\x18\x07 \x03(\x0e\x32\x16.config.PermissionFlag\x12\x31\n\x11\x61\x63l_authenticated\x18\x08 \x03(\x0e\x32\x16.config.PermissionFlag\x12\x18\n\x10\x65xcluded_columns\x18\n \x03(\t\x12\x1a\n\x12\x63reate_access_rule\x18\x0b \x01(\t\x12\x18\n\x10read_access_rule\x18\x0c \x01(\t\x12\x1a\n\x12update_access_rule\x18\r \x01(\t\x12\x1a\n\x12\x64\x65lete_access_rule\x18\x0e \x01(\t\x12\x1a\n\x12schema_access_rule\x18\x0f \x01(\t\x12\x0e\n\x06\x65xpand\x18\x15 \x03(\t\x12\x1a\n\x12listing_hard_limit\x18\x16 \x01(\x04\"0\n\x10JsonSchemaConfig\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06schema\x18\x02 \x01(\t\"\x1e\n\x0e\x44\x61tabaseConfig\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x9a\x02\n\x06\x43onfig\x12\"\n\x05\x65mail\x18\x02 \x02(\x0b\x32\x13.config.EmailConfig\x12$\n\x06server\x18\x03 \x02(\x0b\x32\x14.config.ServerConfig\x12 \n\x04\x61uth\x18\x04 \x02(\x0b\x32\x12.config.AuthConfig\x12 \n\x04jobs\x18\x05 \x02(\x0b\x32\x12.config.JobsConfig\x12)\n\tdatabases\x18\x08 \x03(\x0b\x32\x16.config.DatabaseConfig\x12,\n\x0brecord_apis\x18\x0b \x03(\x0b\x32\x17.config.RecordApiConfig\x12)\n\x07schemas\x18\x15 \x03(\x0b\x32\x18.config.JsonSchemaConfig*\x80\x01\n\x0eSmtpEncryption\x12\x1d\n\x19SMTP_ENCRYPTION_UNDEFINED\x10\x00\x12\x18\n\x14SMTP_ENCRYPTION_NONE\x10\x01\x12\x1c\n\x18SMTP_ENCRYPTION_STARTTLS\x10\x02\x12\x17\n\x13SMTP_ENCRYPTION_TLS\x10\x03*\xb8\x01\n\x0fOAuthProviderId\x12\x1f\n\x1bOAUTH_PROVIDER_ID_UNDEFINED\x10\x00\x12\x08\n\x04TEST\x10\x01\x12\t\n\x05OIDC0\x10\x02\x12\t\n\x05\x41PPLE\x10\t\x12\x0b\n\x07\x44ISCORD\x10\n\x12\n\n\x06GITLAB\x10\x0b\x12\n\n\x06GOOGLE\x10\x0c\x12\x0c\n\x08\x46\x41\x43\x45\x42OOK\x10\r\x12\r\n\tMICROSOFT\x10\x0e\x12\n\n\x06TWITCH\x10\x0f\x12\n\n\x06YANDEX\x10\x10\x12\n\n\x06GITHUB\x10\x11*\x9b\x01\n\x0eUserIdentifier\x12\x1d\n\x19USER_IDENTIFIER_UNDEFINED\x10\x00\x12\x0e\n\nONLY_EMAIL\x10\x01\x12\x11\n\rONLY_USERNAME\x10\x02\x12\x11\n\rREQUIRE_EMAIL\x10\x03\x12\x14\n\x10REQUIRE_USERNAME\x10\x04\x12\x1e\n\x1aREQUIRE_EMAIL_AND_USERNAME\x10\x05*\xa8\x01\n\x0bSystemJobId\x12\x1b\n\x17SYSTEM_JOB_ID_UNDEFINED\x10\x00\x12\n\n\x06\x42\x41\x43KUP\x10\x01\x12\r\n\tHEARTBEAT\x10\x02\x12\x0f\n\x0bLOG_CLEANER\x10\x03\x12\x10\n\x0c\x41UTH_CLEANER\x10\x04\x12\x13\n\x0fQUERY_OPTIMIZER\x10\x05\x12\x12\n\x0e\x46ILE_DELETIONS\x10\x06\x12\x15\n\x11\x41NONYMOUS_CLEANER\x10\x07*l\n\x1a\x43onflictResolutionStrategy\x12*\n&CONFLICT_RESOLUTION_STRATEGY_UNDEFINED\x10\x00\x12\t\n\x05\x41\x42ORT\x10\x01\x12\n\n\x06IGNORE\x10\x04\x12\x0b\n\x07REPLACE\x10\x05*i\n\x0ePermissionFlag\x12\x1d\n\x19PERMISSION_FLAG_UNDEFINED\x10\x00\x12\n\n\x06\x43REATE\x10\x01\x12\x08\n\x04READ\x10\x02\x12\n\n\x06UPDATE\x10\x04\x12\n\n\x06\x44\x45LETE\x10\x08\x12\n\n\x06SCHEMA\x10\x10:/\n\x06secret\x12\x1d.google.protobuf.FieldOptions\x18\xd0\x86\x03 \x01(\x08') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'config_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension(secret) + + DESCRIPTOR._options = None + _EMAILCONFIG.fields_by_name['smtp_password']._options = None + _EMAILCONFIG.fields_by_name['smtp_password']._serialized_options = b'\200\265\030\001' + _OAUTHPROVIDERCONFIG.fields_by_name['client_secret']._options = None + _OAUTHPROVIDERCONFIG.fields_by_name['client_secret']._serialized_options = b'\200\265\030\001' + _AUTHCONFIG_OAUTHPROVIDERSENTRY._options = None + _AUTHCONFIG_OAUTHPROVIDERSENTRY._serialized_options = b'8\001' + _S3STORAGECONFIG.fields_by_name['secret_access_key']._options = None + _S3STORAGECONFIG.fields_by_name['secret_access_key']._serialized_options = b'\200\265\030\001' + _SMTPENCRYPTION._serialized_start=2775 + _SMTPENCRYPTION._serialized_end=2903 + _OAUTHPROVIDERID._serialized_start=2906 + _OAUTHPROVIDERID._serialized_end=3090 + _USERIDENTIFIER._serialized_start=3093 + _USERIDENTIFIER._serialized_end=3248 + _SYSTEMJOBID._serialized_start=3251 + _SYSTEMJOBID._serialized_end=3419 + _CONFLICTRESOLUTIONSTRATEGY._serialized_start=3421 + _CONFLICTRESOLUTIONSTRATEGY._serialized_end=3529 + _PERMISSIONFLAG._serialized_start=3531 + _PERMISSIONFLAG._serialized_end=3636 + _EMAILTEMPLATE._serialized_start=58 + _EMAILTEMPLATE._serialized_end=104 + _EMAILCONFIG._serialized_start=107 + _EMAILCONFIG._serialized_end=518 + _OAUTHPROVIDERCONFIG._serialized_start=521 + _OAUTHPROVIDERCONFIG._serialized_end=717 + _AUTHCONFIG._serialized_start=720 + _AUTHCONFIG._serialized_end=1354 + _AUTHCONFIG_OAUTHPROVIDERSENTRY._serialized_start=1272 + _AUTHCONFIG_OAUTHPROVIDERSENTRY._serialized_end=1354 + _S3STORAGECONFIG._serialized_start=1356 + _S3STORAGECONFIG._serialized_end=1481 + _SERVERCONFIG._serialized_start=1484 + _SERVERCONFIG._serialized_end=1748 + _SYSTEMJOB._serialized_start=1750 + _SYSTEMJOB._serialized_end=1830 + _JOBSCONFIG._serialized_start=1832 + _JOBSCONFIG._serialized_end=1884 + _RECORDAPICONFIG._serialized_start=1887 + _RECORDAPICONFIG._serialized_end=2405 + _JSONSCHEMACONFIG._serialized_start=2407 + _JSONSCHEMACONFIG._serialized_end=2455 + _DATABASECONFIG._serialized_start=2457 + _DATABASECONFIG._serialized_end=2487 + _CONFIG._serialized_start=2490 + _CONFIG._serialized_end=2772 +# @@protoc_insertion_point(module_scope) diff --git a/mcp/src/trailbase_mcp/server.py b/mcp/src/trailbase_mcp/server.py index fda549c95..c86c00e6d 100644 --- a/mcp/src/trailbase_mcp/server.py +++ b/mcp/src/trailbase_mcp/server.py @@ -31,15 +31,22 @@ def trailbase_info() -> Any: @mcp.tool def trailbase_config() -> Any: - """Return TrailBase configuration, including configured record APIs.""" + """Return TrailBase configuration plus the config hash required for updates.""" return _client().admin_config() +@mcp.tool +def update_config(config: dict[str, Any], hash: str) -> Any: + """Replace TrailBase configuration. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" + _require_writes_enabled() + return _client().update_config(config, hash) + + @mcp.tool def list_record_apis() -> Any: """List configured TrailBase record APIs from the server config.""" - config = _client().admin_config() - return config.get("record_apis", []) + response = _client().admin_config() + return {"record_apis": response.get("config", {}).get("record_apis", [])} @mcp.tool diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index a769fa3a8..4ed329431 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -4,6 +4,7 @@ import pytest from trailbase_mcp.client import TrailBaseClient, csrf_token_from_jwt, is_readonly_sql +from trailbase_mcp.proto import config_api_pb2 def test_readonly_sql_detection() -> None: @@ -54,3 +55,45 @@ def test_client_raises_with_response_body() -> None: with pytest.raises(RuntimeError, match="HTTP 401: nope"): client.admin_info() + + +def test_client_decodes_and_updates_protobuf_config() -> None: + config_response = config_api_pb2.GetConfigResponse() + config_response.hash = "hash-1" + config_response.config.email.smtp_host = "localhost" + config_response.config.server.application_name = "TrailBase" + config_response.config.auth.password_minimal_length = 8 + config_response.config.jobs.SetInParent() + + seen_update = None + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal seen_update + if request.method == "GET": + return httpx.Response(200, content=config_response.SerializeToString()) + + update = config_api_pb2.UpdateConfigRequest() + update.ParseFromString(request.content) + seen_update = update + return httpx.Response(200, content=b"") + + client = TrailBaseClient( + base_url="http://trailbase.test", + transport=httpx.MockTransport(handler), + ) + + decoded = client.admin_config() + assert decoded["hash"] == "hash-1" + assert decoded["config"]["server"]["application_name"] == "TrailBase" + + decoded["config"].setdefault("record_apis", []).append( + { + "name": "widgets", + "table_name": "widgets", + "acl_world": [1, 2, 4, 8, 16], + } + ) + assert client.update_config(decoded["config"], decoded["hash"]) == {"ok": True} + assert seen_update is not None + assert seen_update.hash == "hash-1" + assert seen_update.config.record_apis[0].name == "widgets" From 2516543962e04dd1a921709aade838bc82d5e323 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 18:33:39 +1200 Subject: [PATCH 06/31] Add MCP schema modes and file uploads --- mcp/README.md | 17 +++ mcp/src/trailbase_mcp/client.py | 238 +++++++++++++++++++++++++++++++- mcp/src/trailbase_mcp/server.py | 59 +++++++- mcp/tests/test_client.py | 86 +++++++++++- 4 files changed, 392 insertions(+), 8 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 6102f2072..ea701c826 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -62,3 +62,20 @@ TRAILBASE_AUTH_TOKEN=your-token docker compose --profile mcp up --build ``` The MCP HTTP endpoint is exposed at `http://localhost:8000/mcp`. + +## Record API file and schema tools + +The sidecar exposes TrailBase Record API schemas and file helpers in addition +to normal CRUD: + +- `get_api_json_schema(api_name, mode?, admin?)`: read a schema from + `/api/records/v1//schema`. `mode` may be `Insert`, `Select`, or + `Update`. Set `admin=true` to use the admin schema endpoint. +- `create_record_with_file_uploads(api_name, record, files)`: create a record + using JSON/base64 file upload inputs. Each file needs `field` plus either + `content_base64` or `file_path`; optional fields are `filename`, + `content_type`, and `multiple`. +- `create_record_multipart(api_name, fields, files)`: create a record as + `multipart/form-data` using the same file descriptors. +- `download_file(api_name, record_id, column_name, file_name?)`: download a + `std.FileUpload` or `std.FileUploads` file and return `content_base64`. diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py index 439dfb4ed..87249507c 100644 --- a/mcp/src/trailbase_mcp/client.py +++ b/mcp/src/trailbase_mcp/client.py @@ -3,8 +3,10 @@ import os import re import base64 +import binascii import json from dataclasses import dataclass +from pathlib import Path from typing import Any from urllib.parse import quote @@ -29,6 +31,51 @@ def quote_segment(value: str) -> str: return quote(value, safe="") +def base64_file_contents(file: dict[str, Any]) -> str: + content_base64 = file.get("content_base64") or file.get("data") + file_path = file.get("file_path") or file.get("path") + + if content_base64 is not None and file_path is not None: + raise ValueError("Provide either content_base64/data or file_path/path, not both") + if content_base64 is not None: + if not isinstance(content_base64, str): + raise ValueError("content_base64/data must be a string") + return content_base64 + if file_path is not None: + path = Path(file_path) + return base64.urlsafe_b64encode(path.read_bytes()).decode() + + raise ValueError("File upload requires content_base64/data or file_path/path") + + +def decode_base64_contents(value: str) -> bytes: + padded = value + "=" * (-len(value) % 4) + try: + return base64.urlsafe_b64decode(padded) + except (ValueError, binascii.Error): + return base64.b64decode(padded) + + +def file_upload_input(file: dict[str, Any]) -> dict[str, Any]: + field = file.get("field") or file.get("field_name") or file.get("name") + if not isinstance(field, str) or not field: + raise ValueError("File upload requires field/field_name/name") + + filename = file.get("filename") + if filename is None and (file.get("file_path") or file.get("path")): + filename = Path(file.get("file_path") or file.get("path")).name + + upload: dict[str, Any] = { + "name": field, + "data": base64_file_contents(file), + } + if filename is not None: + upload["filename"] = filename + if file.get("content_type") is not None: + upload["content_type"] = file["content_type"] + return upload + + def csrf_token_from_jwt(token: str | None) -> str | None: if not token: return None @@ -151,6 +198,75 @@ def request( "body": response.text, } + def request_raw( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + base_url = self.base_url.rstrip("/") + path = path if path.startswith("/") else f"/{path}" + headers = self._headers() + headers["accept"] = "*/*" + headers.pop("content-type", None) + + with httpx.Client( + base_url=base_url, + headers=headers, + timeout=self.timeout, + transport=self.transport, + ) as client: + response = client.request(method, path, params=params) + + if response.is_error: + body = response.text.strip() + raise RuntimeError( + f"TrailBase {method.upper()} {path} failed with " + f"HTTP {response.status_code}: {body}" + ) + + return response + + def request_multipart( + self, + method: str, + path: str, + *, + data: dict[str, str], + files: list[tuple[str, tuple[str, bytes, str | None]]], + ) -> Any: + base_url = self.base_url.rstrip("/") + path = path if path.startswith("/") else f"/{path}" + headers = self._headers() + headers.pop("content-type", None) + + with httpx.Client( + base_url=base_url, + headers=headers, + timeout=self.timeout, + transport=self.transport, + ) as client: + response = client.request(method, path, data=data, files=files) + + if response.is_error: + body = response.text.strip() + raise RuntimeError( + f"TrailBase {method.upper()} {path} failed with " + f"HTTP {response.status_code}: {body}" + ) + + if response.status_code == 204 or not response.content: + return {"ok": True, "status_code": response.status_code} + try: + return response.json() + except ValueError: + return { + "ok": True, + "status_code": response.status_code, + "body": response.text, + } + def request_bytes( self, method: str, @@ -215,11 +331,19 @@ def execute_sql(self, query: str, attached_databases: list[str] | None = None) - payload["attached_databases"] = attached_databases return self.request("POST", "/api/_admin/query", json=payload) - def api_json_schema(self, api_name: str) -> Any: - return self.request( - "GET", - f"/api/_admin/schema/{quote_segment(api_name)}/schema.json", + def api_json_schema( + self, + api_name: str, + mode: str | None = None, + admin: bool = False, + ) -> Any: + params = {"mode": mode} if mode else None + path = ( + f"/api/_admin/schema/{quote_segment(api_name)}/schema.json" + if admin + else f"/api/records/v1/{quote_segment(api_name)}/schema" ) + return self.request("GET", path, params=params) def list_records( self, @@ -252,6 +376,86 @@ def create_record(self, api_name: str, record: dict[str, Any] | list[dict[str, A json=record, ) + def create_record_with_file_uploads( + self, + api_name: str, + record: dict[str, Any], + files: list[dict[str, Any]], + ) -> Any: + payload = dict(record) + for file in files: + upload = file_upload_input(file) + field = upload["name"] + upload_for_record = dict(upload) + upload_for_record.pop("name", None) + + existing = payload.get(field) + if file.get("multiple"): + if existing is None: + payload[field] = [upload_for_record] + elif isinstance(existing, list): + existing.append(upload_for_record) + else: + payload[field] = [existing, upload_for_record] + elif existing is None: + payload[field] = upload_for_record + elif isinstance(existing, list): + existing.append(upload_for_record) + else: + payload[field] = [existing, upload_for_record] + + return self.create_record(api_name, payload) + + def create_record_multipart( + self, + api_name: str, + fields: dict[str, Any], + files: list[dict[str, Any]], + ) -> Any: + data: dict[str, str] = {} + for key, value in fields.items(): + if isinstance(value, list): + data[key] = json.dumps(value) + elif value is not None: + data[key] = str(value) + + multipart_files: list[tuple[str, tuple[str, bytes, str | None]]] = [] + for file in files: + field = file.get("field") or file.get("field_name") or file.get("name") + if not isinstance(field, str) or not field: + raise ValueError("Multipart file requires field/field_name/name") + + file_path = file.get("file_path") or file.get("path") + content_base64 = file.get("content_base64") or file.get("data") + if file_path is not None and content_base64 is not None: + raise ValueError("Provide either content_base64/data or file_path/path, not both") + if file_path is not None: + bytes_data = Path(file_path).read_bytes() + filename = file.get("filename") or Path(file_path).name + elif content_base64 is not None: + bytes_data = decode_base64_contents(content_base64) + filename = file.get("filename") or field + else: + raise ValueError("Multipart file requires content_base64/data or file_path/path") + + multipart_files.append( + ( + field, + ( + str(filename), + bytes_data, + file.get("content_type"), + ), + ) + ) + + return self.request_multipart( + "POST", + f"/api/records/v1/{quote_segment(api_name)}", + data=data, + files=multipart_files, + ) + def update_record(self, api_name: str, record_id: str, record: dict[str, Any]) -> Any: return self.request( "PATCH", @@ -264,3 +468,29 @@ def delete_record(self, api_name: str, record_id: str) -> Any: "DELETE", f"/api/records/v1/{quote_segment(api_name)}/{quote_segment(record_id)}", ) + + def download_file( + self, + api_name: str, + record_id: str, + column_name: str, + file_name: str | None = None, + ) -> Any: + path = ( + f"/api/records/v1/{quote_segment(api_name)}/" + f"{quote_segment(record_id)}/" + f"{'files' if file_name else 'file'}/" + f"{quote_segment(column_name)}" + ) + if file_name: + path += f"/{quote_segment(file_name)}" + + response = self.request_raw("GET", path) + return { + "ok": True, + "status_code": response.status_code, + "content_type": response.headers.get("content-type"), + "content_disposition": response.headers.get("content-disposition"), + "content_length": len(response.content), + "content_base64": base64.b64encode(response.content).decode(), + } diff --git a/mcp/src/trailbase_mcp/server.py b/mcp/src/trailbase_mcp/server.py index c86c00e6d..4200c9599 100644 --- a/mcp/src/trailbase_mcp/server.py +++ b/mcp/src/trailbase_mcp/server.py @@ -56,9 +56,17 @@ def list_tables() -> Any: @mcp.tool -def get_api_json_schema(api_name: str) -> Any: - """Return the JSON Schema for a configured TrailBase record API.""" - return _client().api_json_schema(api_name) +def get_api_json_schema( + api_name: str, + mode: str | None = None, + admin: bool = False, +) -> Any: + """Return the JSON Schema for a configured TrailBase record API. + + mode may be Insert, Select, or Update. By default this uses the public + record schema endpoint; set admin=True to use the admin schema endpoint. + """ + return _client().api_json_schema(api_name, mode, admin) @mcp.tool @@ -106,6 +114,36 @@ def create_record(api_name: str, record: dict[str, Any] | list[dict[str, Any]]) return _client().create_record(api_name, record) +@mcp.tool +def create_record_with_file_uploads( + api_name: str, + record: dict[str, Any], + files: list[dict[str, Any]], +) -> Any: + """Create one record with JSON/base64 TrailBase file upload inputs. + + Each file requires field/field_name/name plus either content_base64/data or + file_path/path. Optional keys: filename, content_type, multiple. + """ + _require_writes_enabled() + return _client().create_record_with_file_uploads(api_name, record, files) + + +@mcp.tool +def create_record_multipart( + api_name: str, + fields: dict[str, Any], + files: list[dict[str, Any]], +) -> Any: + """Create one record as multipart/form-data with file parts. + + Each file requires field/field_name/name plus either content_base64/data or + file_path/path. Optional keys: filename, content_type. + """ + _require_writes_enabled() + return _client().create_record_multipart(api_name, fields, files) + + @mcp.tool def update_record(api_name: str, record_id: str, record: dict[str, Any]) -> Any: """Update one record. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" @@ -120,6 +158,21 @@ def delete_record(api_name: str, record_id: str) -> Any: return _client().delete_record(api_name, record_id) +@mcp.tool +def download_file( + api_name: str, + record_id: str, + column_name: str, + file_name: str | None = None, +) -> Any: + """Download a TrailBase file column and return the bytes as content_base64. + + For std.FileUpload columns omit file_name. For std.FileUploads columns pass + the metadata filename as file_name. + """ + return _client().download_file(api_name, record_id, column_name, file_name) + + def main() -> None: transport = os.getenv("MCP_TRANSPORT", "stdio") if transport == "http": diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index 4ed329431..00fc547e9 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -3,7 +3,12 @@ import httpx import pytest -from trailbase_mcp.client import TrailBaseClient, csrf_token_from_jwt, is_readonly_sql +from trailbase_mcp.client import ( + TrailBaseClient, + csrf_token_from_jwt, + file_upload_input, + is_readonly_sql, +) from trailbase_mcp.proto import config_api_pb2 @@ -57,6 +62,85 @@ def test_client_raises_with_response_body() -> None: client.admin_info() +def test_client_schema_modes_and_file_download() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/api/records/v1/widgets/schema": + assert request.url.params["mode"] == "Select" + return httpx.Response(200, json={"title": "widgets"}) + + if request.url.path == "/api/records/v1/widgets/123/file/avatar": + return httpx.Response( + 200, + content=b"hello-file", + headers={"content-type": "text/plain"}, + ) + + raise AssertionError(request.url) + + client = TrailBaseClient( + base_url="http://trailbase.test", + transport=httpx.MockTransport(handler), + ) + + assert client.api_json_schema("widgets", mode="Select") == {"title": "widgets"} + downloaded = client.download_file("widgets", "123", "avatar") + assert downloaded["content_type"] == "text/plain" + assert downloaded["content_base64"] == "aGVsbG8tZmlsZQ==" + + +def test_file_upload_input_and_json_file_create() -> None: + seen_payload = None + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal seen_payload + seen_payload = request.read() + return httpx.Response(200, json={"ids": ["1"]}) + + upload = file_upload_input( + { + "field": "avatar", + "filename": "avatar.txt", + "content_type": "text/plain", + "content_base64": "aGVsbG8=", + } + ) + assert upload == { + "name": "avatar", + "filename": "avatar.txt", + "content_type": "text/plain", + "data": "aGVsbG8=", + } + + client = TrailBaseClient( + base_url="http://trailbase.test", + transport=httpx.MockTransport(handler), + ) + + assert client.create_record_with_file_uploads( + "profiles", + {"name": "Ada"}, + [ + { + "field": "avatar", + "filename": "avatar.txt", + "content_type": "text/plain", + "content_base64": "aGVsbG8=", + }, + { + "field": "attachments", + "filename": "notes.txt", + "content_base64": "bm90ZXM=", + "multiple": True, + }, + ], + ) == {"ids": ["1"]} + assert seen_payload is not None + payload = seen_payload.decode() + assert '"name":"Ada"' in payload + assert '"avatar":{"data":"aGVsbG8="' in payload + assert '"attachments":[{"data":"bm90ZXM="' in payload + + def test_client_decodes_and_updates_protobuf_config() -> None: config_response = config_api_pb2.GetConfigResponse() config_response.hash = "hash-1" From bab7cfbec52796ec4a8286a0b6b41179c05a84c3 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 18:40:39 +1200 Subject: [PATCH 07/31] Document TrailBase and MCP endpoint URLs --- mcp/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mcp/README.md b/mcp/README.md index ea701c826..252c7d32e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -63,6 +63,16 @@ TRAILBASE_AUTH_TOKEN=your-token docker compose --profile mcp up --build The MCP HTTP endpoint is exposed at `http://localhost:8000/mcp`. +## Browser and endpoint notes + +- TrailBase's root path (`/`) may return `404`. Use the admin UI path: + `http://localhost:4000/_/admin/`. +- The MCP HTTP endpoint is not a browser UI. Opening `/mcp` directly in a + browser or plain `curl` request can return: + `Not Acceptable: Client must accept text/event-stream`. Use an MCP client, + such as FastMCP's `Client("http://localhost:8000/mcp")`, which sends the + required streaming headers. + ## Record API file and schema tools The sidecar exposes TrailBase Record API schemas and file helpers in addition From 578769bc90c32e69b8ec1fb05a800bc4775b38d1 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 20:23:08 +1200 Subject: [PATCH 08/31] Document Record API query parameters for MCP --- mcp/README.md | 5 +++++ mcp/tests/test_client.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/mcp/README.md b/mcp/README.md index 252c7d32e..543c6d831 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -78,6 +78,11 @@ The MCP HTTP endpoint is exposed at `http://localhost:8000/mcp`. The sidecar exposes TrailBase Record API schemas and file helpers in addition to normal CRUD: +- `list_records(api_name, query?)`: forwards `query` as Record API URL query + parameters. For example: + `{"geojson": "geometry", "limit": 1024, "skip_cursor": "true"}` maps to + `?geojson=geometry&limit=1024&skip_cursor=true`. Cursor pagination works the + same way with `{"cursor": ""}`. - `get_api_json_schema(api_name, mode?, admin?)`: read a schema from `/api/records/v1//schema`. `mode` may be `Insert`, `Select`, or `Update`. Set `admin=true` to use the admin schema endpoint. diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index 00fc547e9..6a1780fd6 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -35,6 +35,31 @@ def handler(request: httpx.Request) -> httpx.Response: assert client.get_record("chat messages", "id/1") == {"id": "id/1"} +def test_client_passes_record_list_query_parameters() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/records/v1/venue" + assert request.url.params["geojson"] == "geometry" + assert request.url.params["limit"] == "1024" + assert request.url.params["skip_cursor"] == "true" + assert request.url.params["cursor"] == "next-page" + return httpx.Response(200, json={"type": "FeatureCollection", "features": []}) + + client = TrailBaseClient( + base_url="http://trailbase.test", + transport=httpx.MockTransport(handler), + ) + + assert client.list_records( + "venue", + { + "geojson": "geometry", + "limit": 1024, + "skip_cursor": "true", + "cursor": "next-page", + }, + ) == {"type": "FeatureCollection", "features": []} + + def test_client_derives_csrf_header_from_jwt() -> None: token = "header.eyJjc3JmX3Rva2VuIjoiY3NyZi0xMjMifQ.signature" From 8b4a05b6ed5d04776cb4b6ae20fb48916646094f Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 20:30:32 +1200 Subject: [PATCH 09/31] Add generic TrailBase endpoint MCP tool --- mcp/README.md | 29 +++++++++++++++++++++++++ mcp/src/trailbase_mcp/client.py | 26 ++++++++++++++++++++++ mcp/src/trailbase_mcp/server.py | 22 ++++++++++++++++++- mcp/tests/test_client.py | 38 +++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index 543c6d831..cc1d8baf7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -78,6 +78,10 @@ The MCP HTTP endpoint is exposed at `http://localhost:8000/mcp`. The sidecar exposes TrailBase Record API schemas and file helpers in addition to normal CRUD: +- `trailbase_request(method, path, params?, body?)`: call any server-relative + TrailBase HTTP endpoint. Use this for auth endpoints, custom WASM APIs, and + OpenAPI endpoints not covered by specialized MCP tools. Non-readonly methods + require `TRAILBASE_MCP_ENABLE_WRITES=true`. - `list_records(api_name, query?)`: forwards `query` as Record API URL query parameters. For example: `{"geojson": "geometry", "limit": 1024, "skip_cursor": "true"}` maps to @@ -94,3 +98,28 @@ to normal CRUD: `multipart/form-data` using the same file descriptors. - `download_file(api_name, record_id, column_name, file_name?)`: download a `std.FileUpload` or `std.FileUploads` file and return `content_base64`. + +## TrailBase documentation compatibility + +The MCP sidecar intentionally delegates to TrailBase's public/admin HTTP APIs +instead of reimplementing TrailBase behavior. Current coverage: + +- Models & Relations: use `execute_sql` for STRICT tables, constraints, + indexes, triggers, views, generated columns, geometry columns, and relations; + use `update_config` to expose tables/views as Record APIs and configure + `expand`. +- Migrations: TrailBase migrations are filesystem/CLI driven + (`traildepot/migrations`, `trail migration`, restart/SIGHUP). MCP can apply + SQL through `execute_sql`, but it is not a migration runner and should not + replace append-only production migrations. +- Type-Safety: use `get_api_json_schema` with `mode` `Insert`, `Select`, or + `Update`; feed those schemas into external generators such as quicktype. +- Production: run MCP as a sidecar container and do not expose it publicly + unless it is protected like an admin surface. The `/mcp` endpoint requires an + MCP client that accepts `text/event-stream`. +- Custom APIs: use `trailbase_request` for TrailBase WASM/custom routes. +- Record APIs: CRUD, list filters/sort/pagination/cursor/geojson query params, + schema, JSON/base64 file upload, multipart upload, and file download are + supported. +- Auth: use `trailbase_request` for auth endpoints; the sidecar itself should + be configured with an admin token for admin/config operations. diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py index 87249507c..6acfb6d10 100644 --- a/mcp/src/trailbase_mcp/client.py +++ b/mcp/src/trailbase_mcp/client.py @@ -18,6 +18,7 @@ TRUE_VALUES = {"1", "true", "yes", "on"} READONLY_SQL_STARTERS = {"select", "with", "pragma", "explain"} +READONLY_HTTP_METHODS = {"GET", "HEAD", "OPTIONS"} def env_flag(name: str, default: bool = False) -> bool: @@ -31,6 +32,16 @@ def quote_segment(value: str) -> str: return quote(value, safe="") +def validate_relative_path(path: str) -> str: + if not isinstance(path, str): + raise ValueError("path must be server-relative and start with '/'") + if path.startswith("//") or "://" in path: + raise ValueError("path must not be an absolute URL") + if not path.startswith("/"): + raise ValueError("path must be server-relative and start with '/'") + return path + + def base64_file_contents(file: dict[str, Any]) -> str: content_base64 = file.get("content_base64") or file.get("data") file_path = file.get("file_path") or file.get("path") @@ -331,6 +342,21 @@ def execute_sql(self, query: str, attached_databases: list[str] | None = None) - payload["attached_databases"] = attached_databases return self.request("POST", "/api/_admin/query", json=payload) + def trailbase_request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + body: Any | None = None, + ) -> Any: + return self.request( + method.upper(), + validate_relative_path(path), + params=params, + json=body, + ) + def api_json_schema( self, api_name: str, diff --git a/mcp/src/trailbase_mcp/server.py b/mcp/src/trailbase_mcp/server.py index 4200c9599..e66e4bb81 100644 --- a/mcp/src/trailbase_mcp/server.py +++ b/mcp/src/trailbase_mcp/server.py @@ -5,7 +5,7 @@ from fastmcp import FastMCP -from .client import TrailBaseClient, env_flag, is_readonly_sql +from .client import READONLY_HTTP_METHODS, TrailBaseClient, env_flag, is_readonly_sql mcp = FastMCP("TrailBase") @@ -91,6 +91,26 @@ def execute_sql( return _client().execute_sql(query, attached_databases) +@mcp.tool +def trailbase_request( + method: str, + path: str, + params: dict[str, Any] | None = None, + body: Any | None = None, +) -> Any: + """Call an arbitrary TrailBase HTTP endpoint on the configured server. + + Use this for custom WASM APIs, auth endpoints, OpenAPI endpoints, and other + TrailBase routes not covered by a specialized MCP tool. The path must be + server-relative, e.g. /api/auth/v1/status. Non-readonly methods require + TRAILBASE_MCP_ENABLE_WRITES=true. + """ + normalized_method = method.upper() + if normalized_method not in READONLY_HTTP_METHODS: + _require_writes_enabled() + return _client().trailbase_request(normalized_method, path, params=params, body=body) + + @mcp.tool def list_records(api_name: str, query: dict[str, Any] | None = None) -> Any: """List records for a TrailBase record API. diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index 6a1780fd6..fa7350086 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -8,8 +8,10 @@ csrf_token_from_jwt, file_upload_input, is_readonly_sql, + validate_relative_path, ) from trailbase_mcp.proto import config_api_pb2 +from trailbase_mcp.server import trailbase_request def test_readonly_sql_detection() -> None: @@ -60,6 +62,42 @@ def handler(request: httpx.Request) -> httpx.Response: ) == {"type": "FeatureCollection", "features": []} +def test_client_generic_trailbase_request() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/api/custom/search" + assert request.url.params["q"] == "coffee" + assert request.read() == b'{"limit":10}' + return httpx.Response(200, json={"ok": True}) + + client = TrailBaseClient( + base_url="http://trailbase.test", + transport=httpx.MockTransport(handler), + ) + + assert client.trailbase_request( + "POST", + "/api/custom/search", + params={"q": "coffee"}, + body={"limit": 10}, + ) == {"ok": True} + + +def test_generic_trailbase_request_rejects_absolute_urls() -> None: + assert validate_relative_path("/api/auth/v1/status") == "/api/auth/v1/status" + with pytest.raises(ValueError, match="server-relative"): + validate_relative_path("api/auth/v1/status") + with pytest.raises(ValueError, match="absolute URL"): + validate_relative_path("https://example.com/api") + + +def test_server_generic_trailbase_request_write_gate(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TRAILBASE_MCP_ENABLE_WRITES", raising=False) + + with pytest.raises(RuntimeError, match="Write operations are disabled"): + trailbase_request("POST", "/api/auth/v1/login", body={}) + + def test_client_derives_csrf_header_from_jwt() -> None: token = "header.eyJjc3JmX3Rva2VuIjoiY3NyZi0xMjMifQ.signature" From ebc3a2359fd9d040b5392b09d2b0463818806b85 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 20:32:42 +1200 Subject: [PATCH 10/31] Expand MCP Docker Hub README --- mcp/README.md | 172 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 160 insertions(+), 12 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index cc1d8baf7..77c27f7f2 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -3,20 +3,106 @@ This package exposes TrailBase through a FastMCP server. It talks to TrailBase over HTTP and uses the existing admin and record APIs. +Use it as a sidecar container next to TrailBase. The MCP container does not +store TrailBase data; it forwards MCP tool calls to a running TrailBase server. + +## Features + +- TrailBase admin/runtime info. +- Admin config read/update, including Record API configuration. +- SQL execution with a default read-only guard. +- Table, view, index, and trigger introspection. +- Record API CRUD. +- Record API list query passthrough, including filters, sorting, pagination, + cursors, `geojson`, `limit`, and `skip_cursor`. +- Record API JSON schemas for `Insert`, `Select`, and `Update`. +- JSON/base64 and multipart file uploads for `std.FileUpload` and + `std.FileUploads`. +- File download as base64. +- Generic `trailbase_request` tool for custom WASM APIs, auth APIs, and other + TrailBase HTTP endpoints. + +## Quick start with Docker + +Run TrailBase separately, then run this MCP sidecar against it. + +```sh +docker run --rm -p 8000:8000 \ + -e TRAILBASE_URL=http://host.docker.internal:4000 \ + -e TRAILBASE_AUTH_TOKEN=your-admin-jwt-without-bearer-prefix \ + -e TRAILBASE_MCP_ENABLE_WRITES=false \ + -e MCP_TRANSPORT=http \ + -e MCP_HOST=0.0.0.0 \ + -e MCP_PORT=8000 \ + YOUR_DOCKERHUB_USER/trailbase-mcp:latest +``` + +The MCP endpoint is: + +```text +http://localhost:8000/mcp +``` + +Do not test `/mcp` in a browser. Use an MCP client. A browser or plain `curl` +request can return `Not Acceptable: Client must accept text/event-stream`, +which is expected for MCP over HTTP. + +## Portainer / Docker Compose stack + +Replace `YOUR_DOCKERHUB_USER/trailbase-mcp:latest` with your published image. + +```yaml +services: + trail: + image: docker.io/trailbase/trailbase:latest + ports: + - "4000:4000" + restart: unless-stopped + volumes: + - /opt/trailbase/traildepot:/app/traildepot + environment: + RUST_BACKTRACE: "1" + + mcp: + image: YOUR_DOCKERHUB_USER/trailbase-mcp:latest + depends_on: + - trail + ports: + - "8000:8000" + restart: unless-stopped + environment: + TRAILBASE_URL: "http://trail:4000" + TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN}" + TRAILBASE_MCP_ENABLE_WRITES: "false" + MCP_TRANSPORT: "http" + MCP_HOST: "0.0.0.0" + MCP_PORT: "8000" +``` + +Create the TrailBase data directory before deploying the stack: + +```sh +sudo mkdir -p /opt/trailbase/traildepot +sudo chown -R 1000:1000 /opt/trailbase/traildepot +``` + +If you see TrailBase permission errors, verify the UID used by your TrailBase +image or temporarily relax permissions to confirm the mount is the issue. + ## Configuration Environment variables: -- `TRAILBASE_URL`: TrailBase base URL. Defaults to `http://localhost:4000`. -- `TRAILBASE_AUTH_TOKEN` or `TRAILBASE_TOKEN`: bearer token used for admin and - protected record APIs. Pass the raw JWT without the `Bearer ` prefix. -- `TRAILBASE_CSRF_TOKEN`: optional explicit CSRF token. If omitted, the sidecar - derives it from TrailBase JWTs for admin API calls. -- `TRAILBASE_MCP_ENABLE_WRITES`: set to `true` to enable create/update/delete - tools and mutating SQL. -- `MCP_TRANSPORT`: `stdio` by default; set to `http` for remote MCP. -- `MCP_HOST`: HTTP bind host, default `127.0.0.1`. -- `MCP_PORT`: HTTP bind port, default `8000`. +| Variable | Default | Description | +| --- | --- | --- | +| `TRAILBASE_URL` | `http://localhost:4000` | TrailBase base URL. In Compose, use the TrailBase service name, e.g. `http://trail:4000`. | +| `TRAILBASE_AUTH_TOKEN` / `TRAILBASE_TOKEN` | unset | Admin or user JWT used for TrailBase API calls. Pass the raw JWT without the `Bearer ` prefix. | +| `TRAILBASE_CSRF_TOKEN` | derived from JWT | Optional explicit CSRF token. Normally not needed for TrailBase-minted JWTs. | +| `TRAILBASE_MCP_ENABLE_WRITES` | `false` | Set to `true` to enable create/update/delete tools, mutating SQL, config updates, and mutating generic HTTP calls. | +| `TRAILBASE_MCP_TIMEOUT` | `30` | HTTP timeout in seconds. | +| `MCP_TRANSPORT` | `stdio` | Use `http` for container/remote MCP. | +| `MCP_HOST` | `127.0.0.1` | HTTP bind host. Use `0.0.0.0` in Docker. | +| `MCP_PORT` | `8000` | HTTP bind port. | Mint an admin bearer token with TrailBase: @@ -24,6 +110,24 @@ Mint an admin bearer token with TrailBase: cargo run --bin trail -- --data-dir ./traildepot user mint-token admin@localhost ``` +Inside the official TrailBase container this is typically: + +```sh +/app/trail --data-dir /app/traildepot user mint-token admin@localhost +``` + +The command prints a value like: + +```text +Bearer eyJhbGciOi... +``` + +Set `TRAILBASE_AUTH_TOKEN` to only the JWT part: + +```text +TRAILBASE_AUTH_TOKEN=eyJhbGciOi... +``` + ## Run with stdio ```sh @@ -32,7 +136,7 @@ python -m venv .venv . .venv/bin/activate pip install -e . TRAILBASE_URL=http://localhost:4000 \ -TRAILBASE_AUTH_TOKEN='Bearer token without the Bearer prefix' \ +TRAILBASE_AUTH_TOKEN='your-token-without-the-Bearer-prefix' \ python -m trailbase_mcp.server ``` @@ -55,7 +159,8 @@ Example MCP client config: ## Run with Docker Compose -The root `docker-compose.yml` includes an opt-in `mcp` profile: +For local development from this repository, the root `docker-compose.yml` +includes an opt-in `mcp` profile: ```sh TRAILBASE_AUTH_TOKEN=your-token docker compose --profile mcp up --build @@ -99,6 +204,49 @@ to normal CRUD: - `download_file(api_name, record_id, column_name, file_name?)`: download a `std.FileUpload` or `std.FileUploads` file and return `content_base64`. +## MCP tools + +Current tools: + +- `trailbase_info` +- `trailbase_config` +- `update_config` +- `list_record_apis` +- `list_tables` +- `execute_sql` +- `trailbase_request` +- `list_records` +- `get_record` +- `create_record` +- `update_record` +- `delete_record` +- `get_api_json_schema` +- `create_record_with_file_uploads` +- `create_record_multipart` +- `download_file` + +## Security notes + +Treat this sidecar like an admin surface when configured with an admin token. + +- Do not expose `/mcp` directly to the public internet. +- Prefer private Docker networks, VPN, mTLS, or an authenticated reverse proxy. +- Keep `TRAILBASE_MCP_ENABLE_WRITES=false` unless the MCP client explicitly + needs mutation/config/SQL write access. +- Use a least-privilege TrailBase token when possible. Admin tokens are required + for admin config and SQL tools. +- `trailbase_request` only accepts server-relative paths and cannot proxy to + arbitrary external URLs. + +## Known limitations + +- Realtime subscriptions are not exposed as a long-running MCP stream in this + release. +- TrailBase migrations remain filesystem/CLI driven. MCP can run SQL, but it is + not a production migration runner. +- The sidecar does not generate language bindings itself; use + `get_api_json_schema` and an external generator such as quicktype. + ## TrailBase documentation compatibility The MCP sidecar intentionally delegates to TrailBase's public/admin HTTP APIs From 0f8dedde40467091024ce45c36e00a9d66dbaf30 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 20:46:02 +1200 Subject: [PATCH 11/31] Use published MCP Docker image in docs --- mcp/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 77c27f7f2..81f65e720 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -34,7 +34,7 @@ docker run --rm -p 8000:8000 \ -e MCP_TRANSPORT=http \ -e MCP_HOST=0.0.0.0 \ -e MCP_PORT=8000 \ - YOUR_DOCKERHUB_USER/trailbase-mcp:latest + frostbite4456/trailbase-mcp:latest ``` The MCP endpoint is: @@ -49,7 +49,8 @@ which is expected for MCP over HTTP. ## Portainer / Docker Compose stack -Replace `YOUR_DOCKERHUB_USER/trailbase-mcp:latest` with your published image. +This example uses the published Docker Hub image: +`frostbite4456/trailbase-mcp:latest`. ```yaml services: @@ -64,7 +65,7 @@ services: RUST_BACKTRACE: "1" mcp: - image: YOUR_DOCKERHUB_USER/trailbase-mcp:latest + image: frostbite4456/trailbase-mcp:latest depends_on: - trail ports: From f3baaf58287498fe3d38b783b39ddd9b5ce29a84 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 20:56:10 +1200 Subject: [PATCH 12/31] Support token files for MCP credentials --- mcp/README.md | 58 +++++++++++++++++++++++++++++++-- mcp/src/trailbase_mcp/client.py | 28 +++++++++++++++- mcp/tests/test_client.py | 22 +++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 81f65e720..b17342ccf 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -37,6 +37,20 @@ docker run --rm -p 8000:8000 \ frostbite4456/trailbase-mcp:latest ``` +Or provide the token via a mounted file: + +```sh +docker run --rm -p 8000:8000 \ + -v /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro \ + -e TRAILBASE_URL=http://host.docker.internal:4000 \ + -e TRAILBASE_AUTH_TOKEN_FILE=/run/secrets/trailbase_auth_token \ + -e TRAILBASE_MCP_ENABLE_WRITES=false \ + -e MCP_TRANSPORT=http \ + -e MCP_HOST=0.0.0.0 \ + -e MCP_PORT=8000 \ + frostbite4456/trailbase-mcp:latest +``` + The MCP endpoint is: ```text @@ -71,9 +85,11 @@ services: ports: - "8000:8000" restart: unless-stopped + volumes: + - /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro environment: TRAILBASE_URL: "http://trail:4000" - TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN}" + TRAILBASE_AUTH_TOKEN_FILE: "/run/secrets/trailbase_auth_token" TRAILBASE_MCP_ENABLE_WRITES: "false" MCP_TRANSPORT: "http" MCP_HOST: "0.0.0.0" @@ -90,6 +106,19 @@ sudo chown -R 1000:1000 /opt/trailbase/traildepot If you see TrailBase permission errors, verify the UID used by your TrailBase image or temporarily relax permissions to confirm the mount is the issue. +For Portainer, the mounted token-file approach is often easier than threading +`TRAILBASE_AUTH_TOKEN` through the stack UI. Create the file once on the Docker +host: + +```sh +sudo mkdir -p /opt/trailbase/secrets +sudo sh -c 'printf "%s" "PASTE_RAW_JWT_HERE" > /opt/trailbase/secrets/trailbase-token' +sudo chmod 600 /opt/trailbase/secrets/trailbase-token +``` + +The file may contain either the raw JWT or the full `Bearer ...` output; the +sidecar strips the `Bearer ` prefix automatically. + ## Configuration Environment variables: @@ -97,7 +126,8 @@ Environment variables: | Variable | Default | Description | | --- | --- | --- | | `TRAILBASE_URL` | `http://localhost:4000` | TrailBase base URL. In Compose, use the TrailBase service name, e.g. `http://trail:4000`. | -| `TRAILBASE_AUTH_TOKEN` / `TRAILBASE_TOKEN` | unset | Admin or user JWT used for TrailBase API calls. Pass the raw JWT without the `Bearer ` prefix. | +| `TRAILBASE_AUTH_TOKEN` / `TRAILBASE_TOKEN` | unset | Admin or user JWT used for TrailBase API calls. Raw JWT is preferred; a leading `Bearer ` prefix is also accepted. | +| `TRAILBASE_AUTH_TOKEN_FILE` / `TRAILBASE_TOKEN_FILE` | unset | Path to a file containing the JWT. Useful for Portainer, Docker secrets, and bind-mounted secret files. | | `TRAILBASE_CSRF_TOKEN` | derived from JWT | Optional explicit CSRF token. Normally not needed for TrailBase-minted JWTs. | | `TRAILBASE_MCP_ENABLE_WRITES` | `false` | Set to `true` to enable create/update/delete tools, mutating SQL, config updates, and mutating generic HTTP calls. | | `TRAILBASE_MCP_TIMEOUT` | `30` | HTTP timeout in seconds. | @@ -129,6 +159,30 @@ Set `TRAILBASE_AUTH_TOKEN` to only the JWT part: TRAILBASE_AUTH_TOKEN=eyJhbGciOi... ``` +For Docker/Portainer deployments, prefer a token file or Docker secret over +hard-coding the token in the stack when possible. + +## Credential model + +Most Dockerized MCP servers use one of these patterns: + +- local/desktop MCP clients pass credentials as environment variables in the + MCP client config; +- remote/container MCP servers receive credentials from Docker/Portainer + environment variables or secrets; +- OAuth-enabled remote MCP servers perform a separate MCP auth flow. + +This sidecar currently uses the second pattern. The MCP client, IntelliJ, or +other frontend connects to the MCP endpoint; the sidecar uses its configured +TrailBase token when it calls TrailBase. That keeps TrailBase credentials out +of individual MCP prompts and avoids requiring every MCP client to understand +TrailBase auth. + +Auto-minting a token from inside the MCP container is possible, but it requires +mounting the TrailBase data directory and shipping the `trail` binary in the +MCP image. That gives the MCP container admin-level depot access. A token file +or Docker secret is usually simpler to operate and easier to reason about. + ## Run with stdio ```sh diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py index 6acfb6d10..e300c5a0a 100644 --- a/mcp/src/trailbase_mcp/client.py +++ b/mcp/src/trailbase_mcp/client.py @@ -28,6 +28,32 @@ def env_flag(name: str, default: bool = False) -> bool: return value.strip().lower() in TRUE_VALUES +def normalize_auth_token(token: str | None) -> str | None: + if token is None: + return None + token = token.strip() + if not token: + return None + if token.lower().startswith("bearer "): + return token[7:].strip() or None + return token + + +def read_secret_file(path: str | None) -> str | None: + if not path: + return None + return normalize_auth_token(Path(path).read_text()) + + +def auth_token_from_env() -> str | None: + return normalize_auth_token( + os.getenv("TRAILBASE_AUTH_TOKEN") or os.getenv("TRAILBASE_TOKEN") + ) or read_secret_file( + os.getenv("TRAILBASE_AUTH_TOKEN_FILE") + or os.getenv("TRAILBASE_TOKEN_FILE") + ) + + def quote_segment(value: str) -> str: return quote(value, safe="") @@ -150,7 +176,7 @@ class TrailBaseClient: def from_env(cls) -> "TrailBaseClient": return cls( base_url=os.getenv("TRAILBASE_URL", "http://localhost:4000"), - auth_token=os.getenv("TRAILBASE_AUTH_TOKEN") or os.getenv("TRAILBASE_TOKEN"), + auth_token=auth_token_from_env(), timeout=float(os.getenv("TRAILBASE_MCP_TIMEOUT", "30")), ) diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index fa7350086..cd9832575 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -5,9 +5,11 @@ from trailbase_mcp.client import ( TrailBaseClient, + auth_token_from_env, csrf_token_from_jwt, file_upload_input, is_readonly_sql, + normalize_auth_token, validate_relative_path, ) from trailbase_mcp.proto import config_api_pb2 @@ -22,6 +24,26 @@ def test_readonly_sql_detection() -> None: assert not is_readonly_sql("select 1; delete from users") +def test_auth_token_env_normalization_and_file( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + token_file = tmp_path / "trailbase-token" + token_file.write_text("Bearer file-token\n") + + assert normalize_auth_token("Bearer env-token") == "env-token" + assert normalize_auth_token(" raw-token ") == "raw-token" + assert normalize_auth_token("") is None + + monkeypatch.delenv("TRAILBASE_AUTH_TOKEN", raising=False) + monkeypatch.delenv("TRAILBASE_TOKEN", raising=False) + monkeypatch.setenv("TRAILBASE_AUTH_TOKEN_FILE", str(token_file)) + assert auth_token_from_env() == "file-token" + + monkeypatch.setenv("TRAILBASE_AUTH_TOKEN", "Bearer env-token") + assert auth_token_from_env() == "env-token" + + def test_client_sends_bearer_token_and_quotes_path_segments() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.headers["authorization"] == "Bearer test-token" From 7a5a1b75cf0ffb16cf90f0ecc9763adb8b85301e Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 21:05:00 +1200 Subject: [PATCH 13/31] Document MCP token lifetime checks --- mcp/README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/mcp/README.md b/mcp/README.md index b17342ccf..2a89dadb3 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -162,6 +162,41 @@ TRAILBASE_AUTH_TOKEN=eyJhbGciOi... For Docker/Portainer deployments, prefer a token file or Docker secret over hard-coding the token in the stack when possible. +### Token lifetime and rotation + +TrailBase JWTs can expire. Do not assume a token copied from an API client or +browser login is long-lived; it may follow the dashboard's normal auth-token +TTL. + +Prefer a CLI-minted token for the MCP sidecar: + +```sh +/app/trail --data-dir /app/traildepot user mint-token admin@localhost +``` + +Then check its `exp` claim before deploying it: + +```sh +TOKEN='paste-jwt-or-bearer-output-here' python3 - <<'PY' +import base64, json, os, time + +token = os.environ["TOKEN"].strip() +if token.lower().startswith("bearer "): + token = token[7:].strip() + +payload = token.split(".")[1] +payload += "=" * (-len(payload) % 4) +claims = json.loads(base64.urlsafe_b64decode(payload)) +print(json.dumps(claims, indent=2, sort_keys=True)) +if "exp" in claims: + print("expires_in_seconds:", claims["exp"] - int(time.time())) +PY +``` + +If the token expires, mint a new token, update the token file or Portainer +environment variable, and restart/redeploy the MCP container. The sidecar reads +the token at startup. + ## Credential model Most Dockerized MCP servers use one of these patterns: From 7ca558a5c1b132cae233fca1dad40c7fb78250ae Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 21:07:28 +1200 Subject: [PATCH 14/31] Prefer Portainer token environment variable --- mcp/README.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 2a89dadb3..bc1f37e36 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -85,11 +85,9 @@ services: ports: - "8000:8000" restart: unless-stopped - volumes: - - /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro environment: TRAILBASE_URL: "http://trail:4000" - TRAILBASE_AUTH_TOKEN_FILE: "/run/secrets/trailbase_auth_token" + TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN}" TRAILBASE_MCP_ENABLE_WRITES: "false" MCP_TRANSPORT: "http" MCP_HOST: "0.0.0.0" @@ -106,9 +104,28 @@ sudo chown -R 1000:1000 /opt/trailbase/traildepot If you see TrailBase permission errors, verify the UID used by your TrailBase image or temporarily relax permissions to confirm the mount is the issue. -For Portainer, the mounted token-file approach is often easier than threading -`TRAILBASE_AUTH_TOKEN` through the stack UI. Create the file once on the Docker -host: +In Portainer, set `TRAILBASE_AUTH_TOKEN` in the stack environment variables. +The value can be either the raw JWT or the full `Bearer ...` output; the sidecar +strips the `Bearer ` prefix automatically. + +If you prefer not to store the token in the stack environment, use the optional +mounted token-file approach: + +```yaml + mcp: + image: frostbite4456/trailbase-mcp:latest + volumes: + - /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro + environment: + TRAILBASE_URL: "http://trail:4000" + TRAILBASE_AUTH_TOKEN_FILE: "/run/secrets/trailbase_auth_token" + TRAILBASE_MCP_ENABLE_WRITES: "false" + MCP_TRANSPORT: "http" + MCP_HOST: "0.0.0.0" + MCP_PORT: "8000" +``` + +Create the token file once on the Docker host: ```sh sudo mkdir -p /opt/trailbase/secrets @@ -159,8 +176,9 @@ Set `TRAILBASE_AUTH_TOKEN` to only the JWT part: TRAILBASE_AUTH_TOKEN=eyJhbGciOi... ``` -For Docker/Portainer deployments, prefer a token file or Docker secret over -hard-coding the token in the stack when possible. +For Docker/Portainer deployments, `TRAILBASE_AUTH_TOKEN` is the simplest path. +`TRAILBASE_AUTH_TOKEN_FILE` is available when you prefer a bind-mounted file or +Docker secret. ### Token lifetime and rotation From 1f145d41f65e13fefcac53180f3dcd9554233983 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 21:15:52 +1200 Subject: [PATCH 15/31] Support login credentials for MCP auth --- mcp/README.md | 90 ++++++++++++++++++-- mcp/src/trailbase_mcp/client.py | 140 +++++++++++++++++++++++++++++++- mcp/tests/test_client.py | 127 +++++++++++++++++++++++++++++ 3 files changed, 347 insertions(+), 10 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index bc1f37e36..9ed2c52f2 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -87,7 +87,10 @@ services: restart: unless-stopped environment: TRAILBASE_URL: "http://trail:4000" - TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN}" + TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN:-}" + TRAILBASE_REFRESH_TOKEN: "${TRAILBASE_REFRESH_TOKEN:-}" + TRAILBASE_LOGIN_EMAIL: "${TRAILBASE_LOGIN_EMAIL:-}" + TRAILBASE_LOGIN_PASSWORD: "${TRAILBASE_LOGIN_PASSWORD:-}" TRAILBASE_MCP_ENABLE_WRITES: "false" MCP_TRANSPORT: "http" MCP_HOST: "0.0.0.0" @@ -104,9 +107,16 @@ sudo chown -R 1000:1000 /opt/trailbase/traildepot If you see TrailBase permission errors, verify the UID used by your TrailBase image or temporarily relax permissions to confirm the mount is the issue. -In Portainer, set `TRAILBASE_AUTH_TOKEN` in the stack environment variables. -The value can be either the raw JWT or the full `Bearer ...` output; the sidecar -strips the `Bearer ` prefix automatically. +In Portainer, the smoothest setup is to set `TRAILBASE_LOGIN_EMAIL` and +`TRAILBASE_LOGIN_PASSWORD`. The sidecar logs in through TrailBase's +`/api/auth/v1/login` endpoint, stores the returned auth/refresh/CSRF tokens only +in memory, and refreshes auth when needed. + +If you prefer to paste tokens instead, set `TRAILBASE_AUTH_TOKEN`. The value can +be either the raw JWT or the full `Bearer ...` output; the sidecar strips the +`Bearer ` prefix automatically. Optionally set `TRAILBASE_REFRESH_TOKEN` too. +When present, the sidecar refreshes an expired or near-expired auth token through +TrailBase's `/api/auth/v1/refresh` endpoint. If you prefer not to store the token in the stack environment, use the optional mounted token-file approach: @@ -116,25 +126,35 @@ mounted token-file approach: image: frostbite4456/trailbase-mcp:latest volumes: - /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro + - /opt/trailbase/secrets/trailbase-refresh-token:/run/secrets/trailbase_refresh_token:ro + - /opt/trailbase/secrets/trailbase-login-password:/run/secrets/trailbase_login_password:ro environment: TRAILBASE_URL: "http://trail:4000" + TRAILBASE_LOGIN_EMAIL: "admin@localhost" TRAILBASE_AUTH_TOKEN_FILE: "/run/secrets/trailbase_auth_token" + TRAILBASE_REFRESH_TOKEN_FILE: "/run/secrets/trailbase_refresh_token" + TRAILBASE_LOGIN_PASSWORD_FILE: "/run/secrets/trailbase_login_password" TRAILBASE_MCP_ENABLE_WRITES: "false" MCP_TRANSPORT: "http" MCP_HOST: "0.0.0.0" MCP_PORT: "8000" ``` -Create the token file once on the Docker host: +Create the token files once on the Docker host, depending on which credential +style you use: ```sh sudo mkdir -p /opt/trailbase/secrets sudo sh -c 'printf "%s" "PASTE_RAW_JWT_HERE" > /opt/trailbase/secrets/trailbase-token' +sudo sh -c 'printf "%s" "PASTE_REFRESH_TOKEN_HERE" > /opt/trailbase/secrets/trailbase-refresh-token' +sudo sh -c 'printf "%s" "PASTE_LOGIN_PASSWORD_HERE" > /opt/trailbase/secrets/trailbase-login-password' sudo chmod 600 /opt/trailbase/secrets/trailbase-token +sudo chmod 600 /opt/trailbase/secrets/trailbase-refresh-token +sudo chmod 600 /opt/trailbase/secrets/trailbase-login-password ``` -The file may contain either the raw JWT or the full `Bearer ...` output; the -sidecar strips the `Bearer ` prefix automatically. +Token files may contain either the raw token or the full `Bearer ...` output; +the sidecar strips the `Bearer ` prefix automatically. ## Configuration @@ -145,6 +165,12 @@ Environment variables: | `TRAILBASE_URL` | `http://localhost:4000` | TrailBase base URL. In Compose, use the TrailBase service name, e.g. `http://trail:4000`. | | `TRAILBASE_AUTH_TOKEN` / `TRAILBASE_TOKEN` | unset | Admin or user JWT used for TrailBase API calls. Raw JWT is preferred; a leading `Bearer ` prefix is also accepted. | | `TRAILBASE_AUTH_TOKEN_FILE` / `TRAILBASE_TOKEN_FILE` | unset | Path to a file containing the JWT. Useful for Portainer, Docker secrets, and bind-mounted secret files. | +| `TRAILBASE_REFRESH_TOKEN` | unset | Optional TrailBase refresh token. If the auth token is absent, expired, or near expiry, the sidecar uses this to fetch a fresh auth token. | +| `TRAILBASE_REFRESH_TOKEN_FILE` | unset | Path to a file containing the refresh token. | +| `TRAILBASE_LOGIN_EMAIL` / `TRAILBASE_ADMIN_EMAIL` | unset | Optional TrailBase login email. If no valid auth token is available, the sidecar logs in and keeps returned tokens in memory. | +| `TRAILBASE_LOGIN_EMAIL_FILE` / `TRAILBASE_ADMIN_EMAIL_FILE` | unset | Path to a file containing the login email. | +| `TRAILBASE_LOGIN_PASSWORD` / `TRAILBASE_ADMIN_PASSWORD` | unset | Optional TrailBase login password. Use with `TRAILBASE_LOGIN_EMAIL`. | +| `TRAILBASE_LOGIN_PASSWORD_FILE` / `TRAILBASE_ADMIN_PASSWORD_FILE` | unset | Path to a file containing the login password. | | `TRAILBASE_CSRF_TOKEN` | derived from JWT | Optional explicit CSRF token. Normally not needed for TrailBase-minted JWTs. | | `TRAILBASE_MCP_ENABLE_WRITES` | `false` | Set to `true` to enable create/update/delete tools, mutating SQL, config updates, and mutating generic HTTP calls. | | `TRAILBASE_MCP_TIMEOUT` | `30` | HTTP timeout in seconds. | @@ -180,6 +206,56 @@ For Docker/Portainer deployments, `TRAILBASE_AUTH_TOKEN` is the simplest path. `TRAILBASE_AUTH_TOKEN_FILE` is available when you prefer a bind-mounted file or Docker secret. +### Getting tokens through the login API + +TrailBase's login endpoint is: + +```text +POST /api/auth/v1/login +``` + +Reference: + +In Bruno or another API client, post JSON like: + +```json +{ + "email": "admin@localhost", + "password": "your-admin-password", + "response_type": "token" +} +``` + +The response contains: + +```json +{ + "auth_token": "...", + "csrf_token": "...", + "refresh_token": "..." +} +``` + +For a long-running MCP container, either let MCP log in directly: + +```env +TRAILBASE_LOGIN_EMAIL=admin@localhost +TRAILBASE_LOGIN_PASSWORD= +``` + +or set the returned tokens: + +```env +TRAILBASE_AUTH_TOKEN= +TRAILBASE_REFRESH_TOKEN= +``` + +When login credentials are configured, MCP keeps the returned tokens in memory +and can log in again after refresh expiry. When tokens are configured directly, +the auth token follows TrailBase's auth-token TTL, while the refresh token lets +the sidecar refresh auth automatically without updating Portainer every time the +auth token expires. + ### Token lifetime and rotation TrailBase JWTs can expire. Do not assume a token copied from an API client or diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py index e300c5a0a..1b5b682dd 100644 --- a/mcp/src/trailbase_mcp/client.py +++ b/mcp/src/trailbase_mcp/client.py @@ -5,6 +5,7 @@ import base64 import binascii import json +import time from dataclasses import dataclass from pathlib import Path from typing import Any @@ -39,10 +40,22 @@ def normalize_auth_token(token: str | None) -> str | None: return token -def read_secret_file(path: str | None) -> str | None: +def read_text_file(path: str | None) -> str | None: if not path: return None - return normalize_auth_token(Path(path).read_text()) + value = Path(path).read_text().strip() + return value or None + + +def read_secret_file(path: str | None) -> str | None: + return normalize_auth_token(read_text_file(path)) + + +def env_or_file(value_name: str, file_name: str) -> str | None: + value = os.getenv(value_name) + if value is not None and value.strip(): + return value.strip() + return read_text_file(os.getenv(file_name)) def auth_token_from_env() -> str | None: @@ -54,6 +67,26 @@ def auth_token_from_env() -> str | None: ) +def refresh_token_from_env() -> str | None: + return normalize_auth_token(os.getenv("TRAILBASE_REFRESH_TOKEN")) or read_secret_file( + os.getenv("TRAILBASE_REFRESH_TOKEN_FILE") + ) + + +def login_email_from_env() -> str | None: + return ( + env_or_file("TRAILBASE_LOGIN_EMAIL", "TRAILBASE_LOGIN_EMAIL_FILE") + or env_or_file("TRAILBASE_ADMIN_EMAIL", "TRAILBASE_ADMIN_EMAIL_FILE") + ) + + +def login_password_from_env() -> str | None: + return ( + env_or_file("TRAILBASE_LOGIN_PASSWORD", "TRAILBASE_LOGIN_PASSWORD_FILE") + or env_or_file("TRAILBASE_ADMIN_PASSWORD", "TRAILBASE_ADMIN_PASSWORD_FILE") + ) + + def quote_segment(value: str) -> str: return quote(value, safe="") @@ -132,6 +165,25 @@ def csrf_token_from_jwt(token: str | None) -> str | None: return csrf_token if isinstance(csrf_token, str) and csrf_token else None +def jwt_expires_within(token: str | None, seconds: int) -> bool: + if not token: + return True + + parts = token.split(".") + if len(parts) != 3: + return False + + payload = parts[1] + payload += "=" * (-len(payload) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (ValueError, TypeError): + return False + + exp = claims.get("exp") + return isinstance(exp, (int, float)) and exp <= time.time() + seconds + + def _strip_leading_sql_comments(statement: str) -> str: sql = statement.strip() while True: @@ -169,6 +221,10 @@ def is_readonly_sql(query: str) -> bool: class TrailBaseClient: base_url: str auth_token: str | None = None + refresh_token: str | None = None + csrf_token: str | None = None + login_email: str | None = None + login_password: str | None = None timeout: float = 30.0 transport: httpx.BaseTransport | None = None @@ -177,17 +233,95 @@ def from_env(cls) -> "TrailBaseClient": return cls( base_url=os.getenv("TRAILBASE_URL", "http://localhost:4000"), auth_token=auth_token_from_env(), + refresh_token=refresh_token_from_env(), + login_email=login_email_from_env(), + login_password=login_password_from_env(), timeout=float(os.getenv("TRAILBASE_MCP_TIMEOUT", "30")), ) + def login(self) -> bool: + if not self.login_email or not self.login_password: + return False + + base_url = self.base_url.rstrip("/") + with httpx.Client( + base_url=base_url, + headers={ + "accept": "application/json", + "content-type": "application/json", + }, + timeout=self.timeout, + transport=self.transport, + ) as client: + response = client.post( + "/api/auth/v1/login", + json={ + "email": self.login_email, + "password": self.login_password, + "response_type": "token", + }, + ) + + if response.is_error: + return False + + body = response.json() + auth_token = normalize_auth_token(body.get("auth_token")) + if not auth_token: + return False + + self.auth_token = auth_token + self.refresh_token = normalize_auth_token(body.get("refresh_token")) or self.refresh_token + self.csrf_token = body.get("csrf_token") + return True + + def refresh_auth_token(self) -> bool: + if not self.refresh_token: + return False + + base_url = self.base_url.rstrip("/") + with httpx.Client( + base_url=base_url, + headers={ + "accept": "application/json", + "content-type": "application/json", + }, + timeout=self.timeout, + transport=self.transport, + ) as client: + response = client.post( + "/api/auth/v1/refresh", + json={"refresh_token": self.refresh_token}, + ) + + if response.is_error: + return False + + body = response.json() + auth_token = normalize_auth_token(body.get("auth_token")) + if not auth_token: + return False + + self.auth_token = auth_token + self.csrf_token = body.get("csrf_token") + return True + def _headers(self) -> dict[str, str]: + if jwt_expires_within(self.auth_token, 60): + if not self.refresh_token or not self.refresh_auth_token(): + self.login() + headers = { "accept": "application/json", "content-type": "application/json", } if self.auth_token: headers["authorization"] = f"Bearer {self.auth_token}" - csrf_token = os.getenv("TRAILBASE_CSRF_TOKEN") or csrf_token_from_jwt(self.auth_token) + csrf_token = ( + os.getenv("TRAILBASE_CSRF_TOKEN") + or self.csrf_token + or csrf_token_from_jwt(self.auth_token) + ) if csrf_token: headers["csrf-token"] = csrf_token return headers diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index cd9832575..ff40d91ec 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -2,6 +2,9 @@ import httpx import pytest +import base64 +import json +import time from trailbase_mcp.client import ( TrailBaseClient, @@ -9,7 +12,11 @@ csrf_token_from_jwt, file_upload_input, is_readonly_sql, + jwt_expires_within, + login_email_from_env, + login_password_from_env, normalize_auth_token, + refresh_token_from_env, validate_relative_path, ) from trailbase_mcp.proto import config_api_pb2 @@ -44,6 +51,126 @@ def test_auth_token_env_normalization_and_file( assert auth_token_from_env() == "env-token" +def test_refresh_token_env_file_and_expiration( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + refresh_file = tmp_path / "trailbase-refresh-token" + refresh_file.write_text("refresh-from-file\n") + + monkeypatch.delenv("TRAILBASE_REFRESH_TOKEN", raising=False) + monkeypatch.setenv("TRAILBASE_REFRESH_TOKEN_FILE", str(refresh_file)) + assert refresh_token_from_env() == "refresh-from-file" + + monkeypatch.setenv("TRAILBASE_REFRESH_TOKEN", "Bearer refresh-from-env") + assert refresh_token_from_env() == "refresh-from-env" + + payload = base64.urlsafe_b64encode( + json.dumps({"exp": int(time.time()) - 1}).encode() + ).decode().rstrip("=") + assert jwt_expires_within(f"header.{payload}.signature", 60) + + +def test_login_credentials_env_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + email_file = tmp_path / "trailbase-email" + password_file = tmp_path / "trailbase-password" + email_file.write_text("admin@localhost\n") + password_file.write_text("secret\n") + + monkeypatch.delenv("TRAILBASE_LOGIN_EMAIL", raising=False) + monkeypatch.delenv("TRAILBASE_LOGIN_PASSWORD", raising=False) + monkeypatch.setenv("TRAILBASE_LOGIN_EMAIL_FILE", str(email_file)) + monkeypatch.setenv("TRAILBASE_LOGIN_PASSWORD_FILE", str(password_file)) + assert login_email_from_env() == "admin@localhost" + assert login_password_from_env() == "secret" + + monkeypatch.setenv("TRAILBASE_ADMIN_EMAIL", "admin-alias@localhost") + monkeypatch.setenv("TRAILBASE_ADMIN_PASSWORD", "admin-secret") + assert login_email_from_env() == "admin@localhost" + assert login_password_from_env() == "secret" + + monkeypatch.setenv("TRAILBASE_LOGIN_EMAIL", "login@localhost") + monkeypatch.setenv("TRAILBASE_LOGIN_PASSWORD", "login-secret") + assert login_email_from_env() == "login@localhost" + assert login_password_from_env() == "login-secret" + + +def test_client_logs_in_when_auth_token_is_missing() -> None: + seen: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, request.url.path)) + if request.url.path == "/api/auth/v1/login": + assert json.loads(request.read()) == { + "email": "admin@localhost", + "password": "secret", + "response_type": "token", + } + return httpx.Response( + 200, + json={ + "auth_token": "fresh-token", + "refresh_token": "refresh-token", + "csrf_token": "fresh-csrf", + }, + ) + + assert request.url.path == "/api/_admin/info" + assert request.headers["authorization"] == "Bearer fresh-token" + assert request.headers["csrf-token"] == "fresh-csrf" + return httpx.Response(200, json={"ok": True}) + + client = TrailBaseClient( + base_url="http://trailbase.test", + login_email="admin@localhost", + login_password="secret", + transport=httpx.MockTransport(handler), + ) + + assert client.admin_info() == {"ok": True} + assert client.refresh_token == "refresh-token" + assert seen == [ + ("POST", "/api/auth/v1/login"), + ("GET", "/api/_admin/info"), + ] + + +def test_client_refreshes_expired_auth_token_before_request() -> None: + expired_payload = base64.urlsafe_b64encode( + json.dumps({"exp": int(time.time()) - 1}).encode() + ).decode().rstrip("=") + expired_token = f"header.{expired_payload}.signature" + + seen: list[tuple[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.method, request.url.path)) + if request.url.path == "/api/auth/v1/refresh": + assert request.read() == b'{"refresh_token":"refresh-token"}' + return httpx.Response( + 200, + json={"auth_token": "fresh-token", "csrf_token": "fresh-csrf"}, + ) + + assert request.url.path == "/api/_admin/info" + assert request.headers["authorization"] == "Bearer fresh-token" + assert request.headers["csrf-token"] == "fresh-csrf" + return httpx.Response(200, json={"ok": True}) + + client = TrailBaseClient( + base_url="http://trailbase.test", + auth_token=expired_token, + refresh_token="refresh-token", + transport=httpx.MockTransport(handler), + ) + + assert client.admin_info() == {"ok": True} + assert seen == [ + ("POST", "/api/auth/v1/refresh"), + ("GET", "/api/_admin/info"), + ] + + def test_client_sends_bearer_token_and_quotes_path_segments() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.headers["authorization"] == "Bearer test-token" From 168635e65121cd26be67d4a14bf65823c1c629c4 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 21:27:34 +1200 Subject: [PATCH 16/31] Add TrailBase OpenAPI operation catalog --- mcp/README.md | 24 +- mcp/src/trailbase_mcp/endpoints.py | 370 +++++++++++++++++++++++++++++ mcp/src/trailbase_mcp/server.py | 39 +++ mcp/tests/test_client.py | 90 ++++++- 4 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 mcp/src/trailbase_mcp/endpoints.py diff --git a/mcp/README.md b/mcp/README.md index 9ed2c52f2..755c33a75 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -371,6 +371,14 @@ to normal CRUD: TrailBase HTTP endpoint. Use this for auth endpoints, custom WASM APIs, and OpenAPI endpoints not covered by specialized MCP tools. Non-readonly methods require `TRAILBASE_MCP_ENABLE_WRITES=true`. +- `list_trailbase_api_operations(category?)`: list the TrailBase OpenAPI + operations known to MCP. `category` may be `auth`, `oauth`, or `records`. + Each entry includes `operation_id`, method, path template, mutation gate, and + the recommended MCP support path. +- `call_trailbase_api_operation(operation_id, path_params?, params?, body?)`: + call a known TrailBase OpenAPI operation by `operation_id`. `path_params` + fills placeholders such as `{"name": "todos", "record": "id"}`. Mutating + operations require `TRAILBASE_MCP_ENABLE_WRITES=true`. - `list_records(api_name, query?)`: forwards `query` as Record API URL query parameters. For example: `{"geojson": "geometry", "limit": 1024, "skip_cursor": "true"}` maps to @@ -399,6 +407,8 @@ Current tools: - `list_tables` - `execute_sql` - `trailbase_request` +- `list_trailbase_api_operations` +- `call_trailbase_api_operation` - `list_records` - `get_record` - `create_record` @@ -453,5 +463,15 @@ instead of reimplementing TrailBase behavior. Current coverage: - Record APIs: CRUD, list filters/sort/pagination/cursor/geojson query params, schema, JSON/base64 file upload, multipart upload, and file download are supported. -- Auth: use `trailbase_request` for auth endpoints; the sidecar itself should - be configured with an admin token for admin/config operations. +- OpenAPI operation pages: `list_trailbase_api_operations` exposes the auth, + OAuth, and Record API operations documented under TrailBase's OpenAPI pages. + Use `call_trailbase_api_operation` when you want to call by operation id + instead of manually composing a URL. +- Auth: use MCP-side login/refresh configuration for the sidecar's own + credentials. Use `call_trailbase_api_operation` or `trailbase_request` for + TrailBase user auth flows such as registration, password reset, email + verification, MFA/TOTP, logout, OAuth provider listing/login/callback, and + avatar endpoints. +- Realtime subscriptions: the TrailBase SSE subscription endpoint is listed in + the operation catalog, but this MCP sidecar does not proxy long-running + streams through a request/response tool. diff --git a/mcp/src/trailbase_mcp/endpoints.py b/mcp/src/trailbase_mcp/endpoints.py new file mode 100644 index 000000000..19d132c18 --- /dev/null +++ b/mcp/src/trailbase_mcp/endpoints.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +TRAILBASE_API_OPERATIONS: tuple[dict[str, Any], ...] = ( + { + "operation_id": "auth_code_to_token_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/token", + "summary": "Exchange authorization code for auth tokens.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "change_email_confirm_handler", + "category": "auth", + "method": "GET", + "path": "/api/auth/v1/change_email/confirm/:email_verification_code", + "summary": "Confirm a change of email address.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "change_email_request_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/change_email/request", + "summary": "Request an email change.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "change_password_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/change_password", + "summary": "Request a change of password.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "create_avatar_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/avatar/", + "summary": "Create or update the current user's avatar.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "delete_avatar_handler", + "category": "auth", + "method": "DELETE", + "path": "/api/auth/v1/avatar/", + "summary": "Delete the current user's avatar.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "delete_handler", + "category": "auth", + "method": "DELETE", + "path": "/api/auth/v1/delete", + "summary": "Delete the current user.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "get_avatar_handler", + "category": "auth", + "method": "GET", + "path": "/api/auth/v1/avatar/:b64_user_id", + "summary": "Get a user's avatar.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": False, + }, + { + "operation_id": "login_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/login", + "summary": "Log in users by email and password.", + "mcp_support": "built-in MCP sidecar login, call_trailbase_api_operation, or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "login_mfa_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/login_mfa", + "summary": "Log in users with an MFA token.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "login_otp_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/otp/login", + "summary": "Log in with an OTP code.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "login_status_handler", + "category": "auth", + "method": "GET", + "path": "/api/auth/v1/status", + "summary": "Check login status.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": False, + }, + { + "operation_id": "logout_handler", + "category": "auth", + "method": "GET", + "path": "/api/auth/v1/logout", + "summary": "Log out the current user and delete all pending sessions.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "post_logout_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/logout", + "summary": "Log out the session for a refresh token.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "refresh_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/refresh", + "summary": "Refresh auth tokens given a refresh token.", + "mcp_support": "built-in MCP sidecar refresh, call_trailbase_api_operation, or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "register_totp_confirm_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/totp/confirm", + "summary": "Verify the current user's TOTP.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "register_totp_request_handler", + "category": "auth", + "method": "GET", + "path": "/api/auth/v1/totp/register", + "summary": "Register the current user for TOTP.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "register_user_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/register", + "summary": "Register a new user with email and password.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "request_email_verification_handler", + "category": "auth", + "method": "GET", + "path": "/api/auth/v1/verify_email/trigger", + "summary": "Request a new email verification email.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "request_otp_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/otp/request", + "summary": "Request an OTP code.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "reset_password_request_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/reset_password/request", + "summary": "Request a password reset.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "reset_password_update_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/reset_password/update", + "summary": "Set a new password after a reset request.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "unregister_totp_handler", + "category": "auth", + "method": "POST", + "path": "/api/auth/v1/totp/unregister", + "summary": "Unregister TOTP for the current user.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "verify_email_handler", + "category": "auth", + "method": "GET", + "path": "/api/auth/v1/verify_email/confirm/:email_verification_code", + "summary": "Confirm an email verification code.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "callback_from_external_auth_provider", + "category": "oauth", + "method": "GET", + "path": "/api/auth/v1/oauth/{provider}/callback", + "summary": "Handle an external OAuth provider callback.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": True, + }, + { + "operation_id": "list_configured_providers_handler", + "category": "oauth", + "method": "GET", + "path": "/api/auth/v1/oauth/providers", + "summary": "List configured OAuth providers.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": False, + }, + { + "operation_id": "login_with_external_auth_provider", + "category": "oauth", + "method": "GET", + "path": "/api/auth/v1/oauth/{provider}/login", + "summary": "Start login through an external OAuth provider.", + "mcp_support": "call_trailbase_api_operation or trailbase_request", + "requires_write_permission": False, + }, + { + "operation_id": "add_subscription_sse_handler", + "category": "records", + "method": "GET", + "path": "/api/records/v1/{name}/subscribe/{record}", + "summary": "Start streaming record changes via SSE/WebSocket.", + "mcp_support": "catalog only; long-running SSE is not proxied by this request/response MCP tool", + "requires_write_permission": False, + "streaming": True, + }, + { + "operation_id": "create_record_handler", + "category": "records", + "method": "POST", + "path": "/api/records/v1/{name}", + "summary": "Create a new record.", + "mcp_support": "create_record, create_record_with_file_uploads, create_record_multipart, or call_trailbase_api_operation", + "requires_write_permission": True, + }, + { + "operation_id": "delete_record_handler", + "category": "records", + "method": "DELETE", + "path": "/api/records/v1/{name}/{record}", + "summary": "Delete a record.", + "mcp_support": "delete_record or call_trailbase_api_operation", + "requires_write_permission": True, + }, + { + "operation_id": "get_uploaded_file_from_record_handler", + "category": "records", + "method": "GET", + "path": "/api/records/v1/{name}/{record}/file/{column_name}", + "summary": "Read a file associated with a record.", + "mcp_support": "download_file or call_trailbase_api_operation", + "requires_write_permission": False, + }, + { + "operation_id": "get_uploaded_files_from_record_handler", + "category": "records", + "method": "GET", + "path": "/api/records/v1/{name}/{record}/files/{column_name}/{file_name}", + "summary": "Read one file from a record file-list column.", + "mcp_support": "download_file or call_trailbase_api_operation", + "requires_write_permission": False, + }, + { + "operation_id": "json_schema_handler", + "category": "records", + "method": "GET", + "path": "/api/records/v1/{name}/schema", + "summary": "Retrieve the JSON Schema for a record API.", + "mcp_support": "get_api_json_schema or call_trailbase_api_operation", + "requires_write_permission": False, + }, + { + "operation_id": "list_records_handler", + "category": "records", + "method": "GET", + "path": "/api/records/v1/{name}", + "summary": "List records matching filters.", + "mcp_support": "list_records or call_trailbase_api_operation", + "requires_write_permission": False, + }, + { + "operation_id": "read_record_handler", + "category": "records", + "method": "GET", + "path": "/api/records/v1/{name}/{record}", + "summary": "Read one record.", + "mcp_support": "get_record or call_trailbase_api_operation", + "requires_write_permission": False, + }, + { + "operation_id": "update_record_handler", + "category": "records", + "method": "PATCH", + "path": "/api/records/v1/{name}/{record}", + "summary": "Update an existing record.", + "mcp_support": "update_record or call_trailbase_api_operation", + "requires_write_permission": True, + }, +) + + +def list_api_operations(category: str | None = None) -> list[dict[str, Any]]: + if category is None: + return [dict(operation) for operation in TRAILBASE_API_OPERATIONS] + normalized = category.lower() + return [ + dict(operation) + for operation in TRAILBASE_API_OPERATIONS + if operation["category"] == normalized + ] + + +def get_api_operation(operation_id: str) -> dict[str, Any]: + for operation in TRAILBASE_API_OPERATIONS: + if operation["operation_id"] == operation_id: + return dict(operation) + raise ValueError(f"Unknown TrailBase API operation: {operation_id}") + + +def render_operation_path( + operation: dict[str, Any], + path_params: dict[str, Any] | None = None, +) -> str: + path = operation["path"] + params = path_params or {} + + for name, value in params.items(): + value = quote(str(value), safe="") + path = path.replace(f"{{{name}}}", value) + path = path.replace(f":{name}", value) + + if "{" in path or "}" in path or "/:" in path: + raise ValueError( + f"Missing path parameter for {operation['operation_id']}: {operation['path']}" + ) + + return path diff --git a/mcp/src/trailbase_mcp/server.py b/mcp/src/trailbase_mcp/server.py index e66e4bb81..600006951 100644 --- a/mcp/src/trailbase_mcp/server.py +++ b/mcp/src/trailbase_mcp/server.py @@ -6,6 +6,7 @@ from fastmcp import FastMCP from .client import READONLY_HTTP_METHODS, TrailBaseClient, env_flag, is_readonly_sql +from .endpoints import get_api_operation, list_api_operations, render_operation_path mcp = FastMCP("TrailBase") @@ -111,6 +112,44 @@ def trailbase_request( return _client().trailbase_request(normalized_method, path, params=params, body=body) +@mcp.tool +def list_trailbase_api_operations(category: str | None = None) -> Any: + """List TrailBase OpenAPI operations known to this MCP server. + + category may be auth, oauth, or records. The response includes the + operation_id, HTTP method, server-relative path template, mutation gate, and + recommended MCP support path for each operation. + """ + return {"operations": list_api_operations(category)} + + +@mcp.tool +def call_trailbase_api_operation( + operation_id: str, + path_params: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + body: Any | None = None, +) -> Any: + """Call a known TrailBase OpenAPI operation by operation_id. + + path_params fills path templates such as {"name": "todos", "record": "id"}. + params are URL query parameters and body is sent as JSON. Mutating + operations require TRAILBASE_MCP_ENABLE_WRITES=true. Streaming SSE + operations are cataloged but not proxied by this request/response tool. + """ + operation = get_api_operation(operation_id) + if operation.get("streaming"): + raise RuntimeError( + f"{operation_id} is a long-running streaming endpoint and is not " + "proxied by this request/response MCP tool." + ) + if operation.get("requires_write_permission"): + _require_writes_enabled() + + path = render_operation_path(operation, path_params) + return _client().trailbase_request(operation["method"], path, params=params, body=body) + + @mcp.tool def list_records(api_name: str, query: dict[str, Any] | None = None) -> Any: """List records for a TrailBase record API. diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index ff40d91ec..d1f46cac2 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -19,8 +19,14 @@ refresh_token_from_env, validate_relative_path, ) +from trailbase_mcp.endpoints import ( + get_api_operation, + list_api_operations, + render_operation_path, +) +from trailbase_mcp import server as server_module from trailbase_mcp.proto import config_api_pb2 -from trailbase_mcp.server import trailbase_request +from trailbase_mcp.server import call_trailbase_api_operation, trailbase_request def test_readonly_sql_detection() -> None: @@ -247,6 +253,88 @@ def test_server_generic_trailbase_request_write_gate(monkeypatch: pytest.MonkeyP trailbase_request("POST", "/api/auth/v1/login", body={}) +def test_trailbase_api_operation_catalog_covers_openapi_pages() -> None: + operations = list_api_operations() + operation_ids = {operation["operation_id"] for operation in operations} + + assert len(operations) == 36 + assert { + "auth_code_to_token_handler", + "login_handler", + "refresh_handler", + "callback_from_external_auth_provider", + "add_subscription_sse_handler", + "create_record_handler", + "json_schema_handler", + "update_record_handler", + }.issubset(operation_ids) + + assert get_api_operation("list_records_handler") == { + "operation_id": "list_records_handler", + "category": "records", + "method": "GET", + "path": "/api/records/v1/{name}", + "summary": "List records matching filters.", + "mcp_support": "list_records or call_trailbase_api_operation", + "requires_write_permission": False, + } + + assert [operation["category"] for operation in list_api_operations("oauth")] == [ + "oauth", + "oauth", + "oauth", + ] + + +def test_render_operation_path_quotes_parameters() -> None: + operation = get_api_operation("read_record_handler") + assert ( + render_operation_path(operation, {"name": "chat messages", "record": "id/1"}) + == "/api/records/v1/chat%20messages/id%2F1" + ) + + with pytest.raises(ValueError, match="Missing path parameter"): + render_operation_path(operation, {"name": "widgets"}) + + +def test_call_trailbase_api_operation(monkeypatch: pytest.MonkeyPatch) -> None: + seen = None + + class FakeClient: + def trailbase_request(self, method, path, *, params=None, body=None): + nonlocal seen + seen = (method, path, params, body) + return {"ok": True} + + monkeypatch.delenv("TRAILBASE_MCP_ENABLE_WRITES", raising=False) + monkeypatch.setattr(server_module, "_client", lambda: FakeClient()) + + assert call_trailbase_api_operation( + "read_record_handler", + path_params={"name": "widgets", "record": "1"}, + params={"expand": "author"}, + ) == {"ok": True} + assert seen == ( + "GET", + "/api/records/v1/widgets/1", + {"expand": "author"}, + None, + ) + + with pytest.raises(RuntimeError, match="Write operations are disabled"): + call_trailbase_api_operation( + "create_record_handler", + path_params={"name": "widgets"}, + body={"name": "Ada"}, + ) + + with pytest.raises(RuntimeError, match="long-running streaming endpoint"): + call_trailbase_api_operation( + "add_subscription_sse_handler", + path_params={"name": "widgets", "record": "1"}, + ) + + def test_client_derives_csrf_header_from_jwt() -> None: token = "header.eyJjc3JmX3Rva2VuIjoiY3NyZi0xMjMifQ.signature" From fefa6dafa31a8c9e60877a760812abcc9613ec48 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 21:30:54 +1200 Subject: [PATCH 17/31] Prepare MCP 0.2 Docker release --- mcp/Dockerfile | 6 ++++++ mcp/README.md | 43 +++++++++++++++++++++++++++++++++++++------ mcp/pyproject.toml | 2 +- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/mcp/Dockerfile b/mcp/Dockerfile index 974be3d49..a690853ef 100644 --- a/mcp/Dockerfile +++ b/mcp/Dockerfile @@ -1,5 +1,11 @@ FROM python:3.12-slim +LABEL org.opencontainers.image.title="TrailBase MCP sidecar" +LABEL org.opencontainers.image.description="FastMCP sidecar server for TrailBase" +LABEL org.opencontainers.image.version="0.2.0" +LABEL org.opencontainers.image.source="https://github.com/trailbaseio/trailbase" +LABEL org.opencontainers.image.url="https://hub.docker.com/r/frostbite4456/trailbase-mcp" + ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 diff --git a/mcp/README.md b/mcp/README.md index 755c33a75..61870ab82 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -19,9 +19,25 @@ store TrailBase data; it forwards MCP tool calls to a running TrailBase server. - JSON/base64 and multipart file uploads for `std.FileUpload` and `std.FileUploads`. - File download as base64. +- MCP-side TrailBase login and refresh-token handling for container deployments. +- TrailBase OpenAPI operation catalog with call-by-`operation_id` support for + auth, OAuth, and Record API operations. - Generic `trailbase_request` tool for custom WASM APIs, auth APIs, and other TrailBase HTTP endpoints. +## Image tags + +Published image: + +```text +frostbite4456/trailbase-mcp +``` + +Recommended tags: + +- `frostbite4456/trailbase-mcp:0.2` - pinned release. +- `frostbite4456/trailbase-mcp:latest` - current release. + ## Quick start with Docker Run TrailBase separately, then run this MCP sidecar against it. @@ -34,7 +50,21 @@ docker run --rm -p 8000:8000 \ -e MCP_TRANSPORT=http \ -e MCP_HOST=0.0.0.0 \ -e MCP_PORT=8000 \ - frostbite4456/trailbase-mcp:latest + frostbite4456/trailbase-mcp:0.2 +``` + +Or let the sidecar log in to TrailBase and keep returned tokens in memory: + +```sh +docker run --rm -p 8000:8000 \ + -e TRAILBASE_URL=http://host.docker.internal:4000 \ + -e TRAILBASE_LOGIN_EMAIL=admin@localhost \ + -e TRAILBASE_LOGIN_PASSWORD=your-admin-password \ + -e TRAILBASE_MCP_ENABLE_WRITES=false \ + -e MCP_TRANSPORT=http \ + -e MCP_HOST=0.0.0.0 \ + -e MCP_PORT=8000 \ + frostbite4456/trailbase-mcp:0.2 ``` Or provide the token via a mounted file: @@ -48,7 +78,7 @@ docker run --rm -p 8000:8000 \ -e MCP_TRANSPORT=http \ -e MCP_HOST=0.0.0.0 \ -e MCP_PORT=8000 \ - frostbite4456/trailbase-mcp:latest + frostbite4456/trailbase-mcp:0.2 ``` The MCP endpoint is: @@ -63,8 +93,9 @@ which is expected for MCP over HTTP. ## Portainer / Docker Compose stack -This example uses the published Docker Hub image: -`frostbite4456/trailbase-mcp:latest`. +This example uses the published Docker Hub image. Pin `0.2` for reproducible +deployments or use `latest` when you intentionally want the current release: +`frostbite4456/trailbase-mcp:0.2`. ```yaml services: @@ -79,7 +110,7 @@ services: RUST_BACKTRACE: "1" mcp: - image: frostbite4456/trailbase-mcp:latest + image: frostbite4456/trailbase-mcp:0.2 depends_on: - trail ports: @@ -123,7 +154,7 @@ mounted token-file approach: ```yaml mcp: - image: frostbite4456/trailbase-mcp:latest + image: frostbite4456/trailbase-mcp:0.2 volumes: - /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro - /opt/trailbase/secrets/trailbase-refresh-token:/run/secrets/trailbase_refresh_token:ro diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index 125d93c52..97cf6c197 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "trailbase-mcp" -version = "0.1.0" +version = "0.2.0" description = "FastMCP sidecar server for TrailBase" readme = "README.md" requires-python = ">=3.11" From 6235a81a3623bbab7095d5e12a05519bb79f9dd9 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 22:06:03 +1200 Subject: [PATCH 18/31] Document Record API table constraints --- mcp/README.md | 60 ++++++++++++++++++++++++++++- mcp/src/trailbase_mcp/client.py | 57 ++++++++++++++++++++++++++++ mcp/src/trailbase_mcp/server.py | 26 +++++++++++++ mcp/tests/test_client.py | 67 +++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index 61870ab82..4b4e61291 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -12,6 +12,8 @@ store TrailBase data; it forwards MCP tool calls to a running TrailBase server. - Admin config read/update, including Record API configuration. - SQL execution with a default read-only guard. - Table, view, index, and trigger introspection. +- Safe table teardown helper that removes Record API config before dropping a + table. - Record API CRUD. - Record API list query passthrough, including filters, sorting, pagination, cursors, `geojson`, `limit`, and `skip_cursor`. @@ -426,6 +428,59 @@ to normal CRUD: `multipart/form-data` using the same file descriptors. - `download_file(api_name, record_id, column_name, file_name?)`: download a `std.FileUpload` or `std.FileUploads` file and return `content_base64`. +- `remove_record_api(api_name?, table_name?)`: remove Record API config entries + by API name or backing table name. Use this before manually dropping a table. +- `drop_table(table_name, remove_record_apis?)`: drop a table. By default this + removes any Record API entries whose `table_name` matches before running + `DROP TABLE IF EXISTS`. + +### Record API primary keys + +TrailBase Record APIs require a compatible primary key. If MCP creates a table +and then exposes it as a Record API, use either: + +```sql +id INTEGER PRIMARY KEY +``` + +or a TrailBase-compatible UUID primary key. Do not use `TEXT PRIMARY KEY` for a +table that should become a Record API. + +If the primary key is not compatible, TrailBase rejects the config update with +an error like: + +```text +Does not have a suitable PRIMARY KEY column. At this point TrailBase requires +PRIMARY KEYS to be of type INTEGER or UUID. +``` + +Recommended simple table shape: + +```sql +CREATE TABLE candyland ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL +) STRICT; +``` + +### Deleting tables that have Record APIs + +Remove Record API config before dropping the backing table. Otherwise TrailBase +can retain a config entry that references a missing table. + +Preferred MCP path: + +```text +drop_table(table_name="candyland") +``` + +That removes Record APIs whose `table_name` is `candyland` first, then drops the +table. If you want to do it manually: + +```text +remove_record_api(table_name="candyland") +execute_sql(query="DROP TABLE IF EXISTS candyland", allow_mutation=true) +``` ## MCP tools @@ -435,6 +490,8 @@ Current tools: - `trailbase_config` - `update_config` - `list_record_apis` +- `remove_record_api` +- `drop_table` - `list_tables` - `execute_sql` - `trailbase_request` @@ -480,7 +537,8 @@ instead of reimplementing TrailBase behavior. Current coverage: - Models & Relations: use `execute_sql` for STRICT tables, constraints, indexes, triggers, views, generated columns, geometry columns, and relations; use `update_config` to expose tables/views as Record APIs and configure - `expand`. + `expand`. For tables intended for Record API access, use an `INTEGER PRIMARY + KEY` or TrailBase-compatible UUID primary key. - Migrations: TrailBase migrations are filesystem/CLI driven (`traildepot/migrations`, `trail migration`, restart/SIGHUP). MCP can apply SQL through `execute_sql`, but it is not a migration runner and should not diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py index 1b5b682dd..8b1cbff6c 100644 --- a/mcp/src/trailbase_mcp/client.py +++ b/mcp/src/trailbase_mcp/client.py @@ -20,6 +20,7 @@ TRUE_VALUES = {"1", "true", "yes", "on"} READONLY_SQL_STARTERS = {"select", "with", "pragma", "explain"} READONLY_HTTP_METHODS = {"GET", "HEAD", "OPTIONS"} +SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") def env_flag(name: str, default: bool = False) -> bool: @@ -91,6 +92,15 @@ def quote_segment(value: str) -> str: return quote(value, safe="") +def quote_sql_identifier(value: str) -> str: + if not isinstance(value, str) or not SQL_IDENTIFIER_RE.fullmatch(value): + raise ValueError( + "SQL identifier must start with a letter or underscore and contain " + "only letters, digits, and underscores" + ) + return f'"{value}"' + + def validate_relative_path(path: str) -> str: if not isinstance(path, str): raise ValueError("path must be server-relative and start with '/'") @@ -493,6 +503,53 @@ def update_config(self, config: dict[str, Any], hash: str) -> Any: ) return {"ok": True} + def remove_record_api( + self, + api_name: str | None = None, + table_name: str | None = None, + ) -> Any: + if not api_name and not table_name: + raise ValueError("Provide api_name or table_name") + + response = self.admin_config() + config = response["config"] + record_apis = config.setdefault("record_apis", []) + kept = [] + removed = [] + + for api in record_apis: + name_matches = api_name is not None and api.get("name") == api_name + table_matches = table_name is not None and api.get("table_name") == table_name + if name_matches or table_matches: + removed.append(api) + else: + kept.append(api) + + if not removed: + return {"ok": True, "removed": [], "updated": False} + + config["record_apis"] = kept + self.update_config(config, response["hash"]) + return {"ok": True, "removed": removed, "updated": True} + + def drop_table( + self, + table_name: str, + remove_record_apis: bool = True, + ) -> Any: + removed_apis = {"ok": True, "removed": [], "updated": False} + if remove_record_apis: + removed_apis = self.remove_record_api(table_name=table_name) + + sql = f"DROP TABLE IF EXISTS {quote_sql_identifier(table_name)}" + dropped = self.execute_sql(sql) + return { + "ok": True, + "table_name": table_name, + "removed_record_apis": removed_apis.get("removed", []), + "drop_result": dropped, + } + def list_tables(self) -> Any: return self.request("GET", "/api/_admin/tables") diff --git a/mcp/src/trailbase_mcp/server.py b/mcp/src/trailbase_mcp/server.py index 600006951..4fa90d2ad 100644 --- a/mcp/src/trailbase_mcp/server.py +++ b/mcp/src/trailbase_mcp/server.py @@ -50,6 +50,32 @@ def list_record_apis() -> Any: return {"record_apis": response.get("config", {}).get("record_apis", [])} +@mcp.tool +def remove_record_api( + api_name: str | None = None, + table_name: str | None = None, +) -> Any: + """Remove Record API config entries by API name or backing table name. + + Requires TRAILBASE_MCP_ENABLE_WRITES=true. Use this before dropping a table + that is exposed as a Record API. + """ + _require_writes_enabled() + return _client().remove_record_api(api_name=api_name, table_name=table_name) + + +@mcp.tool +def drop_table(table_name: str, remove_record_apis: bool = True) -> Any: + """Drop a table, optionally removing Record APIs that reference it first. + + Requires TRAILBASE_MCP_ENABLE_WRITES=true. The table name must be a simple + SQL identifier. This helper removes API config before DROP TABLE by default + to avoid stale Record API references. + """ + _require_writes_enabled() + return _client().drop_table(table_name, remove_record_apis) + + @mcp.tool def list_tables() -> Any: """List TrailBase tables, views, indexes, and triggers.""" diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py index d1f46cac2..1807471e2 100644 --- a/mcp/tests/test_client.py +++ b/mcp/tests/test_client.py @@ -16,6 +16,7 @@ login_email_from_env, login_password_from_env, normalize_auth_token, + quote_sql_identifier, refresh_token_from_env, validate_relative_path, ) @@ -246,6 +247,14 @@ def test_generic_trailbase_request_rejects_absolute_urls() -> None: validate_relative_path("https://example.com/api") +def test_quote_sql_identifier_rejects_unsafe_names() -> None: + assert quote_sql_identifier("candyland_2") == '"candyland_2"' + with pytest.raises(ValueError, match="SQL identifier"): + quote_sql_identifier("candyland; drop table users") + with pytest.raises(ValueError, match="SQL identifier"): + quote_sql_identifier("candy-land") + + def test_server_generic_trailbase_request_write_gate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("TRAILBASE_MCP_ENABLE_WRITES", raising=False) @@ -481,3 +490,61 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen_update is not None assert seen_update.hash == "hash-1" assert seen_update.config.record_apis[0].name == "widgets" + + +def test_client_removes_record_api_before_drop_table() -> None: + config_response = config_api_pb2.GetConfigResponse() + config_response.hash = "hash-1" + config_response.config.email.smtp_host = "localhost" + config_response.config.server.application_name = "TrailBase" + config_response.config.auth.password_minimal_length = 8 + config_response.config.jobs.SetInParent() + config_response.config.record_apis.add( + name="widgets", + table_name="widgets", + acl_world=[1, 2], + ) + config_response.config.record_apis.add( + name="other_widgets", + table_name="widgets", + acl_world=[1], + ) + config_response.config.record_apis.add( + name="profiles", + table_name="profiles", + acl_world=[1], + ) + + updates: list[config_api_pb2.UpdateConfigRequest] = [] + queries: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/api/_admin/config" and request.method == "GET": + return httpx.Response(200, content=config_response.SerializeToString()) + + if request.url.path == "/api/_admin/config" and request.method == "POST": + update = config_api_pb2.UpdateConfigRequest() + update.ParseFromString(request.content) + updates.append(update) + return httpx.Response(200, content=b"") + + if request.url.path == "/api/_admin/query": + queries.append(json.loads(request.read())["query"]) + return httpx.Response(200, json={"columns": None, "rows": []}) + + raise AssertionError(request.url) + + client = TrailBaseClient( + base_url="http://trailbase.test", + transport=httpx.MockTransport(handler), + ) + + result = client.drop_table("widgets") + assert result["ok"] + assert [api["name"] for api in result["removed_record_apis"]] == [ + "widgets", + "other_widgets", + ] + assert queries == ['DROP TABLE IF EXISTS "widgets"'] + assert len(updates) == 1 + assert [api.name for api in updates[0].config.record_apis] == ["profiles"] From 0ec3e72565d2c426d4e12106d159b325256666d9 Mon Sep 17 00:00:00 2001 From: brigon Date: Wed, 15 Jul 2026 22:31:49 +1200 Subject: [PATCH 19/31] Clarify Portainer credential options --- mcp/README.md | 91 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 67 insertions(+), 24 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 4b4e61291..070091cf2 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -99,6 +99,10 @@ This example uses the published Docker Hub image. Pin `0.2` for reproducible deployments or use `latest` when you intentionally want the current release: `frostbite4456/trailbase-mcp:0.2`. +The quick-start credential path is TrailBase login email/password. The MCP +sidecar logs in at startup, keeps returned auth/refresh/CSRF tokens in memory, +and refreshes auth when needed. + ```yaml services: trail: @@ -120,10 +124,8 @@ services: restart: unless-stopped environment: TRAILBASE_URL: "http://trail:4000" - TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN:-}" - TRAILBASE_REFRESH_TOKEN: "${TRAILBASE_REFRESH_TOKEN:-}" - TRAILBASE_LOGIN_EMAIL: "${TRAILBASE_LOGIN_EMAIL:-}" - TRAILBASE_LOGIN_PASSWORD: "${TRAILBASE_LOGIN_PASSWORD:-}" + TRAILBASE_LOGIN_EMAIL: "${TRAILBASE_LOGIN_EMAIL:-admin@localhost}" + TRAILBASE_LOGIN_PASSWORD: "${TRAILBASE_LOGIN_PASSWORD}" TRAILBASE_MCP_ENABLE_WRITES: "false" MCP_TRANSPORT: "http" MCP_HOST: "0.0.0.0" @@ -140,32 +142,51 @@ sudo chown -R 1000:1000 /opt/trailbase/traildepot If you see TrailBase permission errors, verify the UID used by your TrailBase image or temporarily relax permissions to confirm the mount is the issue. -In Portainer, the smoothest setup is to set `TRAILBASE_LOGIN_EMAIL` and -`TRAILBASE_LOGIN_PASSWORD`. The sidecar logs in through TrailBase's -`/api/auth/v1/login` endpoint, stores the returned auth/refresh/CSRF tokens only -in memory, and refreshes auth when needed. +In Portainer, set these stack environment variables: + +```env +TRAILBASE_LOGIN_EMAIL=admin@localhost +TRAILBASE_LOGIN_PASSWORD=your-admin-password +``` + +This is usually smoother than copying bearer tokens between the TrailBase UI, +Bruno, and Portainer. The sidecar logs in through TrailBase's +`/api/auth/v1/login` endpoint and does not write the returned tokens to disk. -If you prefer to paste tokens instead, set `TRAILBASE_AUTH_TOKEN`. The value can -be either the raw JWT or the full `Bearer ...` output; the sidecar strips the -`Bearer ` prefix automatically. Optionally set `TRAILBASE_REFRESH_TOKEN` too. -When present, the sidecar refreshes an expired or near-expired auth token through -TrailBase's `/api/auth/v1/refresh` endpoint. +### Alternative: auth token and refresh token -If you prefer not to store the token in the stack environment, use the optional -mounted token-file approach: +If you prefer to paste tokens instead of storing a login password, replace the +login variables in the `mcp` service with: + +```yaml + environment: + TRAILBASE_URL: "http://trail:4000" + TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN}" + TRAILBASE_REFRESH_TOKEN: "${TRAILBASE_REFRESH_TOKEN:-}" + TRAILBASE_MCP_ENABLE_WRITES: "false" + MCP_TRANSPORT: "http" + MCP_HOST: "0.0.0.0" + MCP_PORT: "8000" +``` + +Set `TRAILBASE_AUTH_TOKEN` to either the raw JWT or the full `Bearer ...` +output; the sidecar strips the `Bearer ` prefix automatically. Optionally set +`TRAILBASE_REFRESH_TOKEN` too. When present, the sidecar refreshes an expired or +near-expired auth token through TrailBase's `/api/auth/v1/refresh` endpoint. + +### Alternative: mounted credential files + +If you prefer not to store credentials directly in the stack environment, use +mounted files. For login/password mode, mount only the password: ```yaml mcp: image: frostbite4456/trailbase-mcp:0.2 volumes: - - /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro - - /opt/trailbase/secrets/trailbase-refresh-token:/run/secrets/trailbase_refresh_token:ro - /opt/trailbase/secrets/trailbase-login-password:/run/secrets/trailbase_login_password:ro environment: TRAILBASE_URL: "http://trail:4000" TRAILBASE_LOGIN_EMAIL: "admin@localhost" - TRAILBASE_AUTH_TOKEN_FILE: "/run/secrets/trailbase_auth_token" - TRAILBASE_REFRESH_TOKEN_FILE: "/run/secrets/trailbase_refresh_token" TRAILBASE_LOGIN_PASSWORD_FILE: "/run/secrets/trailbase_login_password" TRAILBASE_MCP_ENABLE_WRITES: "false" MCP_TRANSPORT: "http" @@ -173,21 +194,43 @@ mounted token-file approach: MCP_PORT: "8000" ``` -Create the token files once on the Docker host, depending on which credential -style you use: +Create the password file once on the Docker host: + +```sh +sudo mkdir -p /opt/trailbase/secrets +sudo sh -c 'printf "%s" "PASTE_LOGIN_PASSWORD_HERE" > /opt/trailbase/secrets/trailbase-login-password' +sudo chmod 600 /opt/trailbase/secrets/trailbase-login-password +``` + +For token mode, mount token files instead: + +```yaml + mcp: + image: frostbite4456/trailbase-mcp:0.2 + volumes: + - /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro + - /opt/trailbase/secrets/trailbase-refresh-token:/run/secrets/trailbase_refresh_token:ro + environment: + TRAILBASE_URL: "http://trail:4000" + TRAILBASE_AUTH_TOKEN_FILE: "/run/secrets/trailbase_auth_token" + TRAILBASE_REFRESH_TOKEN_FILE: "/run/secrets/trailbase_refresh_token" + TRAILBASE_MCP_ENABLE_WRITES: "false" + MCP_TRANSPORT: "http" + MCP_HOST: "0.0.0.0" + MCP_PORT: "8000" +``` ```sh sudo mkdir -p /opt/trailbase/secrets sudo sh -c 'printf "%s" "PASTE_RAW_JWT_HERE" > /opt/trailbase/secrets/trailbase-token' sudo sh -c 'printf "%s" "PASTE_REFRESH_TOKEN_HERE" > /opt/trailbase/secrets/trailbase-refresh-token' -sudo sh -c 'printf "%s" "PASTE_LOGIN_PASSWORD_HERE" > /opt/trailbase/secrets/trailbase-login-password' sudo chmod 600 /opt/trailbase/secrets/trailbase-token sudo chmod 600 /opt/trailbase/secrets/trailbase-refresh-token -sudo chmod 600 /opt/trailbase/secrets/trailbase-login-password ``` Token files may contain either the raw token or the full `Bearer ...` output; -the sidecar strips the `Bearer ` prefix automatically. +the sidecar strips the `Bearer ` prefix automatically. Password files are read +as plain text. ## Configuration From 81c3f5c5f6ba7c7c175f93055f990baffa231ba8 Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 15:22:11 +1200 Subject: [PATCH 20/31] Add native MCP server to TrailBase --- Cargo.lock | 99 ++++++++++++++++++ Cargo.toml | 1 + crates/cli/src/args.rs | 4 + crates/cli/src/bin/trail.rs | 1 + crates/core/Cargo.toml | 1 + crates/core/src/lib.rs | 1 + crates/core/src/mcp.rs | 182 ++++++++++++++++++++++++++++++++++ crates/core/src/server/mod.rs | 16 ++- 8 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 crates/core/src/mcp.rs diff --git a/Cargo.lock b/Cargo.lock index eaf3d2e30..e5e392feb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1887,6 +1887,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core 0.24.0", + "darling_macro 0.24.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1914,6 +1924,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1936,6 +1959,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core 0.24.0", + "quote", + "syn 3.0.3", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -5093,6 +5127,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "path-clean" version = "1.0.1" @@ -6399,6 +6439,50 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "rmcp" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8dddc5b1924b9a59fba420166160ca2c4663a4e01803e52eda33070f56d63c8" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.2", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.19", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6898e24cd16342b59bfa8a53c2c04b9cf62fc8a2cfea57b9c038b09984bfc521" +dependencies = [ + "darling 0.24.0", + "proc-macro2", + "quote", + "serde_json", + "syn 3.0.3", +] + [[package]] name = "rquickjs" version = "0.12.2" @@ -6812,6 +6896,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", @@ -7345,6 +7430,19 @@ dependencies = [ "recursive", ] +[[package]] +name = "sse-stream" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -8228,6 +8326,7 @@ dependencies = [ "rcgen", "regex", "reqwest 0.13.4", + "rmcp", "rusqlite", "rust-embed", "schemars", diff --git a/Cargo.toml b/Cargo.toml index f8ed2a157..0d7fbdaba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ minijinja = { version = "2.1.2", default-features = false } parking_lot = { version = "0.12.3", default-features = false, features = ["send_guard", "arc_lock"] } rand = "^0.10.0" reqwest = { version = "0.13.1", default-features = false, features = ["rustls", "json"] } +rmcp = { version = "3.1.2", features = ["transport-streamable-http-server"] } rusqlite = { version = "0.40.0", default-features = false, features = ["bundled", "cache", "column_decltype", "functions", "backup", "preupdate_hook"] } rust-embed = { version = "8.4.0", default-features = false, features = ["mime-guess"] } serde = { version = "^1.0.203", features = ["derive", "rc"] } diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 2a8c3e47a..3213e5faa 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -131,6 +131,10 @@ pub struct ServerArgs { #[arg(long)] pub demo: bool, + /// Enable the authenticated MCP endpoint at /mcp on the admin server. + #[arg(long, env, default_value_t = false)] + pub mcp: bool, + #[arg(long, default_value_t = false)] pub stderr_logging: bool, diff --git a/crates/cli/src/bin/trail.rs b/crates/cli/src/bin/trail.rs index 046b34d5c..b78a749d5 100644 --- a/crates/cli/src/bin/trail.rs +++ b/crates/cli/src/bin/trail.rs @@ -93,6 +93,7 @@ async fn async_main( tls_key: None, tls_cert: None, custom_router: None, + enable_mcp: cmd.mcp, }, ) .await?; diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 008285630..4ff91cffc 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -81,6 +81,7 @@ quick_cache = "0.7.0" rand = { workspace = true } regex = "1.11.0" reqwest = { workspace = true } +rmcp = { workspace = true } rusqlite = { workspace = true } rust-embed = { workspace = true } serde = { workspace = true } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0773d8f53..5242c3745 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -23,6 +23,7 @@ mod encryption; mod extract; mod init_error; mod listing; +mod mcp; mod migrations; mod scheduler; mod schema_metadata; diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs new file mode 100644 index 000000000..eaf020922 --- /dev/null +++ b/crates/core/src/mcp.rs @@ -0,0 +1,182 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{Method, header}; +use axum::middleware::{self, Next}; +use axum::response::Response; +use axum::{RequestExt, Router}; +use http_body_util::BodyExt; +use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters}; +use rmcp::model::{ErrorData as McpError, ServerCapabilities, ServerInfo}; +use rmcp::transport::{ + StreamableHttpServerConfig, + streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, +}; +use rmcp::{Json, ServerHandler, schemars, tool, tool_handler, tool_router}; +use serde::Deserialize; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use crate::admin; +use crate::app_state::AppState; +use crate::auth::util::is_admin; +use crate::auth::{AuthError, User}; + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct AdminRequest { + /// HTTP method accepted by the TrailBase admin API. + method: String, + /// Admin API path relative to /api/_admin, including an optional query string. + path: String, + /// Optional JSON request body. + #[serde(default)] + body: Option, +} + +#[derive(Clone)] +struct TrailBaseMcp { + state: AppState, + #[allow(dead_code)] + tool_router: ToolRouter, +} + +impl TrailBaseMcp { + fn new(state: AppState) -> Self { + Self { + state, + tool_router: Self::tool_router(), + } + } +} + +#[tool_router] +impl TrailBaseMcp { + #[tool( + description = "Call a TrailBase admin API in-process. Paths are relative to /api/_admin. This exposes the same table, index, row, config, schema, query, user, log, backup, job, and WASM operations as the admin dashboard." + )] + async fn call_admin_api( + &self, + Parameters(request): Parameters, + ) -> Result, McpError> { + let method = Method::from_bytes(request.method.as_bytes()) + .map_err(|_| McpError::invalid_params("invalid HTTP method", None))?; + let path = normalize_admin_path(&request.path)?; + let body = request + .body + .map(|value| serde_json::to_vec(&value)) + .transpose() + .map_err(|err| McpError::invalid_params(err.to_string(), None))? + .unwrap_or_default(); + + let request = Request::builder() + .method(method) + .uri(path) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .map_err(|err| McpError::internal_error(err.to_string(), None))?; + + let response = admin::router() + .with_state(self.state.clone()) + .oneshot(request) + .await + .map_err(|never| match never {})?; + let status = response.status(); + let bytes = response + .into_body() + .collect() + .await + .map_err(|err| McpError::internal_error(err.to_string(), None))? + .to_bytes(); + + let response_body = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned())) + }; + + if !status.is_success() { + return Err(McpError::internal_error( + format!("TrailBase admin API returned {status}: {response_body}"), + Some(json!({ "status": status.as_u16(), "body": response_body })), + )); + } + + Ok(Json(json!({ + "status": status.as_u16(), + "body": response_body + }))) + } +} + +#[tool_handler] +impl ServerHandler for TrailBaseMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( + "TrailBase's native administrative MCP server. Use call_admin_api to perform the same operations as the admin dashboard. Destructive operations modify the active TrailBase depot.", + ) + } +} + +pub(crate) fn router(state: &AppState) -> Router { + let state_for_service = state.clone(); + let service: StreamableHttpService = + StreamableHttpService::new( + move || Ok(TrailBaseMcp::new(state_for_service.clone())), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default() + .with_json_response(true) + // TrailBase supports operator-configured reverse proxies. Admin authentication below is + // the security boundary, so the MCP transport must accept the proxy's Host header. + .disable_allowed_hosts(), + ); + + Router::new() + .nest_service("/mcp", service) + .layer(middleware::from_fn_with_state( + state.clone(), + assert_mcp_access, + )) +} + +async fn assert_mcp_access( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let user = request.extract_parts_with_state::(&state).await?; + if !is_admin(&state, &user.uuid).await { + return Err(AuthError::Forbidden); + } + + Ok(next.run(request).await) +} + +fn normalize_admin_path(path: &str) -> Result { + let path = path.trim(); + if path.is_empty() || path.contains("://") || path.starts_with("//") { + return Err(McpError::invalid_params("invalid admin API path", None)); + } + + let path = path + .strip_prefix("/api/_admin") + .unwrap_or(path) + .trim_start_matches('/'); + Ok(format!("/{path}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_admin_paths() { + assert_eq!(normalize_admin_path("tables").unwrap(), "/tables"); + assert_eq!( + normalize_admin_path("/api/_admin/logs/list?limit=5").unwrap(), + "/logs/list?limit=5" + ); + assert!(normalize_admin_path("https://example.com").is_err()); + } +} diff --git a/crates/core/src/server/mod.rs b/crates/core/src/server/mod.rs index f3002fd77..4ca9e2716 100644 --- a/crates/core/src/server/mod.rs +++ b/crates/core/src/server/mod.rs @@ -75,6 +75,9 @@ pub struct ServerOptions { /// Custom axum router. pub custom_router: Option>, + + /// Expose the MCP endpoint on the admin server. + pub enable_mcp: bool, } pub struct Server { @@ -108,6 +111,7 @@ impl Server { tls_cert, tls_key, custom_router, + enable_mcp, } = opts; let version_info = trailbase_build::get_version_info!(); @@ -174,7 +178,7 @@ impl Server { None }; - let admin_router = Self::build_admin_router(&state); + let admin_router = Self::build_admin_router(&state, enable_mcp); let independent_admin_router = if let Some(admin_address) = admin_address && admin_address != address { @@ -365,8 +369,8 @@ impl Server { return Ok(()); } - fn build_admin_router(state: &AppState) -> Router { - return Router::new() + fn build_admin_router(state: &AppState, enable_mcp: bool) -> Router { + let mut router = Router::new() .nest( &format!("/{ADMIN_API_PATH}/"), admin::router().layer(middleware::from_fn_with_state( @@ -391,6 +395,12 @@ impl Server { }, ), ); + + if enable_mcp { + router = router.merge(crate::mcp::router(state)); + } + + return router; } async fn build_main_router( From 1b7c1d41d07fa775cde8c98fcb0779da296d0c4a Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 15:34:13 +1200 Subject: [PATCH 21/31] Add OAuth login flow for native MCP --- crates/core/src/auth/api/login.rs | 2 +- crates/core/src/auth/api/mod.rs | 6 +- crates/core/src/mcp.rs | 516 +++++++++++++++++++++++++++++- 3 files changed, 506 insertions(+), 18 deletions(-) diff --git a/crates/core/src/auth/api/login.rs b/crates/core/src/auth/api/login.rs index 4f757c1fa..a9053d0df 100644 --- a/crates/core/src/auth/api/login.rs +++ b/crates/core/src/auth/api/login.rs @@ -356,7 +356,7 @@ pub(crate) async fn build_auth_token_flow_response( /// /// An example using the two-step "authentication code flow" with PKCE can be found in /// `/examples/blog/flutter`. -async fn build_authorization_code_flow_and_pkce_response( +pub(crate) async fn build_authorization_code_flow_and_pkce_response( state: &AppState, db_user: &DbUser, redirect: String, diff --git a/crates/core/src/auth/api/mod.rs b/crates/core/src/auth/api/mod.rs index 67a5031c6..868109e72 100644 --- a/crates/core/src/auth/api/mod.rs +++ b/crates/core/src/auth/api/mod.rs @@ -3,15 +3,15 @@ pub(super) mod change_email; pub(super) mod change_password; pub(super) mod change_username; pub(super) mod delete; -pub(super) mod login; +pub(crate) mod login; pub(super) mod login_anonymous; pub(super) mod logout; pub(super) mod otp; pub(super) mod promote_anonymous; -pub(super) mod refresh; +pub(crate) mod refresh; pub(super) mod register; pub(super) mod reset_password; pub(super) mod status; -pub(super) mod token; +pub(crate) mod token; pub(super) mod totp; pub(super) mod verify_email; diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs index eaf020922..fa6f21e82 100644 --- a/crates/core/src/mcp.rs +++ b/crates/core/src/mcp.rs @@ -1,10 +1,11 @@ use std::sync::Arc; use axum::body::Body; -use axum::extract::{Request, State}; -use axum::http::{Method, header}; +use axum::extract::{Form, Json as AxumJson, Path, Query, Request, State}; +use axum::http::{HeaderValue, Method, StatusCode, header}; use axum::middleware::{self, Next}; -use axum::response::Response; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; use axum::{RequestExt, Router}; use http_body_util::BodyExt; use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters}; @@ -14,7 +15,7 @@ use rmcp::transport::{ streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, }; use rmcp::{Json, ServerHandler, schemars, tool, tool_handler, tool_router}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tower::ServiceExt; @@ -23,6 +24,9 @@ use crate::app_state::AppState; use crate::auth::util::is_admin; use crate::auth::{AuthError, User}; +const MCP_SCOPE: &str = "mcp"; +const MCP_PATH: &str = "/mcp"; + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct AdminRequest { /// HTTP method accepted by the TrailBase admin API. @@ -132,25 +136,464 @@ pub(crate) fn router(state: &AppState) -> Router { .disable_allowed_hosts(), ); + let protected_mcp = + Router::new() + .nest_service("/mcp", service) + .layer(middleware::from_fn_with_state( + state.clone(), + assert_mcp_access, + )); + Router::new() - .nest_service("/mcp", service) - .layer(middleware::from_fn_with_state( - state.clone(), - assert_mcp_access, - )) + .merge(protected_mcp) + .route( + "/.well-known/oauth-protected-resource", + get(protected_resource_metadata), + ) + .route( + "/.well-known/oauth-protected-resource/mcp", + get(protected_resource_metadata), + ) + .route( + "/.well-known/oauth-authorization-server", + get(authorization_server_metadata), + ) + .route("/_/mcp/authorize", get(authorize)) + .route("/_/mcp/callback/{flow}", get(authorization_callback)) + .route("/_/mcp/register", post(register_client)) + .route("/_/mcp/token", post(oauth_token)) } async fn assert_mcp_access( State(state): State, mut request: Request, next: Next, -) -> Result { - let user = request.extract_parts_with_state::(&state).await?; - if !is_admin(&state, &user.uuid).await { - return Err(AuthError::Forbidden); +) -> Response { + let authorized = match request.extract_parts_with_state::(&state).await { + Ok(user) => is_admin(&state, &user.uuid).await, + Err(_) => false, + }; + if authorized { + return next.run(request).await; + } + + let metadata_url = external_url(&state, "/.well-known/oauth-protected-resource"); + let mut response = AuthError::Unauthorized.into_response(); + if let Ok(value) = HeaderValue::from_str(&format!( + "Bearer resource_metadata=\"{metadata_url}\", scope=\"{MCP_SCOPE}\"" + )) { + response + .headers_mut() + .insert(header::WWW_AUTHENTICATE, value); + } + response +} + +#[derive(Serialize)] +struct ProtectedResourceMetadata { + resource: String, + authorization_servers: Vec, + scopes_supported: Vec<&'static str>, + bearer_methods_supported: Vec<&'static str>, +} + +async fn protected_resource_metadata( + State(state): State, +) -> AxumJson { + let issuer = external_url(&state, ""); + AxumJson(ProtectedResourceMetadata { + resource: external_url(&state, MCP_PATH), + authorization_servers: vec![issuer], + scopes_supported: vec![MCP_SCOPE], + bearer_methods_supported: vec!["header"], + }) +} + +#[derive(Serialize)] +struct AuthorizationServerMetadata { + issuer: String, + authorization_endpoint: String, + token_endpoint: String, + registration_endpoint: String, + response_types_supported: Vec<&'static str>, + grant_types_supported: Vec<&'static str>, + code_challenge_methods_supported: Vec<&'static str>, + token_endpoint_auth_methods_supported: Vec<&'static str>, + scopes_supported: Vec<&'static str>, +} + +async fn authorization_server_metadata( + State(state): State, +) -> AxumJson { + AxumJson(AuthorizationServerMetadata { + issuer: external_url(&state, ""), + authorization_endpoint: external_url(&state, "/_/mcp/authorize"), + token_endpoint: external_url(&state, "/_/mcp/token"), + registration_endpoint: external_url(&state, "/_/mcp/register"), + response_types_supported: vec!["code"], + grant_types_supported: vec!["authorization_code", "refresh_token"], + code_challenge_methods_supported: vec!["S256"], + token_endpoint_auth_methods_supported: vec!["none"], + scopes_supported: vec![MCP_SCOPE], + }) +} + +#[derive(Debug, Deserialize, Serialize)] +struct ClientRegistration { + #[serde(default)] + redirect_uris: Vec, + #[serde(default)] + client_name: Option, + #[serde(default)] + scope: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ClientClaims { + exp: i64, + iat: i64, + redirect_uris: Vec, + client_name: Option, +} + +#[derive(Serialize)] +struct ClientRegistrationResponse { + client_id: String, + client_id_issued_at: i64, + redirect_uris: Vec, + client_name: Option, + token_endpoint_auth_method: &'static str, + grant_types: Vec<&'static str>, + response_types: Vec<&'static str>, + scope: &'static str, +} + +async fn register_client( + State(state): State, + AxumJson(request): AxumJson, +) -> Result, OAuthError> { + if request.redirect_uris.is_empty() + || request + .redirect_uris + .iter() + .any(|uri| !valid_client_redirect(uri)) + || request + .scope + .as_deref() + .is_some_and(|scope| !scope.split(' ').any(|value| value == MCP_SCOPE)) + { + return Err(OAuthError::invalid_client_metadata("invalid redirect_uris")); + } + + let now = chrono::Utc::now().timestamp(); + let claims = ClientClaims { + iat: now, + exp: now + chrono::Duration::days(30).num_seconds(), + redirect_uris: request.redirect_uris.clone(), + client_name: request.client_name.clone(), + }; + let client_id = state + .jwt() + .encode(&claims) + .map_err(|err| OAuthError::server(err.to_string()))?; + + Ok(AxumJson(ClientRegistrationResponse { + client_id, + client_id_issued_at: now, + redirect_uris: request.redirect_uris, + client_name: request.client_name, + token_endpoint_auth_method: "none", + grant_types: vec!["authorization_code", "refresh_token"], + response_types: vec!["code"], + scope: MCP_SCOPE, + })) +} + +#[derive(Deserialize)] +struct AuthorizeQuery { + client_id: String, + redirect_uri: String, + response_type: String, + code_challenge: String, + code_challenge_method: String, + state: Option, + scope: Option, + resource: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +struct FlowClaims { + exp: i64, + redirect_uri: String, + client_id: String, + state: Option, +} + +async fn authorize( + State(state): State, + user: Option, + Query(query): Query, +) -> Result { + let client: ClientClaims = state + .jwt() + .decode(&query.client_id) + .map_err(|_| OAuthError::invalid_request("unknown or expired client_id"))?; + if query.response_type != "code" + || query.code_challenge_method != "S256" + || !client.redirect_uris.contains(&query.redirect_uri) + || query + .scope + .as_deref() + .is_some_and(|scope| !scope.split(' ').any(|s| s == MCP_SCOPE)) + || query + .resource + .as_deref() + .is_some_and(|resource| resource != external_url(&state, MCP_PATH)) + { + return Err(OAuthError::invalid_request("invalid authorization request")); + } + + let flow = state + .jwt() + .encode(&FlowClaims { + exp: chrono::Utc::now().timestamp() + chrono::Duration::minutes(10).num_seconds(), + redirect_uri: query.redirect_uri, + client_id: query.client_id, + state: query.state, + }) + .map_err(|err| OAuthError::server(err.to_string()))?; + let callback = format!("/_/mcp/callback/{flow}"); + let login_query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("redirect_uri", &callback) + .append_pair("response_type", "code") + .append_pair("pkce_code_challenge", &query.code_challenge) + .finish(); + + if let Some(user) = user { + if !is_admin(&state, &user.uuid).await { + return Err(OAuthError::access_denied( + "MCP access requires an administrator", + )); + } + let db_user = crate::auth::util::user_by_id(&state, &user.uuid) + .await + .map_err(OAuthError::from_auth)?; + return crate::auth::api::login::build_authorization_code_flow_and_pkce_response( + &state, + &db_user, + callback, + query.code_challenge, + ) + .await + .map_err(OAuthError::from_auth); + } + + Ok(Redirect::to(&format!("/_/auth/login?{login_query}")).into_response()) +} + +#[derive(Deserialize)] +struct CallbackQuery { + code: String, +} + +async fn authorization_callback( + State(state): State, + Path(flow): Path, + Query(query): Query, +) -> Result { + let flow: FlowClaims = state + .jwt() + .decode(&flow) + .map_err(|_| OAuthError::invalid_request("unknown or expired authorization flow"))?; + let mut redirect = url::Url::parse(&flow.redirect_uri) + .map_err(|_| OAuthError::invalid_request("invalid redirect_uri"))?; + redirect.query_pairs_mut().append_pair("code", &query.code); + if let Some(state) = flow.state { + redirect.query_pairs_mut().append_pair("state", &state); } + Ok(Redirect::to(redirect.as_str())) +} + +#[derive(Deserialize)] +struct TokenRequest { + grant_type: String, + code: Option, + code_verifier: Option, + refresh_token: Option, + client_id: Option, + redirect_uri: Option, + resource: Option, +} + +#[derive(Serialize)] +struct TokenResponse { + access_token: String, + token_type: &'static str, + expires_in: i64, + refresh_token: Option, + scope: &'static str, +} + +async fn oauth_token( + State(state): State, + Form(request): Form, +) -> Result, OAuthError> { + if request + .resource + .as_deref() + .is_some_and(|resource| resource != external_url(&state, MCP_PATH)) + { + return Err(OAuthError::invalid_grant("invalid resource")); + } + + let client_id = request + .client_id + .as_deref() + .ok_or_else(|| OAuthError::invalid_grant("missing client_id"))?; + let client: ClientClaims = state + .jwt() + .decode(client_id) + .map_err(|_| OAuthError::invalid_grant("unknown or expired client_id"))?; + + let (access_token, refresh_token) = match request.grant_type.as_str() { + "authorization_code" => { + let redirect_uri = request + .redirect_uri + .as_deref() + .ok_or_else(|| OAuthError::invalid_grant("missing redirect_uri"))?; + if !client.redirect_uris.iter().any(|uri| uri == redirect_uri) { + return Err(OAuthError::invalid_grant( + "redirect_uri does not match client", + )); + } + let code = request + .code + .ok_or_else(|| OAuthError::invalid_grant("missing code"))?; + let verifier = request + .code_verifier + .ok_or_else(|| OAuthError::invalid_grant("missing code_verifier"))?; + let AxumJson(tokens) = crate::auth::api::token::auth_code_to_token_handler( + State(state.clone()), + AxumJson(crate::auth::api::token::AuthCodeToTokenRequest { + authorization_code: Some(code), + pkce_code_verifier: Some(verifier), + }), + ) + .await + .map_err(OAuthError::from_auth)?; + (tokens.auth_token, Some(tokens.refresh_token)) + } + "refresh_token" => { + let refresh_token = request + .refresh_token + .ok_or_else(|| OAuthError::invalid_grant("missing refresh_token"))?; + let AxumJson(tokens) = crate::auth::api::refresh::refresh_handler( + State(state.clone()), + AxumJson(crate::auth::api::refresh::RefreshRequest { refresh_token }), + ) + .await + .map_err(OAuthError::from_auth)?; + (tokens.auth_token, None) + } + _ => return Err(OAuthError::invalid_grant("unsupported grant_type")), + }; + let claims = crate::auth::AuthTokenClaims::from_auth_token(state.jwt(), &access_token) + .map_err(|_| OAuthError::server("failed to decode issued access token"))?; - Ok(next.run(request).await) + Ok(AxumJson(TokenResponse { + access_token, + token_type: "Bearer", + expires_in: (claims.exp - chrono::Utc::now().timestamp()).max(0), + refresh_token, + scope: MCP_SCOPE, + })) +} + +#[derive(Debug)] +struct OAuthError { + status: StatusCode, + code: &'static str, + description: String, +} + +impl OAuthError { + fn invalid_request(description: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_request", + description: description.into(), + } + } + + fn invalid_client_metadata(description: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_client_metadata", + description: description.into(), + } + } + + fn invalid_grant(description: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_grant", + description: description.into(), + } + } + + fn access_denied(description: impl Into) -> Self { + Self { + status: StatusCode::FORBIDDEN, + code: "access_denied", + description: description.into(), + } + } + + fn server(description: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "server_error", + description: description.into(), + } + } + + fn from_auth(error: AuthError) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + code: "invalid_grant", + description: error.to_string(), + } + } +} + +impl IntoResponse for OAuthError { + fn into_response(self) -> Response { + ( + self.status, + AxumJson(json!({ "error": self.code, "error_description": self.description })), + ) + .into_response() + } +} + +fn valid_client_redirect(uri: &str) -> bool { + let Ok(uri) = url::Url::parse(uri) else { + return false; + }; + uri.scheme() == "https" + || (uri.scheme() == "http" && matches!(uri.host_str(), Some("localhost" | "127.0.0.1" | "::1"))) +} + +fn external_url(state: &AppState, path: &str) -> String { + let mut base = state + .site_url() + .as_ref() + .clone() + .unwrap_or_else(|| url::Url::parse("http://localhost:4000").expect("constant URL")); + base.set_path(path); + base.set_query(None); + base.set_fragment(None); + base.to_string().trim_end_matches('/').to_string() } fn normalize_admin_path(path: &str) -> Result { @@ -169,6 +612,7 @@ fn normalize_admin_path(path: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::app_state::test_state; #[test] fn normalizes_admin_paths() { @@ -179,4 +623,48 @@ mod tests { ); assert!(normalize_admin_path("https://example.com").is_err()); } + + #[tokio::test] + async fn registers_client_and_builds_pkce_login_redirect() { + let state = test_state(None).await.unwrap(); + let callback = "http://127.0.0.1:3334/oauth/callback".to_string(); + let AxumJson(registration) = register_client( + State(state.clone()), + AxumJson(ClientRegistration { + redirect_uris: vec![callback.clone()], + client_name: Some("test client".to_string()), + scope: Some(MCP_SCOPE.to_string()), + }), + ) + .await + .unwrap(); + + let redirect = authorize( + State(state), + None, + Query(AuthorizeQuery { + client_id: registration.client_id, + redirect_uri: callback, + response_type: "code".to_string(), + code_challenge: "ZmFrZS1jaGFsbGVuZ2U".to_string(), + code_challenge_method: "S256".to_string(), + state: Some("client-state".to_string()), + scope: Some(MCP_SCOPE.to_string()), + resource: None, + }), + ) + .await + .unwrap() + .into_response(); + + let location = redirect + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(); + assert!(location.starts_with("/_/auth/login?")); + assert!(location.contains("response_type=code")); + assert!(location.contains("pkce_code_challenge=")); + } } From 601f80bdfb42ff4382071eb543aa7f5c5533418b Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 15:43:19 +1200 Subject: [PATCH 22/31] Replace MCP sidecar with native deployment --- README.md | 17 +- crates/core/src/mcp.rs | 15 +- docker-compose.yml | 22 +- mcp/Dockerfile | 19 - mcp/README.md | 686 ++++------------ mcp/pyproject.toml | 27 - mcp/src/trailbase_mcp/__init__.py | 6 - mcp/src/trailbase_mcp/client.py | 739 ------------------ mcp/src/trailbase_mcp/endpoints.py | 370 --------- mcp/src/trailbase_mcp/proto/__init__.py | 0 mcp/src/trailbase_mcp/proto/config_api_pb2.py | 28 - mcp/src/trailbase_mcp/proto/config_pb2.py | 71 -- mcp/src/trailbase_mcp/server.py | 274 ------- mcp/tests/test_client.py | 550 ------------- 14 files changed, 160 insertions(+), 2664 deletions(-) delete mode 100644 mcp/Dockerfile delete mode 100644 mcp/pyproject.toml delete mode 100644 mcp/src/trailbase_mcp/__init__.py delete mode 100644 mcp/src/trailbase_mcp/client.py delete mode 100644 mcp/src/trailbase_mcp/endpoints.py delete mode 100644 mcp/src/trailbase_mcp/proto/__init__.py delete mode 100644 mcp/src/trailbase_mcp/proto/config_api_pb2.py delete mode 100644 mcp/src/trailbase_mcp/proto/config_pb2.py delete mode 100644 mcp/src/trailbase_mcp/server.py delete mode 100644 mcp/tests/test_client.py diff --git a/README.md b/README.md index 4a4e92f33..71e74acd1 100644 --- a/README.md +++ b/README.md @@ -141,20 +141,19 @@ trail components add trailbase/auth_ui endpoints, e.g. [http://localhost:4000/\_/auth/login](http://localhost:4000/_/auth/login). -## MCP sidecar +## MCP -This fork includes a FastMCP sidecar in [`mcp/`](mcp/) that exposes TrailBase's -admin and record APIs as MCP tools. It can run over stdio for local MCP clients -or as an HTTP sidecar in Docker Compose. +This fork includes an optional native MCP server in the main TrailBase binary. +It uses TrailBase administrator login through OAuth and runs on the same port as +TrailBase—no sidecar container or copied bearer token is required. ```sh -# Start TrailBase plus the MCP sidecar at http://localhost:8000/mcp. -TRAILBASE_AUTH_TOKEN=your-admin-token docker compose --profile mcp up --build +trail --public-url https://trailbase.example.com run --mcp ``` -Write-capable tools are disabled by default. Set -`TRAILBASE_MCP_ENABLE_WRITES=true` for the MCP process to allow create, update, -delete, or mutating SQL tools. +Connect an OAuth-capable MCP client to +`https://trailbase.example.com/mcp`. See the [MCP guide](mcp/README.md) for IDE, +Docker, Portainer, reverse-proxy, authentication, and security configuration. ## Building diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs index fa6f21e82..4b24156d2 100644 --- a/crates/core/src/mcp.rs +++ b/crates/core/src/mcp.rs @@ -9,7 +9,7 @@ use axum::routing::{get, post}; use axum::{RequestExt, Router}; use http_body_util::BodyExt; use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters}; -use rmcp::model::{ErrorData as McpError, ServerCapabilities, ServerInfo}; +use rmcp::model::{ErrorData as McpError, Implementation, ServerCapabilities, ServerInfo}; use rmcp::transport::{ StreamableHttpServerConfig, streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService}, @@ -117,9 +117,16 @@ impl TrailBaseMcp { #[tool_handler] impl ServerHandler for TrailBaseMcp { fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions( - "TrailBase's native administrative MCP server. Use call_admin_api to perform the same operations as the admin dashboard. Destructive operations modify the active TrailBase depot.", - ) + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info( + Implementation::new("trailbase", env!("CARGO_PKG_VERSION")) + .with_title("TrailBase MCP") + .with_description("Native administrative MCP server for TrailBase") + .with_website_url("https://trailbase.io"), + ) + .with_instructions( + "TrailBase's native administrative MCP server. Use call_admin_api to perform the same operations as the admin dashboard. Destructive operations modify the active TrailBase depot.", + ) } } diff --git a/docker-compose.yml b/docker-compose.yml index 465991803..5dbc2f437 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,22 +14,6 @@ services: - ${DATA_DIR:-.}/traildepot:/app/traildepot environment: RUST_BACKTRACE: "1" - # command: "/app/trail --data-dir /app/traildepot run --address 0.0.0.0:4000" - - mcp: - profiles: - - mcp - build: - context: ./mcp - depends_on: - - trail - ports: - - "${MCP_PORT:-8000}:8000" - restart: unless-stopped - environment: - TRAILBASE_URL: "http://trail:4000" - TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN:-}" - TRAILBASE_MCP_ENABLE_WRITES: "${TRAILBASE_MCP_ENABLE_WRITES:-false}" - MCP_TRANSPORT: "http" - MCP_HOST: "0.0.0.0" - MCP_PORT: "8000" + # Add --mcp to expose the authenticated native MCP endpoint at /mcp. + # Also set --public-url to the external HTTPS origin used by MCP clients. + # command: "/app/trail --data-dir /app/traildepot --public-url https://trailbase.example.com run --address 0.0.0.0:4000 --mcp" diff --git a/mcp/Dockerfile b/mcp/Dockerfile deleted file mode 100644 index a690853ef..000000000 --- a/mcp/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM python:3.12-slim - -LABEL org.opencontainers.image.title="TrailBase MCP sidecar" -LABEL org.opencontainers.image.description="FastMCP sidecar server for TrailBase" -LABEL org.opencontainers.image.version="0.2.0" -LABEL org.opencontainers.image.source="https://github.com/trailbaseio/trailbase" -LABEL org.opencontainers.image.url="https://hub.docker.com/r/frostbite4456/trailbase-mcp" - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 - -WORKDIR /app -COPY pyproject.toml README.md ./ -COPY src ./src - -RUN pip install --no-cache-dir . - -EXPOSE 8000 -CMD ["python", "-m", "trailbase_mcp.server"] diff --git a/mcp/README.md b/mcp/README.md index 070091cf2..1db143d70 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,107 +1,81 @@ -# TrailBase MCP sidecar - -This package exposes TrailBase through a FastMCP server. It talks to TrailBase -over HTTP and uses the existing admin and record APIs. - -Use it as a sidecar container next to TrailBase. The MCP container does not -store TrailBase data; it forwards MCP tool calls to a running TrailBase server. - -## Features - -- TrailBase admin/runtime info. -- Admin config read/update, including Record API configuration. -- SQL execution with a default read-only guard. -- Table, view, index, and trigger introspection. -- Safe table teardown helper that removes Record API config before dropping a - table. -- Record API CRUD. -- Record API list query passthrough, including filters, sorting, pagination, - cursors, `geojson`, `limit`, and `skip_cursor`. -- Record API JSON schemas for `Insert`, `Select`, and `Update`. -- JSON/base64 and multipart file uploads for `std.FileUpload` and - `std.FileUploads`. -- File download as base64. -- MCP-side TrailBase login and refresh-token handling for container deployments. -- TrailBase OpenAPI operation catalog with call-by-`operation_id` support for - auth, OAuth, and Record API operations. -- Generic `trailbase_request` tool for custom WASM APIs, auth APIs, and other - TrailBase HTTP endpoints. - -## Image tags - -Published image: +# TrailBase MCP + +TrailBase includes an optional native MCP server in the main `trail` binary. It +runs in the same process and container as TrailBase, on the same HTTP port: ```text -frostbite4456/trailbase-mcp +TrailBase admin UI: https://trailbase.example.com/_/admin/ +TrailBase MCP: https://trailbase.example.com/mcp ``` -Recommended tags: - -- `frostbite4456/trailbase-mcp:0.2` - pinned release. -- `frostbite4456/trailbase-mcp:latest` - current release. +There is no MCP sidecar, second Docker image, second port, shared depot mount, +or manually copied bearer token. MCP clients use OAuth to open TrailBase's own +login UI. After an administrator signs in, TrailBase issues and refreshes the +tokens used by the client. -## Quick start with Docker +## Enable MCP -Run TrailBase separately, then run this MCP sidecar against it. +MCP is disabled by default because it exposes privileged development and +administration tools. Enable it with `--mcp`: ```sh -docker run --rm -p 8000:8000 \ - -e TRAILBASE_URL=http://host.docker.internal:4000 \ - -e TRAILBASE_AUTH_TOKEN=your-admin-jwt-without-bearer-prefix \ - -e TRAILBASE_MCP_ENABLE_WRITES=false \ - -e MCP_TRANSPORT=http \ - -e MCP_HOST=0.0.0.0 \ - -e MCP_PORT=8000 \ - frostbite4456/trailbase-mcp:0.2 +trail --public-url https://trailbase.example.com run \ + --address 0.0.0.0:4000 \ + --mcp ``` -Or let the sidecar log in to TrailBase and keep returned tokens in memory: +`--public-url` must be the external HTTPS origin clients use. TrailBase uses it +in OAuth discovery metadata and validates the MCP resource audience against it. +Use HTTPS outside localhost. + +The auth UI must also be installed so the browser login page is available: ```sh -docker run --rm -p 8000:8000 \ - -e TRAILBASE_URL=http://host.docker.internal:4000 \ - -e TRAILBASE_LOGIN_EMAIL=admin@localhost \ - -e TRAILBASE_LOGIN_PASSWORD=your-admin-password \ - -e TRAILBASE_MCP_ENABLE_WRITES=false \ - -e MCP_TRANSPORT=http \ - -e MCP_HOST=0.0.0.0 \ - -e MCP_PORT=8000 \ - frostbite4456/trailbase-mcp:0.2 +trail components add trailbase/auth_ui ``` -Or provide the token via a mounted file: +The official Docker image already contains the auth UI component. -```sh -docker run --rm -p 8000:8000 \ - -v /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro \ - -e TRAILBASE_URL=http://host.docker.internal:4000 \ - -e TRAILBASE_AUTH_TOKEN_FILE=/run/secrets/trailbase_auth_token \ - -e TRAILBASE_MCP_ENABLE_WRITES=false \ - -e MCP_TRANSPORT=http \ - -e MCP_HOST=0.0.0.0 \ - -e MCP_PORT=8000 \ - frostbite4456/trailbase-mcp:0.2 -``` +## IDE configuration -The MCP endpoint is: +Clients with native remote-MCP and OAuth support can connect directly to: ```text -http://localhost:8000/mcp +https://trailbase.example.com/mcp ``` -Do not test `/mcp` in a browser. Use an MCP client. A browser or plain `curl` -request can return `Not Acceptable: Client must accept text/event-stream`, -which is expected for MCP over HTTP. +For IDEs that accept only local command-based MCP servers, use `mcp-remote`: -## Portainer / Docker Compose stack +```json +{ + "mcpServers": { + "trailbase": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://trailbase.example.com/mcp", + "--static-oauth-client-metadata", + "{\"scope\":\"mcp\"}" + ] + } + } +} +``` -This example uses the published Docker Hub image. Pin `0.2` for reproducible -deployments or use `latest` when you intentionally want the current release: -`frostbite4456/trailbase-mcp:0.2`. +On the first connection, the client opens a browser at TrailBase's login page. +Sign in with a TrailBase administrator account. Credentials are submitted only +to that TrailBase instance; they are not stored in the IDE configuration or +sent through MCP tool arguments. Existing TrailBase MFA and external identity +provider flows continue to apply. -The quick-start credential path is TrailBase login email/password. The MCP -sidecar logs in at startup, keeps returned auth/refresh/CSRF tokens in memory, -and refreshes auth when needed. +If the client caches an old failed registration or token, clear its MCP OAuth +cache and reconnect. + +## Docker and Portainer + +The native server uses the normal `trailbase/trailbase` image. A single-service +Portainer stack is sufficient: ```yaml services: @@ -111,499 +85,115 @@ services: - "4000:4000" restart: unless-stopped volumes: - - /opt/trailbase/traildepot:/app/traildepot + - /mnt/traildepot:/app/traildepot environment: RUST_BACKTRACE: "1" - - mcp: - image: frostbite4456/trailbase-mcp:0.2 - depends_on: - - trail - ports: - - "8000:8000" - restart: unless-stopped - environment: - TRAILBASE_URL: "http://trail:4000" - TRAILBASE_LOGIN_EMAIL: "${TRAILBASE_LOGIN_EMAIL:-admin@localhost}" - TRAILBASE_LOGIN_PASSWORD: "${TRAILBASE_LOGIN_PASSWORD}" - TRAILBASE_MCP_ENABLE_WRITES: "false" - MCP_TRANSPORT: "http" - MCP_HOST: "0.0.0.0" - MCP_PORT: "8000" -``` - -Create the TrailBase data directory before deploying the stack: - -```sh -sudo mkdir -p /opt/trailbase/traildepot -sudo chown -R 1000:1000 /opt/trailbase/traildepot -``` - -If you see TrailBase permission errors, verify the UID used by your TrailBase -image or temporarily relax permissions to confirm the mount is the issue. - -In Portainer, set these stack environment variables: - -```env -TRAILBASE_LOGIN_EMAIL=admin@localhost -TRAILBASE_LOGIN_PASSWORD=your-admin-password -``` - -This is usually smoother than copying bearer tokens between the TrailBase UI, -Bruno, and Portainer. The sidecar logs in through TrailBase's -`/api/auth/v1/login` endpoint and does not write the returned tokens to disk. - -### Alternative: auth token and refresh token - -If you prefer to paste tokens instead of storing a login password, replace the -login variables in the `mcp` service with: - -```yaml - environment: - TRAILBASE_URL: "http://trail:4000" - TRAILBASE_AUTH_TOKEN: "${TRAILBASE_AUTH_TOKEN}" - TRAILBASE_REFRESH_TOKEN: "${TRAILBASE_REFRESH_TOKEN:-}" - TRAILBASE_MCP_ENABLE_WRITES: "false" - MCP_TRANSPORT: "http" - MCP_HOST: "0.0.0.0" - MCP_PORT: "8000" -``` - -Set `TRAILBASE_AUTH_TOKEN` to either the raw JWT or the full `Bearer ...` -output; the sidecar strips the `Bearer ` prefix automatically. Optionally set -`TRAILBASE_REFRESH_TOKEN` too. When present, the sidecar refreshes an expired or -near-expired auth token through TrailBase's `/api/auth/v1/refresh` endpoint. - -### Alternative: mounted credential files - -If you prefer not to store credentials directly in the stack environment, use -mounted files. For login/password mode, mount only the password: - -```yaml - mcp: - image: frostbite4456/trailbase-mcp:0.2 - volumes: - - /opt/trailbase/secrets/trailbase-login-password:/run/secrets/trailbase_login_password:ro - environment: - TRAILBASE_URL: "http://trail:4000" - TRAILBASE_LOGIN_EMAIL: "admin@localhost" - TRAILBASE_LOGIN_PASSWORD_FILE: "/run/secrets/trailbase_login_password" - TRAILBASE_MCP_ENABLE_WRITES: "false" - MCP_TRANSPORT: "http" - MCP_HOST: "0.0.0.0" - MCP_PORT: "8000" -``` - -Create the password file once on the Docker host: - -```sh -sudo mkdir -p /opt/trailbase/secrets -sudo sh -c 'printf "%s" "PASTE_LOGIN_PASSWORD_HERE" > /opt/trailbase/secrets/trailbase-login-password' -sudo chmod 600 /opt/trailbase/secrets/trailbase-login-password -``` - -For token mode, mount token files instead: - -```yaml - mcp: - image: frostbite4456/trailbase-mcp:0.2 - volumes: - - /opt/trailbase/secrets/trailbase-token:/run/secrets/trailbase_auth_token:ro - - /opt/trailbase/secrets/trailbase-refresh-token:/run/secrets/trailbase_refresh_token:ro - environment: - TRAILBASE_URL: "http://trail:4000" - TRAILBASE_AUTH_TOKEN_FILE: "/run/secrets/trailbase_auth_token" - TRAILBASE_REFRESH_TOKEN_FILE: "/run/secrets/trailbase_refresh_token" - TRAILBASE_MCP_ENABLE_WRITES: "false" - MCP_TRANSPORT: "http" - MCP_HOST: "0.0.0.0" - MCP_PORT: "8000" -``` - -```sh -sudo mkdir -p /opt/trailbase/secrets -sudo sh -c 'printf "%s" "PASTE_RAW_JWT_HERE" > /opt/trailbase/secrets/trailbase-token' -sudo sh -c 'printf "%s" "PASTE_REFRESH_TOKEN_HERE" > /opt/trailbase/secrets/trailbase-refresh-token' -sudo chmod 600 /opt/trailbase/secrets/trailbase-token -sudo chmod 600 /opt/trailbase/secrets/trailbase-refresh-token -``` - -Token files may contain either the raw token or the full `Bearer ...` output; -the sidecar strips the `Bearer ` prefix automatically. Password files are read -as plain text. - -## Configuration - -Environment variables: - -| Variable | Default | Description | -| --- | --- | --- | -| `TRAILBASE_URL` | `http://localhost:4000` | TrailBase base URL. In Compose, use the TrailBase service name, e.g. `http://trail:4000`. | -| `TRAILBASE_AUTH_TOKEN` / `TRAILBASE_TOKEN` | unset | Admin or user JWT used for TrailBase API calls. Raw JWT is preferred; a leading `Bearer ` prefix is also accepted. | -| `TRAILBASE_AUTH_TOKEN_FILE` / `TRAILBASE_TOKEN_FILE` | unset | Path to a file containing the JWT. Useful for Portainer, Docker secrets, and bind-mounted secret files. | -| `TRAILBASE_REFRESH_TOKEN` | unset | Optional TrailBase refresh token. If the auth token is absent, expired, or near expiry, the sidecar uses this to fetch a fresh auth token. | -| `TRAILBASE_REFRESH_TOKEN_FILE` | unset | Path to a file containing the refresh token. | -| `TRAILBASE_LOGIN_EMAIL` / `TRAILBASE_ADMIN_EMAIL` | unset | Optional TrailBase login email. If no valid auth token is available, the sidecar logs in and keeps returned tokens in memory. | -| `TRAILBASE_LOGIN_EMAIL_FILE` / `TRAILBASE_ADMIN_EMAIL_FILE` | unset | Path to a file containing the login email. | -| `TRAILBASE_LOGIN_PASSWORD` / `TRAILBASE_ADMIN_PASSWORD` | unset | Optional TrailBase login password. Use with `TRAILBASE_LOGIN_EMAIL`. | -| `TRAILBASE_LOGIN_PASSWORD_FILE` / `TRAILBASE_ADMIN_PASSWORD_FILE` | unset | Path to a file containing the login password. | -| `TRAILBASE_CSRF_TOKEN` | derived from JWT | Optional explicit CSRF token. Normally not needed for TrailBase-minted JWTs. | -| `TRAILBASE_MCP_ENABLE_WRITES` | `false` | Set to `true` to enable create/update/delete tools, mutating SQL, config updates, and mutating generic HTTP calls. | -| `TRAILBASE_MCP_TIMEOUT` | `30` | HTTP timeout in seconds. | -| `MCP_TRANSPORT` | `stdio` | Use `http` for container/remote MCP. | -| `MCP_HOST` | `127.0.0.1` | HTTP bind host. Use `0.0.0.0` in Docker. | -| `MCP_PORT` | `8000` | HTTP bind port. | - -Mint an admin bearer token with TrailBase: - -```sh -cargo run --bin trail -- --data-dir ./traildepot user mint-token admin@localhost -``` - -Inside the official TrailBase container this is typically: - -```sh -/app/trail --data-dir /app/traildepot user mint-token admin@localhost -``` - -The command prints a value like: - -```text -Bearer eyJhbGciOi... -``` - -Set `TRAILBASE_AUTH_TOKEN` to only the JWT part: - -```text -TRAILBASE_AUTH_TOKEN=eyJhbGciOi... -``` - -For Docker/Portainer deployments, `TRAILBASE_AUTH_TOKEN` is the simplest path. -`TRAILBASE_AUTH_TOKEN_FILE` is available when you prefer a bind-mounted file or -Docker secret. - -### Getting tokens through the login API - -TrailBase's login endpoint is: - -```text -POST /api/auth/v1/login -``` - -Reference: - -In Bruno or another API client, post JSON like: - -```json -{ - "email": "admin@localhost", - "password": "your-admin-password", - "response_type": "token" -} -``` - -The response contains: + command: + - /app/trail + - --data-dir + - /app/traildepot + - --public-url + - https://trailbase.example.com + - run + - --address + - 0.0.0.0:4000 + - --mcp +``` + +Point the existing Cloudflare Tunnel or reverse proxy at port `4000`. The same +hostname serves both TrailBase and `/mcp`; no public port `4001` or `8000` is +needed. Do not place Cloudflare Access or another interactive login layer in +front of only `/mcp`, because MCP clients need to reach TrailBase's OAuth +discovery and authorization endpoints. TLS termination at Cloudflare or the +reverse proxy is expected. + +## Authentication and security + +The native MCP implementation follows the HTTP MCP authorization flow: + +- OAuth Protected Resource Metadata (RFC 9728). +- OAuth Authorization Server Metadata (RFC 8414). +- Dynamic Client Registration (RFC 7591). +- Authorization Code flow with PKCE S256. +- Access-token refresh. +- `WWW-Authenticate` discovery on unauthenticated MCP requests. + +Only a currently valid TrailBase administrator can use MCP. TrailBase verifies +administrator status against the database for every MCP HTTP request rather +than trusting the potentially stale `admin` claim in a token. Registering an +OAuth client does not grant access. + +Treat MCP as an administrative surface: + +- Enable it only when needed. +- Require HTTPS on remote deployments. +- Keep the TrailBase admin login protected with a strong password and MFA. +- Restrict the hostname at the firewall, VPN, Cloudflare policy, or reverse + proxy when broad internet access is unnecessary. +- Review tool calls before approving destructive schema or data changes. + +## Tools + +`call_admin_api(method, path, body?)` dispatches directly to TrailBase's +in-process admin router. `path` is relative to `/api/_admin`; it may also be the +full `/api/_admin/...` path. This means MCP and the dashboard use the same Rust +handlers and cannot drift into separate API implementations. + +Examples: ```json { - "auth_token": "...", - "csrf_token": "...", - "refresh_token": "..." + "method": "GET", + "path": "tables" } ``` -For a long-running MCP container, either let MCP log in directly: - -```env -TRAILBASE_LOGIN_EMAIL=admin@localhost -TRAILBASE_LOGIN_PASSWORD= -``` - -or set the returned tokens: - -```env -TRAILBASE_AUTH_TOKEN= -TRAILBASE_REFRESH_TOKEN= -``` - -When login credentials are configured, MCP keeps the returned tokens in memory -and can log in again after refresh expiry. When tokens are configured directly, -the auth token follows TrailBase's auth-token TTL, while the refresh token lets -the sidecar refresh auth automatically without updating Portainer every time the -auth token expires. - -### Token lifetime and rotation - -TrailBase JWTs can expire. Do not assume a token copied from an API client or -browser login is long-lived; it may follow the dashboard's normal auth-token -TTL. - -Prefer a CLI-minted token for the MCP sidecar: - -```sh -/app/trail --data-dir /app/traildepot user mint-token admin@localhost -``` - -Then check its `exp` claim before deploying it: - -```sh -TOKEN='paste-jwt-or-bearer-output-here' python3 - <<'PY' -import base64, json, os, time - -token = os.environ["TOKEN"].strip() -if token.lower().startswith("bearer "): - token = token[7:].strip() - -payload = token.split(".")[1] -payload += "=" * (-len(payload) % 4) -claims = json.loads(base64.urlsafe_b64decode(payload)) -print(json.dumps(claims, indent=2, sort_keys=True)) -if "exp" in claims: - print("expires_in_seconds:", claims["exp"] - int(time.time())) -PY -``` - -If the token expires, mint a new token, update the token file or Portainer -environment variable, and restart/redeploy the MCP container. The sidecar reads -the token at startup. - -## Credential model - -Most Dockerized MCP servers use one of these patterns: - -- local/desktop MCP clients pass credentials as environment variables in the - MCP client config; -- remote/container MCP servers receive credentials from Docker/Portainer - environment variables or secrets; -- OAuth-enabled remote MCP servers perform a separate MCP auth flow. - -This sidecar currently uses the second pattern. The MCP client, IntelliJ, or -other frontend connects to the MCP endpoint; the sidecar uses its configured -TrailBase token when it calls TrailBase. That keeps TrailBase credentials out -of individual MCP prompts and avoids requiring every MCP client to understand -TrailBase auth. - -Auto-minting a token from inside the MCP container is possible, but it requires -mounting the TrailBase data directory and shipping the `trail` binary in the -MCP image. That gives the MCP container admin-level depot access. A token file -or Docker secret is usually simpler to operate and easier to reason about. - -## Run with stdio - -```sh -cd mcp -python -m venv .venv -. .venv/bin/activate -pip install -e . -TRAILBASE_URL=http://localhost:4000 \ -TRAILBASE_AUTH_TOKEN='your-token-without-the-Bearer-prefix' \ -python -m trailbase_mcp.server -``` - -Example MCP client config: - ```json { - "mcpServers": { - "trailbase": { - "command": "python", - "args": ["-m", "trailbase_mcp.server"], - "env": { - "TRAILBASE_URL": "http://localhost:4000", - "TRAILBASE_AUTH_TOKEN": "your-token" - } - } + "method": "POST", + "path": "query", + "body": { + "query": "CREATE TABLE candy (id INTEGER PRIMARY KEY, name TEXT NOT NULL)" } } ``` -## Run with Docker Compose - -For local development from this repository, the root `docker-compose.yml` -includes an opt-in `mcp` profile: - -```sh -TRAILBASE_AUTH_TOKEN=your-token docker compose --profile mcp up --build -``` +Available paths are the same ones used by the admin dashboard, including table, +index, row, file, configuration, JSON Schema, SQL query, user, log, job, backup, +OAuth-provider, and WASM-component operations. TrailBase's normal demo-mode and +handler-level safety checks still apply. -The MCP HTTP endpoint is exposed at `http://localhost:8000/mcp`. - -## Browser and endpoint notes - -- TrailBase's root path (`/`) may return `404`. Use the admin UI path: - `http://localhost:4000/_/admin/`. -- The MCP HTTP endpoint is not a browser UI. Opening `/mcp` directly in a - browser or plain `curl` request can return: - `Not Acceptable: Client must accept text/event-stream`. Use an MCP client, - such as FastMCP's `Client("http://localhost:8000/mcp")`, which sends the - required streaming headers. - -## Record API file and schema tools - -The sidecar exposes TrailBase Record API schemas and file helpers in addition -to normal CRUD: - -- `trailbase_request(method, path, params?, body?)`: call any server-relative - TrailBase HTTP endpoint. Use this for auth endpoints, custom WASM APIs, and - OpenAPI endpoints not covered by specialized MCP tools. Non-readonly methods - require `TRAILBASE_MCP_ENABLE_WRITES=true`. -- `list_trailbase_api_operations(category?)`: list the TrailBase OpenAPI - operations known to MCP. `category` may be `auth`, `oauth`, or `records`. - Each entry includes `operation_id`, method, path template, mutation gate, and - the recommended MCP support path. -- `call_trailbase_api_operation(operation_id, path_params?, params?, body?)`: - call a known TrailBase OpenAPI operation by `operation_id`. `path_params` - fills placeholders such as `{"name": "todos", "record": "id"}`. Mutating - operations require `TRAILBASE_MCP_ENABLE_WRITES=true`. -- `list_records(api_name, query?)`: forwards `query` as Record API URL query - parameters. For example: - `{"geojson": "geometry", "limit": 1024, "skip_cursor": "true"}` maps to - `?geojson=geometry&limit=1024&skip_cursor=true`. Cursor pagination works the - same way with `{"cursor": ""}`. -- `get_api_json_schema(api_name, mode?, admin?)`: read a schema from - `/api/records/v1//schema`. `mode` may be `Insert`, `Select`, or - `Update`. Set `admin=true` to use the admin schema endpoint. -- `create_record_with_file_uploads(api_name, record, files)`: create a record - using JSON/base64 file upload inputs. Each file needs `field` plus either - `content_base64` or `file_path`; optional fields are `filename`, - `content_type`, and `multiple`. -- `create_record_multipart(api_name, fields, files)`: create a record as - `multipart/form-data` using the same file descriptors. -- `download_file(api_name, record_id, column_name, file_name?)`: download a - `std.FileUpload` or `std.FileUploads` file and return `content_base64`. -- `remove_record_api(api_name?, table_name?)`: remove Record API config entries - by API name or backing table name. Use this before manually dropping a table. -- `drop_table(table_name, remove_record_apis?)`: drop a table. By default this - removes any Record API entries whose `table_name` matches before running - `DROP TABLE IF EXISTS`. - -### Record API primary keys - -TrailBase Record APIs require a compatible primary key. If MCP creates a table -and then exposes it as a Record API, use either: - -```sql -id INTEGER PRIMARY KEY -``` +Schema-changing dashboard handlers write migrations and rebuild metadata in the +same way when called through MCP. Raw SQL through `query` also rebuilds schema +metadata for recognized table/view changes. -or a TrailBase-compatible UUID primary key. Do not use `TEXT PRIMARY KEY` for a -table that should become a Record API. +## Direct bearer-token clients -If the primary key is not compatible, TrailBase rejects the config update with -an error like: +OAuth is recommended. A client that can explicitly set HTTP headers may instead +send an existing TrailBase administrator access token: ```text -Does not have a suitable PRIMARY KEY column. At this point TrailBase requires -PRIMARY KEYS to be of type INTEGER or UUID. -``` - -Recommended simple table shape: - -```sql -CREATE TABLE candyland ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL -) STRICT; +Authorization: Bearer ``` -### Deleting tables that have Record APIs +The access token returned by `/api/auth/v1/login` is short-lived. Clients using +this mode must manage `/api/auth/v1/refresh` themselves. Do not put an admin +password or long-lived refresh token in a shared project configuration. -Remove Record API config before dropping the backing table. Otherwise TrailBase -can retain a config entry that references a missing table. +## Development validation -Preferred MCP path: +Run the native MCP unit tests with: -```text -drop_table(table_name="candyland") +```sh +cargo test -p trailbase --lib mcp::tests ``` -That removes Record APIs whose `table_name` is `candyland` first, then drops the -table. If you want to do it manually: +For an isolated manual test: -```text -remove_record_api(table_name="candyland") -execute_sql(query="DROP TABLE IF EXISTS candyland", allow_mutation=true) +```sh +trail --depot "$(mktemp -d)" \ + --public-url http://127.0.0.1:4100 \ + run --address 127.0.0.1:4100 --dev --mcp ``` -## MCP tools - -Current tools: - -- `trailbase_info` -- `trailbase_config` -- `update_config` -- `list_record_apis` -- `remove_record_api` -- `drop_table` -- `list_tables` -- `execute_sql` -- `trailbase_request` -- `list_trailbase_api_operations` -- `call_trailbase_api_operation` -- `list_records` -- `get_record` -- `create_record` -- `update_record` -- `delete_record` -- `get_api_json_schema` -- `create_record_with_file_uploads` -- `create_record_multipart` -- `download_file` - -## Security notes - -Treat this sidecar like an admin surface when configured with an admin token. - -- Do not expose `/mcp` directly to the public internet. -- Prefer private Docker networks, VPN, mTLS, or an authenticated reverse proxy. -- Keep `TRAILBASE_MCP_ENABLE_WRITES=false` unless the MCP client explicitly - needs mutation/config/SQL write access. -- Use a least-privilege TrailBase token when possible. Admin tokens are required - for admin config and SQL tools. -- `trailbase_request` only accepts server-relative paths and cannot proxy to - arbitrary external URLs. - -## Known limitations - -- Realtime subscriptions are not exposed as a long-running MCP stream in this - release. -- TrailBase migrations remain filesystem/CLI driven. MCP can run SQL, but it is - not a production migration runner. -- The sidecar does not generate language bindings itself; use - `get_api_json_schema` and an external generator such as quicktype. - -## TrailBase documentation compatibility - -The MCP sidecar intentionally delegates to TrailBase's public/admin HTTP APIs -instead of reimplementing TrailBase behavior. Current coverage: - -- Models & Relations: use `execute_sql` for STRICT tables, constraints, - indexes, triggers, views, generated columns, geometry columns, and relations; - use `update_config` to expose tables/views as Record APIs and configure - `expand`. For tables intended for Record API access, use an `INTEGER PRIMARY - KEY` or TrailBase-compatible UUID primary key. -- Migrations: TrailBase migrations are filesystem/CLI driven - (`traildepot/migrations`, `trail migration`, restart/SIGHUP). MCP can apply - SQL through `execute_sql`, but it is not a migration runner and should not - replace append-only production migrations. -- Type-Safety: use `get_api_json_schema` with `mode` `Insert`, `Select`, or - `Update`; feed those schemas into external generators such as quicktype. -- Production: run MCP as a sidecar container and do not expose it publicly - unless it is protected like an admin surface. The `/mcp` endpoint requires an - MCP client that accepts `text/event-stream`. -- Custom APIs: use `trailbase_request` for TrailBase WASM/custom routes. -- Record APIs: CRUD, list filters/sort/pagination/cursor/geojson query params, - schema, JSON/base64 file upload, multipart upload, and file download are - supported. -- OpenAPI operation pages: `list_trailbase_api_operations` exposes the auth, - OAuth, and Record API operations documented under TrailBase's OpenAPI pages. - Use `call_trailbase_api_operation` when you want to call by operation id - instead of manually composing a URL. -- Auth: use MCP-side login/refresh configuration for the sidecar's own - credentials. Use `call_trailbase_api_operation` or `trailbase_request` for - TrailBase user auth flows such as registration, password reset, email - verification, MFA/TOTP, logout, OAuth provider listing/login/callback, and - avatar endpoints. -- Realtime subscriptions: the TrailBase SSE subscription endpoint is listed in - the operation catalog, but this MCP sidecar does not proxy long-running - streams through a request/response tool. +Use only a disposable depot for destructive integration tests. diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml deleted file mode 100644 index 97cf6c197..000000000 --- a/mcp/pyproject.toml +++ /dev/null @@ -1,27 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "trailbase-mcp" -version = "0.2.0" -description = "FastMCP sidecar server for TrailBase" -readme = "README.md" -requires-python = ">=3.11" -dependencies = [ - "fastmcp>=3.4.4", - "httpx>=0.28.1", - "protobuf>=5.29.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.4.0", -] - -[tool.hatch.build.targets.wheel] -packages = ["src/trailbase_mcp"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["src"] diff --git a/mcp/src/trailbase_mcp/__init__.py b/mcp/src/trailbase_mcp/__init__.py deleted file mode 100644 index 32bbb2942..000000000 --- a/mcp/src/trailbase_mcp/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""FastMCP integration for TrailBase.""" - -from .client import TrailBaseClient -from .server import mcp - -__all__ = ["TrailBaseClient", "mcp"] diff --git a/mcp/src/trailbase_mcp/client.py b/mcp/src/trailbase_mcp/client.py deleted file mode 100644 index 8b1cbff6c..000000000 --- a/mcp/src/trailbase_mcp/client.py +++ /dev/null @@ -1,739 +0,0 @@ -from __future__ import annotations - -import os -import re -import base64 -import binascii -import json -import time -from dataclasses import dataclass -from pathlib import Path -from typing import Any -from urllib.parse import quote - -import httpx -from google.protobuf.json_format import MessageToDict, ParseDict - -from .proto import config_api_pb2 - - -TRUE_VALUES = {"1", "true", "yes", "on"} -READONLY_SQL_STARTERS = {"select", "with", "pragma", "explain"} -READONLY_HTTP_METHODS = {"GET", "HEAD", "OPTIONS"} -SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") - - -def env_flag(name: str, default: bool = False) -> bool: - value = os.getenv(name) - if value is None: - return default - return value.strip().lower() in TRUE_VALUES - - -def normalize_auth_token(token: str | None) -> str | None: - if token is None: - return None - token = token.strip() - if not token: - return None - if token.lower().startswith("bearer "): - return token[7:].strip() or None - return token - - -def read_text_file(path: str | None) -> str | None: - if not path: - return None - value = Path(path).read_text().strip() - return value or None - - -def read_secret_file(path: str | None) -> str | None: - return normalize_auth_token(read_text_file(path)) - - -def env_or_file(value_name: str, file_name: str) -> str | None: - value = os.getenv(value_name) - if value is not None and value.strip(): - return value.strip() - return read_text_file(os.getenv(file_name)) - - -def auth_token_from_env() -> str | None: - return normalize_auth_token( - os.getenv("TRAILBASE_AUTH_TOKEN") or os.getenv("TRAILBASE_TOKEN") - ) or read_secret_file( - os.getenv("TRAILBASE_AUTH_TOKEN_FILE") - or os.getenv("TRAILBASE_TOKEN_FILE") - ) - - -def refresh_token_from_env() -> str | None: - return normalize_auth_token(os.getenv("TRAILBASE_REFRESH_TOKEN")) or read_secret_file( - os.getenv("TRAILBASE_REFRESH_TOKEN_FILE") - ) - - -def login_email_from_env() -> str | None: - return ( - env_or_file("TRAILBASE_LOGIN_EMAIL", "TRAILBASE_LOGIN_EMAIL_FILE") - or env_or_file("TRAILBASE_ADMIN_EMAIL", "TRAILBASE_ADMIN_EMAIL_FILE") - ) - - -def login_password_from_env() -> str | None: - return ( - env_or_file("TRAILBASE_LOGIN_PASSWORD", "TRAILBASE_LOGIN_PASSWORD_FILE") - or env_or_file("TRAILBASE_ADMIN_PASSWORD", "TRAILBASE_ADMIN_PASSWORD_FILE") - ) - - -def quote_segment(value: str) -> str: - return quote(value, safe="") - - -def quote_sql_identifier(value: str) -> str: - if not isinstance(value, str) or not SQL_IDENTIFIER_RE.fullmatch(value): - raise ValueError( - "SQL identifier must start with a letter or underscore and contain " - "only letters, digits, and underscores" - ) - return f'"{value}"' - - -def validate_relative_path(path: str) -> str: - if not isinstance(path, str): - raise ValueError("path must be server-relative and start with '/'") - if path.startswith("//") or "://" in path: - raise ValueError("path must not be an absolute URL") - if not path.startswith("/"): - raise ValueError("path must be server-relative and start with '/'") - return path - - -def base64_file_contents(file: dict[str, Any]) -> str: - content_base64 = file.get("content_base64") or file.get("data") - file_path = file.get("file_path") or file.get("path") - - if content_base64 is not None and file_path is not None: - raise ValueError("Provide either content_base64/data or file_path/path, not both") - if content_base64 is not None: - if not isinstance(content_base64, str): - raise ValueError("content_base64/data must be a string") - return content_base64 - if file_path is not None: - path = Path(file_path) - return base64.urlsafe_b64encode(path.read_bytes()).decode() - - raise ValueError("File upload requires content_base64/data or file_path/path") - - -def decode_base64_contents(value: str) -> bytes: - padded = value + "=" * (-len(value) % 4) - try: - return base64.urlsafe_b64decode(padded) - except (ValueError, binascii.Error): - return base64.b64decode(padded) - - -def file_upload_input(file: dict[str, Any]) -> dict[str, Any]: - field = file.get("field") or file.get("field_name") or file.get("name") - if not isinstance(field, str) or not field: - raise ValueError("File upload requires field/field_name/name") - - filename = file.get("filename") - if filename is None and (file.get("file_path") or file.get("path")): - filename = Path(file.get("file_path") or file.get("path")).name - - upload: dict[str, Any] = { - "name": field, - "data": base64_file_contents(file), - } - if filename is not None: - upload["filename"] = filename - if file.get("content_type") is not None: - upload["content_type"] = file["content_type"] - return upload - - -def csrf_token_from_jwt(token: str | None) -> str | None: - if not token: - return None - - parts = token.split(".") - if len(parts) != 3: - return None - - payload = parts[1] - payload += "=" * (-len(payload) % 4) - try: - claims = json.loads(base64.urlsafe_b64decode(payload)) - except (ValueError, TypeError): - return None - - csrf_token = claims.get("csrf_token") - return csrf_token if isinstance(csrf_token, str) and csrf_token else None - - -def jwt_expires_within(token: str | None, seconds: int) -> bool: - if not token: - return True - - parts = token.split(".") - if len(parts) != 3: - return False - - payload = parts[1] - payload += "=" * (-len(payload) % 4) - try: - claims = json.loads(base64.urlsafe_b64decode(payload)) - except (ValueError, TypeError): - return False - - exp = claims.get("exp") - return isinstance(exp, (int, float)) and exp <= time.time() + seconds - - -def _strip_leading_sql_comments(statement: str) -> str: - sql = statement.strip() - while True: - if sql.startswith("--"): - _, _, rest = sql.partition("\n") - sql = rest.strip() - continue - if sql.startswith("/*"): - _, sep, rest = sql.partition("*/") - if not sep: - return "" - sql = rest.strip() - continue - return sql - - -def is_readonly_sql(query: str) -> bool: - statements = [ - _strip_leading_sql_comments(stmt) - for stmt in query.split(";") - if _strip_leading_sql_comments(stmt) - ] - if not statements: - return False - - for statement in statements: - match = re.match(r"([A-Za-z_]+)", statement) - if not match or match.group(1).lower() not in READONLY_SQL_STARTERS: - return False - - return True - - -@dataclass(slots=True) -class TrailBaseClient: - base_url: str - auth_token: str | None = None - refresh_token: str | None = None - csrf_token: str | None = None - login_email: str | None = None - login_password: str | None = None - timeout: float = 30.0 - transport: httpx.BaseTransport | None = None - - @classmethod - def from_env(cls) -> "TrailBaseClient": - return cls( - base_url=os.getenv("TRAILBASE_URL", "http://localhost:4000"), - auth_token=auth_token_from_env(), - refresh_token=refresh_token_from_env(), - login_email=login_email_from_env(), - login_password=login_password_from_env(), - timeout=float(os.getenv("TRAILBASE_MCP_TIMEOUT", "30")), - ) - - def login(self) -> bool: - if not self.login_email or not self.login_password: - return False - - base_url = self.base_url.rstrip("/") - with httpx.Client( - base_url=base_url, - headers={ - "accept": "application/json", - "content-type": "application/json", - }, - timeout=self.timeout, - transport=self.transport, - ) as client: - response = client.post( - "/api/auth/v1/login", - json={ - "email": self.login_email, - "password": self.login_password, - "response_type": "token", - }, - ) - - if response.is_error: - return False - - body = response.json() - auth_token = normalize_auth_token(body.get("auth_token")) - if not auth_token: - return False - - self.auth_token = auth_token - self.refresh_token = normalize_auth_token(body.get("refresh_token")) or self.refresh_token - self.csrf_token = body.get("csrf_token") - return True - - def refresh_auth_token(self) -> bool: - if not self.refresh_token: - return False - - base_url = self.base_url.rstrip("/") - with httpx.Client( - base_url=base_url, - headers={ - "accept": "application/json", - "content-type": "application/json", - }, - timeout=self.timeout, - transport=self.transport, - ) as client: - response = client.post( - "/api/auth/v1/refresh", - json={"refresh_token": self.refresh_token}, - ) - - if response.is_error: - return False - - body = response.json() - auth_token = normalize_auth_token(body.get("auth_token")) - if not auth_token: - return False - - self.auth_token = auth_token - self.csrf_token = body.get("csrf_token") - return True - - def _headers(self) -> dict[str, str]: - if jwt_expires_within(self.auth_token, 60): - if not self.refresh_token or not self.refresh_auth_token(): - self.login() - - headers = { - "accept": "application/json", - "content-type": "application/json", - } - if self.auth_token: - headers["authorization"] = f"Bearer {self.auth_token}" - csrf_token = ( - os.getenv("TRAILBASE_CSRF_TOKEN") - or self.csrf_token - or csrf_token_from_jwt(self.auth_token) - ) - if csrf_token: - headers["csrf-token"] = csrf_token - return headers - - def request( - self, - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - json: Any | None = None, - ) -> Any: - base_url = self.base_url.rstrip("/") - path = path if path.startswith("/") else f"/{path}" - - with httpx.Client( - base_url=base_url, - headers=self._headers(), - timeout=self.timeout, - transport=self.transport, - ) as client: - response = client.request(method, path, params=params, json=json) - - if response.is_error: - body = response.text.strip() - raise RuntimeError( - f"TrailBase {method.upper()} {path} failed with " - f"HTTP {response.status_code}: {body}" - ) - - if response.status_code == 204 or not response.content: - return {"ok": True, "status_code": response.status_code} - - content_type = response.headers.get("content-type", "") - if "application/json" in content_type: - return response.json() - try: - return response.json() - except ValueError: - pass - - return { - "ok": True, - "status_code": response.status_code, - "body": response.text, - } - - def request_raw( - self, - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - ) -> httpx.Response: - base_url = self.base_url.rstrip("/") - path = path if path.startswith("/") else f"/{path}" - headers = self._headers() - headers["accept"] = "*/*" - headers.pop("content-type", None) - - with httpx.Client( - base_url=base_url, - headers=headers, - timeout=self.timeout, - transport=self.transport, - ) as client: - response = client.request(method, path, params=params) - - if response.is_error: - body = response.text.strip() - raise RuntimeError( - f"TrailBase {method.upper()} {path} failed with " - f"HTTP {response.status_code}: {body}" - ) - - return response - - def request_multipart( - self, - method: str, - path: str, - *, - data: dict[str, str], - files: list[tuple[str, tuple[str, bytes, str | None]]], - ) -> Any: - base_url = self.base_url.rstrip("/") - path = path if path.startswith("/") else f"/{path}" - headers = self._headers() - headers.pop("content-type", None) - - with httpx.Client( - base_url=base_url, - headers=headers, - timeout=self.timeout, - transport=self.transport, - ) as client: - response = client.request(method, path, data=data, files=files) - - if response.is_error: - body = response.text.strip() - raise RuntimeError( - f"TrailBase {method.upper()} {path} failed with " - f"HTTP {response.status_code}: {body}" - ) - - if response.status_code == 204 or not response.content: - return {"ok": True, "status_code": response.status_code} - try: - return response.json() - except ValueError: - return { - "ok": True, - "status_code": response.status_code, - "body": response.text, - } - - def request_bytes( - self, - method: str, - path: str, - *, - body: bytes | None = None, - ) -> bytes: - base_url = self.base_url.rstrip("/") - path = path if path.startswith("/") else f"/{path}" - - headers = self._headers() - headers["content-type"] = "application/protobuf" - headers["accept"] = "application/protobuf" - - with httpx.Client( - base_url=base_url, - headers=headers, - timeout=self.timeout, - transport=self.transport, - ) as client: - response = client.request(method, path, content=body) - - if response.is_error: - body_text = response.text.strip() - raise RuntimeError( - f"TrailBase {method.upper()} {path} failed with " - f"HTTP {response.status_code}: {body_text}" - ) - - return response.content - - def admin_info(self) -> Any: - return self.request("GET", "/api/_admin/info") - - def admin_config(self) -> Any: - response = config_api_pb2.GetConfigResponse() - response.ParseFromString(self.request_bytes("GET", "/api/_admin/config")) - return MessageToDict( - response, - preserving_proto_field_name=True, - use_integers_for_enums=True, - ) - - def update_config(self, config: dict[str, Any], hash: str) -> Any: - request = ParseDict( - {"config": config, "hash": hash}, - config_api_pb2.UpdateConfigRequest(), - ) - self.request_bytes( - "POST", - "/api/_admin/config", - body=request.SerializeToString(), - ) - return {"ok": True} - - def remove_record_api( - self, - api_name: str | None = None, - table_name: str | None = None, - ) -> Any: - if not api_name and not table_name: - raise ValueError("Provide api_name or table_name") - - response = self.admin_config() - config = response["config"] - record_apis = config.setdefault("record_apis", []) - kept = [] - removed = [] - - for api in record_apis: - name_matches = api_name is not None and api.get("name") == api_name - table_matches = table_name is not None and api.get("table_name") == table_name - if name_matches or table_matches: - removed.append(api) - else: - kept.append(api) - - if not removed: - return {"ok": True, "removed": [], "updated": False} - - config["record_apis"] = kept - self.update_config(config, response["hash"]) - return {"ok": True, "removed": removed, "updated": True} - - def drop_table( - self, - table_name: str, - remove_record_apis: bool = True, - ) -> Any: - removed_apis = {"ok": True, "removed": [], "updated": False} - if remove_record_apis: - removed_apis = self.remove_record_api(table_name=table_name) - - sql = f"DROP TABLE IF EXISTS {quote_sql_identifier(table_name)}" - dropped = self.execute_sql(sql) - return { - "ok": True, - "table_name": table_name, - "removed_record_apis": removed_apis.get("removed", []), - "drop_result": dropped, - } - - def list_tables(self) -> Any: - return self.request("GET", "/api/_admin/tables") - - def execute_sql(self, query: str, attached_databases: list[str] | None = None) -> Any: - payload: dict[str, Any] = {"query": query} - if attached_databases: - payload["attached_databases"] = attached_databases - return self.request("POST", "/api/_admin/query", json=payload) - - def trailbase_request( - self, - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - body: Any | None = None, - ) -> Any: - return self.request( - method.upper(), - validate_relative_path(path), - params=params, - json=body, - ) - - def api_json_schema( - self, - api_name: str, - mode: str | None = None, - admin: bool = False, - ) -> Any: - params = {"mode": mode} if mode else None - path = ( - f"/api/_admin/schema/{quote_segment(api_name)}/schema.json" - if admin - else f"/api/records/v1/{quote_segment(api_name)}/schema" - ) - return self.request("GET", path, params=params) - - def list_records( - self, - api_name: str, - query: dict[str, Any] | None = None, - ) -> Any: - return self.request( - "GET", - f"/api/records/v1/{quote_segment(api_name)}", - params=query, - ) - - def get_record( - self, - api_name: str, - record_id: str, - expand: str | None = None, - ) -> Any: - params = {"expand": expand} if expand else None - return self.request( - "GET", - f"/api/records/v1/{quote_segment(api_name)}/{quote_segment(record_id)}", - params=params, - ) - - def create_record(self, api_name: str, record: dict[str, Any] | list[dict[str, Any]]) -> Any: - return self.request( - "POST", - f"/api/records/v1/{quote_segment(api_name)}", - json=record, - ) - - def create_record_with_file_uploads( - self, - api_name: str, - record: dict[str, Any], - files: list[dict[str, Any]], - ) -> Any: - payload = dict(record) - for file in files: - upload = file_upload_input(file) - field = upload["name"] - upload_for_record = dict(upload) - upload_for_record.pop("name", None) - - existing = payload.get(field) - if file.get("multiple"): - if existing is None: - payload[field] = [upload_for_record] - elif isinstance(existing, list): - existing.append(upload_for_record) - else: - payload[field] = [existing, upload_for_record] - elif existing is None: - payload[field] = upload_for_record - elif isinstance(existing, list): - existing.append(upload_for_record) - else: - payload[field] = [existing, upload_for_record] - - return self.create_record(api_name, payload) - - def create_record_multipart( - self, - api_name: str, - fields: dict[str, Any], - files: list[dict[str, Any]], - ) -> Any: - data: dict[str, str] = {} - for key, value in fields.items(): - if isinstance(value, list): - data[key] = json.dumps(value) - elif value is not None: - data[key] = str(value) - - multipart_files: list[tuple[str, tuple[str, bytes, str | None]]] = [] - for file in files: - field = file.get("field") or file.get("field_name") or file.get("name") - if not isinstance(field, str) or not field: - raise ValueError("Multipart file requires field/field_name/name") - - file_path = file.get("file_path") or file.get("path") - content_base64 = file.get("content_base64") or file.get("data") - if file_path is not None and content_base64 is not None: - raise ValueError("Provide either content_base64/data or file_path/path, not both") - if file_path is not None: - bytes_data = Path(file_path).read_bytes() - filename = file.get("filename") or Path(file_path).name - elif content_base64 is not None: - bytes_data = decode_base64_contents(content_base64) - filename = file.get("filename") or field - else: - raise ValueError("Multipart file requires content_base64/data or file_path/path") - - multipart_files.append( - ( - field, - ( - str(filename), - bytes_data, - file.get("content_type"), - ), - ) - ) - - return self.request_multipart( - "POST", - f"/api/records/v1/{quote_segment(api_name)}", - data=data, - files=multipart_files, - ) - - def update_record(self, api_name: str, record_id: str, record: dict[str, Any]) -> Any: - return self.request( - "PATCH", - f"/api/records/v1/{quote_segment(api_name)}/{quote_segment(record_id)}", - json=record, - ) - - def delete_record(self, api_name: str, record_id: str) -> Any: - return self.request( - "DELETE", - f"/api/records/v1/{quote_segment(api_name)}/{quote_segment(record_id)}", - ) - - def download_file( - self, - api_name: str, - record_id: str, - column_name: str, - file_name: str | None = None, - ) -> Any: - path = ( - f"/api/records/v1/{quote_segment(api_name)}/" - f"{quote_segment(record_id)}/" - f"{'files' if file_name else 'file'}/" - f"{quote_segment(column_name)}" - ) - if file_name: - path += f"/{quote_segment(file_name)}" - - response = self.request_raw("GET", path) - return { - "ok": True, - "status_code": response.status_code, - "content_type": response.headers.get("content-type"), - "content_disposition": response.headers.get("content-disposition"), - "content_length": len(response.content), - "content_base64": base64.b64encode(response.content).decode(), - } diff --git a/mcp/src/trailbase_mcp/endpoints.py b/mcp/src/trailbase_mcp/endpoints.py deleted file mode 100644 index 19d132c18..000000000 --- a/mcp/src/trailbase_mcp/endpoints.py +++ /dev/null @@ -1,370 +0,0 @@ -from __future__ import annotations - -from typing import Any -from urllib.parse import quote - -TRAILBASE_API_OPERATIONS: tuple[dict[str, Any], ...] = ( - { - "operation_id": "auth_code_to_token_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/token", - "summary": "Exchange authorization code for auth tokens.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "change_email_confirm_handler", - "category": "auth", - "method": "GET", - "path": "/api/auth/v1/change_email/confirm/:email_verification_code", - "summary": "Confirm a change of email address.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "change_email_request_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/change_email/request", - "summary": "Request an email change.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "change_password_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/change_password", - "summary": "Request a change of password.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "create_avatar_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/avatar/", - "summary": "Create or update the current user's avatar.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "delete_avatar_handler", - "category": "auth", - "method": "DELETE", - "path": "/api/auth/v1/avatar/", - "summary": "Delete the current user's avatar.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "delete_handler", - "category": "auth", - "method": "DELETE", - "path": "/api/auth/v1/delete", - "summary": "Delete the current user.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "get_avatar_handler", - "category": "auth", - "method": "GET", - "path": "/api/auth/v1/avatar/:b64_user_id", - "summary": "Get a user's avatar.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": False, - }, - { - "operation_id": "login_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/login", - "summary": "Log in users by email and password.", - "mcp_support": "built-in MCP sidecar login, call_trailbase_api_operation, or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "login_mfa_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/login_mfa", - "summary": "Log in users with an MFA token.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "login_otp_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/otp/login", - "summary": "Log in with an OTP code.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "login_status_handler", - "category": "auth", - "method": "GET", - "path": "/api/auth/v1/status", - "summary": "Check login status.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": False, - }, - { - "operation_id": "logout_handler", - "category": "auth", - "method": "GET", - "path": "/api/auth/v1/logout", - "summary": "Log out the current user and delete all pending sessions.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "post_logout_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/logout", - "summary": "Log out the session for a refresh token.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "refresh_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/refresh", - "summary": "Refresh auth tokens given a refresh token.", - "mcp_support": "built-in MCP sidecar refresh, call_trailbase_api_operation, or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "register_totp_confirm_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/totp/confirm", - "summary": "Verify the current user's TOTP.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "register_totp_request_handler", - "category": "auth", - "method": "GET", - "path": "/api/auth/v1/totp/register", - "summary": "Register the current user for TOTP.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "register_user_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/register", - "summary": "Register a new user with email and password.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "request_email_verification_handler", - "category": "auth", - "method": "GET", - "path": "/api/auth/v1/verify_email/trigger", - "summary": "Request a new email verification email.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "request_otp_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/otp/request", - "summary": "Request an OTP code.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "reset_password_request_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/reset_password/request", - "summary": "Request a password reset.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "reset_password_update_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/reset_password/update", - "summary": "Set a new password after a reset request.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "unregister_totp_handler", - "category": "auth", - "method": "POST", - "path": "/api/auth/v1/totp/unregister", - "summary": "Unregister TOTP for the current user.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "verify_email_handler", - "category": "auth", - "method": "GET", - "path": "/api/auth/v1/verify_email/confirm/:email_verification_code", - "summary": "Confirm an email verification code.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "callback_from_external_auth_provider", - "category": "oauth", - "method": "GET", - "path": "/api/auth/v1/oauth/{provider}/callback", - "summary": "Handle an external OAuth provider callback.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": True, - }, - { - "operation_id": "list_configured_providers_handler", - "category": "oauth", - "method": "GET", - "path": "/api/auth/v1/oauth/providers", - "summary": "List configured OAuth providers.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": False, - }, - { - "operation_id": "login_with_external_auth_provider", - "category": "oauth", - "method": "GET", - "path": "/api/auth/v1/oauth/{provider}/login", - "summary": "Start login through an external OAuth provider.", - "mcp_support": "call_trailbase_api_operation or trailbase_request", - "requires_write_permission": False, - }, - { - "operation_id": "add_subscription_sse_handler", - "category": "records", - "method": "GET", - "path": "/api/records/v1/{name}/subscribe/{record}", - "summary": "Start streaming record changes via SSE/WebSocket.", - "mcp_support": "catalog only; long-running SSE is not proxied by this request/response MCP tool", - "requires_write_permission": False, - "streaming": True, - }, - { - "operation_id": "create_record_handler", - "category": "records", - "method": "POST", - "path": "/api/records/v1/{name}", - "summary": "Create a new record.", - "mcp_support": "create_record, create_record_with_file_uploads, create_record_multipart, or call_trailbase_api_operation", - "requires_write_permission": True, - }, - { - "operation_id": "delete_record_handler", - "category": "records", - "method": "DELETE", - "path": "/api/records/v1/{name}/{record}", - "summary": "Delete a record.", - "mcp_support": "delete_record or call_trailbase_api_operation", - "requires_write_permission": True, - }, - { - "operation_id": "get_uploaded_file_from_record_handler", - "category": "records", - "method": "GET", - "path": "/api/records/v1/{name}/{record}/file/{column_name}", - "summary": "Read a file associated with a record.", - "mcp_support": "download_file or call_trailbase_api_operation", - "requires_write_permission": False, - }, - { - "operation_id": "get_uploaded_files_from_record_handler", - "category": "records", - "method": "GET", - "path": "/api/records/v1/{name}/{record}/files/{column_name}/{file_name}", - "summary": "Read one file from a record file-list column.", - "mcp_support": "download_file or call_trailbase_api_operation", - "requires_write_permission": False, - }, - { - "operation_id": "json_schema_handler", - "category": "records", - "method": "GET", - "path": "/api/records/v1/{name}/schema", - "summary": "Retrieve the JSON Schema for a record API.", - "mcp_support": "get_api_json_schema or call_trailbase_api_operation", - "requires_write_permission": False, - }, - { - "operation_id": "list_records_handler", - "category": "records", - "method": "GET", - "path": "/api/records/v1/{name}", - "summary": "List records matching filters.", - "mcp_support": "list_records or call_trailbase_api_operation", - "requires_write_permission": False, - }, - { - "operation_id": "read_record_handler", - "category": "records", - "method": "GET", - "path": "/api/records/v1/{name}/{record}", - "summary": "Read one record.", - "mcp_support": "get_record or call_trailbase_api_operation", - "requires_write_permission": False, - }, - { - "operation_id": "update_record_handler", - "category": "records", - "method": "PATCH", - "path": "/api/records/v1/{name}/{record}", - "summary": "Update an existing record.", - "mcp_support": "update_record or call_trailbase_api_operation", - "requires_write_permission": True, - }, -) - - -def list_api_operations(category: str | None = None) -> list[dict[str, Any]]: - if category is None: - return [dict(operation) for operation in TRAILBASE_API_OPERATIONS] - normalized = category.lower() - return [ - dict(operation) - for operation in TRAILBASE_API_OPERATIONS - if operation["category"] == normalized - ] - - -def get_api_operation(operation_id: str) -> dict[str, Any]: - for operation in TRAILBASE_API_OPERATIONS: - if operation["operation_id"] == operation_id: - return dict(operation) - raise ValueError(f"Unknown TrailBase API operation: {operation_id}") - - -def render_operation_path( - operation: dict[str, Any], - path_params: dict[str, Any] | None = None, -) -> str: - path = operation["path"] - params = path_params or {} - - for name, value in params.items(): - value = quote(str(value), safe="") - path = path.replace(f"{{{name}}}", value) - path = path.replace(f":{name}", value) - - if "{" in path or "}" in path or "/:" in path: - raise ValueError( - f"Missing path parameter for {operation['operation_id']}: {operation['path']}" - ) - - return path diff --git a/mcp/src/trailbase_mcp/proto/__init__.py b/mcp/src/trailbase_mcp/proto/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/mcp/src/trailbase_mcp/proto/config_api_pb2.py b/mcp/src/trailbase_mcp/proto/config_api_pb2.py deleted file mode 100644 index 5ff7e45a4..000000000 --- a/mcp/src/trailbase_mcp/proto/config_api_pb2.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: config_api.proto -"""Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -from . import config_pb2 as config__pb2 - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10\x63onfig_api.proto\x12\x06\x63onfig\x1a\x0c\x63onfig.proto\"A\n\x11GetConfigResponse\x12\x1e\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x0e.config.Config\x12\x0c\n\x04hash\x18\x02 \x01(\t\"C\n\x13UpdateConfigRequest\x12\x1e\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x0e.config.Config\x12\x0c\n\x04hash\x18\x02 \x01(\t') - -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'config_api_pb2', globals()) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - _GETCONFIGRESPONSE._serialized_start=42 - _GETCONFIGRESPONSE._serialized_end=107 - _UPDATECONFIGREQUEST._serialized_start=109 - _UPDATECONFIGREQUEST._serialized_end=176 -# @@protoc_insertion_point(module_scope) diff --git a/mcp/src/trailbase_mcp/proto/config_pb2.py b/mcp/src/trailbase_mcp/proto/config_pb2.py deleted file mode 100644 index 85eb641dd..000000000 --- a/mcp/src/trailbase_mcp/proto/config_pb2.py +++ /dev/null @@ -1,71 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: config.proto -"""Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import symbol_database as _symbol_database -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 - - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63onfig.proto\x12\x06\x63onfig\x1a google/protobuf/descriptor.proto\".\n\rEmailTemplate\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\"\x9b\x03\n\x0b\x45mailConfig\x12\x11\n\tsmtp_host\x18\x01 \x01(\t\x12\x11\n\tsmtp_port\x18\x02 \x01(\r\x12\x15\n\rsmtp_username\x18\x03 \x01(\t\x12\x1b\n\rsmtp_password\x18\x04 \x01(\tB\x04\x80\xb5\x18\x01\x12/\n\x0fsmtp_encryption\x18\x05 \x01(\x0e\x32\x16.config.SmtpEncryption\x12\x13\n\x0bsender_name\x18\x0b \x01(\t\x12\x16\n\x0esender_address\x18\x0c \x01(\t\x12\x39\n\x1auser_verification_template\x18\x15 \x01(\x0b\x32\x15.config.EmailTemplate\x12\x36\n\x17password_reset_template\x18\x16 \x01(\x0b\x32\x15.config.EmailTemplate\x12\x34\n\x15\x63hange_email_template\x18\x17 \x01(\x0b\x32\x15.config.EmailTemplate\x12+\n\x0cotp_template\x18\x18 \x01(\x0b\x32\x15.config.EmailTemplate\"\xc4\x01\n\x13OAuthProviderConfig\x12\x11\n\tclient_id\x18\x01 \x01(\t\x12\x1b\n\rclient_secret\x18\x02 \x01(\tB\x04\x80\xb5\x18\x01\x12,\n\x0bprovider_id\x18\x03 \x01(\x0e\x32\x17.config.OAuthProviderId\x12\x14\n\x0c\x64isplay_name\x18\x0b \x01(\t\x12\x10\n\x08\x61uth_url\x18\x0c \x01(\t\x12\x11\n\ttoken_url\x18\r \x01(\t\x12\x14\n\x0cuser_api_url\x18\x0e \x01(\t\"\xfa\x04\n\nAuthConfig\x12\x1a\n\x12\x61uth_token_ttl_sec\x18\x01 \x01(\x03\x12\x1d\n\x15refresh_token_ttl_sec\x18\x02 \x01(\x03\x12\'\n\x1f\x61nonymous_refresh_token_ttl_sec\x18\t \x01(\x03\x12\x1d\n\x15\x64isable_password_auth\x18\x03 \x01(\x08\x12\x19\n\x11\x65nable_otp_signin\x18\x08 \x01(\x08\x12\x1f\n\x17\x65nable_anonymous_signin\x18\x0c \x01(\x08\x12\x1f\n\x17password_minimal_length\x18\x04 \x01(\r\x12\x32\n*password_must_contain_upper_and_lower_case\x18\x05 \x01(\x08\x12$\n\x1cpassword_must_contain_digits\x18\x06 \x01(\x08\x12\x30\n(password_must_contain_special_characters\x18\x07 \x01(\x08\x12?\n\x0foauth_providers\x18\x0b \x03(\x0b\x32&.config.AuthConfig.OauthProvidersEntry\x12\x1a\n\x12\x63ustom_uri_schemes\x18\x15 \x03(\t\x12\x1e\n\x16redirect_uri_allowlist\x18\x16 \x03(\t\x12/\n\x0fuser_identifier\x18\x1f \x01(\x0e\x32\x16.config.UserIdentifier\x1aR\n\x13OauthProvidersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12*\n\x05value\x18\x02 \x01(\x0b\x32\x1b.config.OAuthProviderConfig:\x02\x38\x01\"}\n\x0fS3StorageConfig\x12\x10\n\x08\x65ndpoint\x18\x01 \x01(\t\x12\x0e\n\x06region\x18\x02 \x01(\t\x12\x13\n\x0b\x62ucket_name\x18\x05 \x01(\t\x12\x12\n\naccess_key\x18\x08 \x01(\t\x12\x1f\n\x11secret_access_key\x18\t \x01(\tB\x04\x80\xb5\x18\x01\"\x88\x02\n\x0cServerConfig\x12\x18\n\x10\x61pplication_name\x18\x01 \x01(\t\x12\x10\n\x08site_url\x18\x02 \x01(\t\x12\x1a\n\x12logs_retention_sec\x18\x0b \x01(\x03\x12\x32\n\x11s3_storage_config\x18\r \x01(\x0b\x32\x17.config.S3StorageConfig\x12\"\n\x1a\x65nable_record_transactions\x18\x0e \x01(\x08\x12 \n\x18request_size_limit_bytes\x18\x0f \x01(\x04\x12\x1a\n\x12\x61uth_ip_rate_limit\x18\x10 \x01(\r\x12\x1a\n\x12\x62\x61\x63kup_window_size\x18\x11 \x01(\x04\"P\n\tSystemJob\x12\x1f\n\x02id\x18\x01 \x01(\x0e\x32\x13.config.SystemJobId\x12\x10\n\x08schedule\x18\x02 \x01(\t\x12\x10\n\x08\x64isabled\x18\x03 \x01(\x08\"4\n\nJobsConfig\x12&\n\x0bsystem_jobs\x18\x01 \x03(\x0b\x32\x11.config.SystemJob\"\x86\x04\n\x0fRecordApiConfig\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x12\n\ntable_name\x18\x02 \x01(\t\x12\x1a\n\x12\x61ttached_databases\x18\x03 \x03(\t\x12?\n\x13\x63onflict_resolution\x18\x05 \x01(\x0e\x32\".config.ConflictResolutionStrategy\x12(\n autofill_missing_user_id_columns\x18\x06 \x01(\x08\x12\x1c\n\x14\x65nable_subscriptions\x18\t \x01(\x08\x12)\n\tacl_world\x18\x07 \x03(\x0e\x32\x16.config.PermissionFlag\x12\x31\n\x11\x61\x63l_authenticated\x18\x08 \x03(\x0e\x32\x16.config.PermissionFlag\x12\x18\n\x10\x65xcluded_columns\x18\n \x03(\t\x12\x1a\n\x12\x63reate_access_rule\x18\x0b \x01(\t\x12\x18\n\x10read_access_rule\x18\x0c \x01(\t\x12\x1a\n\x12update_access_rule\x18\r \x01(\t\x12\x1a\n\x12\x64\x65lete_access_rule\x18\x0e \x01(\t\x12\x1a\n\x12schema_access_rule\x18\x0f \x01(\t\x12\x0e\n\x06\x65xpand\x18\x15 \x03(\t\x12\x1a\n\x12listing_hard_limit\x18\x16 \x01(\x04\"0\n\x10JsonSchemaConfig\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06schema\x18\x02 \x01(\t\"\x1e\n\x0e\x44\x61tabaseConfig\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x9a\x02\n\x06\x43onfig\x12\"\n\x05\x65mail\x18\x02 \x02(\x0b\x32\x13.config.EmailConfig\x12$\n\x06server\x18\x03 \x02(\x0b\x32\x14.config.ServerConfig\x12 \n\x04\x61uth\x18\x04 \x02(\x0b\x32\x12.config.AuthConfig\x12 \n\x04jobs\x18\x05 \x02(\x0b\x32\x12.config.JobsConfig\x12)\n\tdatabases\x18\x08 \x03(\x0b\x32\x16.config.DatabaseConfig\x12,\n\x0brecord_apis\x18\x0b \x03(\x0b\x32\x17.config.RecordApiConfig\x12)\n\x07schemas\x18\x15 \x03(\x0b\x32\x18.config.JsonSchemaConfig*\x80\x01\n\x0eSmtpEncryption\x12\x1d\n\x19SMTP_ENCRYPTION_UNDEFINED\x10\x00\x12\x18\n\x14SMTP_ENCRYPTION_NONE\x10\x01\x12\x1c\n\x18SMTP_ENCRYPTION_STARTTLS\x10\x02\x12\x17\n\x13SMTP_ENCRYPTION_TLS\x10\x03*\xb8\x01\n\x0fOAuthProviderId\x12\x1f\n\x1bOAUTH_PROVIDER_ID_UNDEFINED\x10\x00\x12\x08\n\x04TEST\x10\x01\x12\t\n\x05OIDC0\x10\x02\x12\t\n\x05\x41PPLE\x10\t\x12\x0b\n\x07\x44ISCORD\x10\n\x12\n\n\x06GITLAB\x10\x0b\x12\n\n\x06GOOGLE\x10\x0c\x12\x0c\n\x08\x46\x41\x43\x45\x42OOK\x10\r\x12\r\n\tMICROSOFT\x10\x0e\x12\n\n\x06TWITCH\x10\x0f\x12\n\n\x06YANDEX\x10\x10\x12\n\n\x06GITHUB\x10\x11*\x9b\x01\n\x0eUserIdentifier\x12\x1d\n\x19USER_IDENTIFIER_UNDEFINED\x10\x00\x12\x0e\n\nONLY_EMAIL\x10\x01\x12\x11\n\rONLY_USERNAME\x10\x02\x12\x11\n\rREQUIRE_EMAIL\x10\x03\x12\x14\n\x10REQUIRE_USERNAME\x10\x04\x12\x1e\n\x1aREQUIRE_EMAIL_AND_USERNAME\x10\x05*\xa8\x01\n\x0bSystemJobId\x12\x1b\n\x17SYSTEM_JOB_ID_UNDEFINED\x10\x00\x12\n\n\x06\x42\x41\x43KUP\x10\x01\x12\r\n\tHEARTBEAT\x10\x02\x12\x0f\n\x0bLOG_CLEANER\x10\x03\x12\x10\n\x0c\x41UTH_CLEANER\x10\x04\x12\x13\n\x0fQUERY_OPTIMIZER\x10\x05\x12\x12\n\x0e\x46ILE_DELETIONS\x10\x06\x12\x15\n\x11\x41NONYMOUS_CLEANER\x10\x07*l\n\x1a\x43onflictResolutionStrategy\x12*\n&CONFLICT_RESOLUTION_STRATEGY_UNDEFINED\x10\x00\x12\t\n\x05\x41\x42ORT\x10\x01\x12\n\n\x06IGNORE\x10\x04\x12\x0b\n\x07REPLACE\x10\x05*i\n\x0ePermissionFlag\x12\x1d\n\x19PERMISSION_FLAG_UNDEFINED\x10\x00\x12\n\n\x06\x43REATE\x10\x01\x12\x08\n\x04READ\x10\x02\x12\n\n\x06UPDATE\x10\x04\x12\n\n\x06\x44\x45LETE\x10\x08\x12\n\n\x06SCHEMA\x10\x10:/\n\x06secret\x12\x1d.google.protobuf.FieldOptions\x18\xd0\x86\x03 \x01(\x08') - -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'config_pb2', globals()) -if _descriptor._USE_C_DESCRIPTORS == False: - google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension(secret) - - DESCRIPTOR._options = None - _EMAILCONFIG.fields_by_name['smtp_password']._options = None - _EMAILCONFIG.fields_by_name['smtp_password']._serialized_options = b'\200\265\030\001' - _OAUTHPROVIDERCONFIG.fields_by_name['client_secret']._options = None - _OAUTHPROVIDERCONFIG.fields_by_name['client_secret']._serialized_options = b'\200\265\030\001' - _AUTHCONFIG_OAUTHPROVIDERSENTRY._options = None - _AUTHCONFIG_OAUTHPROVIDERSENTRY._serialized_options = b'8\001' - _S3STORAGECONFIG.fields_by_name['secret_access_key']._options = None - _S3STORAGECONFIG.fields_by_name['secret_access_key']._serialized_options = b'\200\265\030\001' - _SMTPENCRYPTION._serialized_start=2775 - _SMTPENCRYPTION._serialized_end=2903 - _OAUTHPROVIDERID._serialized_start=2906 - _OAUTHPROVIDERID._serialized_end=3090 - _USERIDENTIFIER._serialized_start=3093 - _USERIDENTIFIER._serialized_end=3248 - _SYSTEMJOBID._serialized_start=3251 - _SYSTEMJOBID._serialized_end=3419 - _CONFLICTRESOLUTIONSTRATEGY._serialized_start=3421 - _CONFLICTRESOLUTIONSTRATEGY._serialized_end=3529 - _PERMISSIONFLAG._serialized_start=3531 - _PERMISSIONFLAG._serialized_end=3636 - _EMAILTEMPLATE._serialized_start=58 - _EMAILTEMPLATE._serialized_end=104 - _EMAILCONFIG._serialized_start=107 - _EMAILCONFIG._serialized_end=518 - _OAUTHPROVIDERCONFIG._serialized_start=521 - _OAUTHPROVIDERCONFIG._serialized_end=717 - _AUTHCONFIG._serialized_start=720 - _AUTHCONFIG._serialized_end=1354 - _AUTHCONFIG_OAUTHPROVIDERSENTRY._serialized_start=1272 - _AUTHCONFIG_OAUTHPROVIDERSENTRY._serialized_end=1354 - _S3STORAGECONFIG._serialized_start=1356 - _S3STORAGECONFIG._serialized_end=1481 - _SERVERCONFIG._serialized_start=1484 - _SERVERCONFIG._serialized_end=1748 - _SYSTEMJOB._serialized_start=1750 - _SYSTEMJOB._serialized_end=1830 - _JOBSCONFIG._serialized_start=1832 - _JOBSCONFIG._serialized_end=1884 - _RECORDAPICONFIG._serialized_start=1887 - _RECORDAPICONFIG._serialized_end=2405 - _JSONSCHEMACONFIG._serialized_start=2407 - _JSONSCHEMACONFIG._serialized_end=2455 - _DATABASECONFIG._serialized_start=2457 - _DATABASECONFIG._serialized_end=2487 - _CONFIG._serialized_start=2490 - _CONFIG._serialized_end=2772 -# @@protoc_insertion_point(module_scope) diff --git a/mcp/src/trailbase_mcp/server.py b/mcp/src/trailbase_mcp/server.py deleted file mode 100644 index 4fa90d2ad..000000000 --- a/mcp/src/trailbase_mcp/server.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -import os -from typing import Any - -from fastmcp import FastMCP - -from .client import READONLY_HTTP_METHODS, TrailBaseClient, env_flag, is_readonly_sql -from .endpoints import get_api_operation, list_api_operations, render_operation_path - - -mcp = FastMCP("TrailBase") - - -def _client() -> TrailBaseClient: - return TrailBaseClient.from_env() - - -def _require_writes_enabled() -> None: - if not env_flag("TRAILBASE_MCP_ENABLE_WRITES"): - raise RuntimeError( - "Write operations are disabled. Set TRAILBASE_MCP_ENABLE_WRITES=true " - "for this MCP server process to enable mutating tools." - ) - - -@mcp.tool -def trailbase_info() -> Any: - """Return TrailBase server build/runtime metadata.""" - return _client().admin_info() - - -@mcp.tool -def trailbase_config() -> Any: - """Return TrailBase configuration plus the config hash required for updates.""" - return _client().admin_config() - - -@mcp.tool -def update_config(config: dict[str, Any], hash: str) -> Any: - """Replace TrailBase configuration. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" - _require_writes_enabled() - return _client().update_config(config, hash) - - -@mcp.tool -def list_record_apis() -> Any: - """List configured TrailBase record APIs from the server config.""" - response = _client().admin_config() - return {"record_apis": response.get("config", {}).get("record_apis", [])} - - -@mcp.tool -def remove_record_api( - api_name: str | None = None, - table_name: str | None = None, -) -> Any: - """Remove Record API config entries by API name or backing table name. - - Requires TRAILBASE_MCP_ENABLE_WRITES=true. Use this before dropping a table - that is exposed as a Record API. - """ - _require_writes_enabled() - return _client().remove_record_api(api_name=api_name, table_name=table_name) - - -@mcp.tool -def drop_table(table_name: str, remove_record_apis: bool = True) -> Any: - """Drop a table, optionally removing Record APIs that reference it first. - - Requires TRAILBASE_MCP_ENABLE_WRITES=true. The table name must be a simple - SQL identifier. This helper removes API config before DROP TABLE by default - to avoid stale Record API references. - """ - _require_writes_enabled() - return _client().drop_table(table_name, remove_record_apis) - - -@mcp.tool -def list_tables() -> Any: - """List TrailBase tables, views, indexes, and triggers.""" - return _client().list_tables() - - -@mcp.tool -def get_api_json_schema( - api_name: str, - mode: str | None = None, - admin: bool = False, -) -> Any: - """Return the JSON Schema for a configured TrailBase record API. - - mode may be Insert, Select, or Update. By default this uses the public - record schema endpoint; set admin=True to use the admin schema endpoint. - """ - return _client().api_json_schema(api_name, mode, admin) - - -@mcp.tool -def execute_sql( - query: str, - attached_databases: list[str] | None = None, - allow_mutation: bool = False, -) -> Any: - """Execute SQL through TrailBase's admin query endpoint. - - By default this accepts only read-oriented statements. Mutations require both - allow_mutation=True and TRAILBASE_MCP_ENABLE_WRITES=true. - """ - if not allow_mutation and not is_readonly_sql(query): - raise RuntimeError( - "Only SELECT/WITH/PRAGMA/EXPLAIN statements are allowed by default. " - "Set allow_mutation=True and TRAILBASE_MCP_ENABLE_WRITES=true to run mutations." - ) - if allow_mutation: - _require_writes_enabled() - - return _client().execute_sql(query, attached_databases) - - -@mcp.tool -def trailbase_request( - method: str, - path: str, - params: dict[str, Any] | None = None, - body: Any | None = None, -) -> Any: - """Call an arbitrary TrailBase HTTP endpoint on the configured server. - - Use this for custom WASM APIs, auth endpoints, OpenAPI endpoints, and other - TrailBase routes not covered by a specialized MCP tool. The path must be - server-relative, e.g. /api/auth/v1/status. Non-readonly methods require - TRAILBASE_MCP_ENABLE_WRITES=true. - """ - normalized_method = method.upper() - if normalized_method not in READONLY_HTTP_METHODS: - _require_writes_enabled() - return _client().trailbase_request(normalized_method, path, params=params, body=body) - - -@mcp.tool -def list_trailbase_api_operations(category: str | None = None) -> Any: - """List TrailBase OpenAPI operations known to this MCP server. - - category may be auth, oauth, or records. The response includes the - operation_id, HTTP method, server-relative path template, mutation gate, and - recommended MCP support path for each operation. - """ - return {"operations": list_api_operations(category)} - - -@mcp.tool -def call_trailbase_api_operation( - operation_id: str, - path_params: dict[str, Any] | None = None, - params: dict[str, Any] | None = None, - body: Any | None = None, -) -> Any: - """Call a known TrailBase OpenAPI operation by operation_id. - - path_params fills path templates such as {"name": "todos", "record": "id"}. - params are URL query parameters and body is sent as JSON. Mutating - operations require TRAILBASE_MCP_ENABLE_WRITES=true. Streaming SSE - operations are cataloged but not proxied by this request/response tool. - """ - operation = get_api_operation(operation_id) - if operation.get("streaming"): - raise RuntimeError( - f"{operation_id} is a long-running streaming endpoint and is not " - "proxied by this request/response MCP tool." - ) - if operation.get("requires_write_permission"): - _require_writes_enabled() - - path = render_operation_path(operation, path_params) - return _client().trailbase_request(operation["method"], path, params=params, body=body) - - -@mcp.tool -def list_records(api_name: str, query: dict[str, Any] | None = None) -> Any: - """List records for a TrailBase record API. - - The optional query object is passed as URL query parameters, e.g. - {"limit": 20, "count": true}. - """ - return _client().list_records(api_name, query) - - -@mcp.tool -def get_record(api_name: str, record_id: str, expand: str | None = None) -> Any: - """Fetch one record from a TrailBase record API.""" - return _client().get_record(api_name, record_id, expand) - - -@mcp.tool -def create_record(api_name: str, record: dict[str, Any] | list[dict[str, Any]]) -> Any: - """Create one or more records. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" - _require_writes_enabled() - return _client().create_record(api_name, record) - - -@mcp.tool -def create_record_with_file_uploads( - api_name: str, - record: dict[str, Any], - files: list[dict[str, Any]], -) -> Any: - """Create one record with JSON/base64 TrailBase file upload inputs. - - Each file requires field/field_name/name plus either content_base64/data or - file_path/path. Optional keys: filename, content_type, multiple. - """ - _require_writes_enabled() - return _client().create_record_with_file_uploads(api_name, record, files) - - -@mcp.tool -def create_record_multipart( - api_name: str, - fields: dict[str, Any], - files: list[dict[str, Any]], -) -> Any: - """Create one record as multipart/form-data with file parts. - - Each file requires field/field_name/name plus either content_base64/data or - file_path/path. Optional keys: filename, content_type. - """ - _require_writes_enabled() - return _client().create_record_multipart(api_name, fields, files) - - -@mcp.tool -def update_record(api_name: str, record_id: str, record: dict[str, Any]) -> Any: - """Update one record. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" - _require_writes_enabled() - return _client().update_record(api_name, record_id, record) - - -@mcp.tool -def delete_record(api_name: str, record_id: str) -> Any: - """Delete one record. Requires TRAILBASE_MCP_ENABLE_WRITES=true.""" - _require_writes_enabled() - return _client().delete_record(api_name, record_id) - - -@mcp.tool -def download_file( - api_name: str, - record_id: str, - column_name: str, - file_name: str | None = None, -) -> Any: - """Download a TrailBase file column and return the bytes as content_base64. - - For std.FileUpload columns omit file_name. For std.FileUploads columns pass - the metadata filename as file_name. - """ - return _client().download_file(api_name, record_id, column_name, file_name) - - -def main() -> None: - transport = os.getenv("MCP_TRANSPORT", "stdio") - if transport == "http": - mcp.run( - transport="http", - host=os.getenv("MCP_HOST", "127.0.0.1"), - port=int(os.getenv("MCP_PORT", "8000")), - ) - else: - mcp.run() - - -if __name__ == "__main__": - main() diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py deleted file mode 100644 index 1807471e2..000000000 --- a/mcp/tests/test_client.py +++ /dev/null @@ -1,550 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest -import base64 -import json -import time - -from trailbase_mcp.client import ( - TrailBaseClient, - auth_token_from_env, - csrf_token_from_jwt, - file_upload_input, - is_readonly_sql, - jwt_expires_within, - login_email_from_env, - login_password_from_env, - normalize_auth_token, - quote_sql_identifier, - refresh_token_from_env, - validate_relative_path, -) -from trailbase_mcp.endpoints import ( - get_api_operation, - list_api_operations, - render_operation_path, -) -from trailbase_mcp import server as server_module -from trailbase_mcp.proto import config_api_pb2 -from trailbase_mcp.server import call_trailbase_api_operation, trailbase_request - - -def test_readonly_sql_detection() -> None: - assert is_readonly_sql("select * from users") - assert is_readonly_sql("-- comment\nWITH x AS (select 1) select * from x") - assert is_readonly_sql("/* comment */ pragma table_info(users)") - assert not is_readonly_sql("insert into users values (1)") - assert not is_readonly_sql("select 1; delete from users") - - -def test_auth_token_env_normalization_and_file( - monkeypatch: pytest.MonkeyPatch, - tmp_path, -) -> None: - token_file = tmp_path / "trailbase-token" - token_file.write_text("Bearer file-token\n") - - assert normalize_auth_token("Bearer env-token") == "env-token" - assert normalize_auth_token(" raw-token ") == "raw-token" - assert normalize_auth_token("") is None - - monkeypatch.delenv("TRAILBASE_AUTH_TOKEN", raising=False) - monkeypatch.delenv("TRAILBASE_TOKEN", raising=False) - monkeypatch.setenv("TRAILBASE_AUTH_TOKEN_FILE", str(token_file)) - assert auth_token_from_env() == "file-token" - - monkeypatch.setenv("TRAILBASE_AUTH_TOKEN", "Bearer env-token") - assert auth_token_from_env() == "env-token" - - -def test_refresh_token_env_file_and_expiration( - monkeypatch: pytest.MonkeyPatch, - tmp_path, -) -> None: - refresh_file = tmp_path / "trailbase-refresh-token" - refresh_file.write_text("refresh-from-file\n") - - monkeypatch.delenv("TRAILBASE_REFRESH_TOKEN", raising=False) - monkeypatch.setenv("TRAILBASE_REFRESH_TOKEN_FILE", str(refresh_file)) - assert refresh_token_from_env() == "refresh-from-file" - - monkeypatch.setenv("TRAILBASE_REFRESH_TOKEN", "Bearer refresh-from-env") - assert refresh_token_from_env() == "refresh-from-env" - - payload = base64.urlsafe_b64encode( - json.dumps({"exp": int(time.time()) - 1}).encode() - ).decode().rstrip("=") - assert jwt_expires_within(f"header.{payload}.signature", 60) - - -def test_login_credentials_env_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - email_file = tmp_path / "trailbase-email" - password_file = tmp_path / "trailbase-password" - email_file.write_text("admin@localhost\n") - password_file.write_text("secret\n") - - monkeypatch.delenv("TRAILBASE_LOGIN_EMAIL", raising=False) - monkeypatch.delenv("TRAILBASE_LOGIN_PASSWORD", raising=False) - monkeypatch.setenv("TRAILBASE_LOGIN_EMAIL_FILE", str(email_file)) - monkeypatch.setenv("TRAILBASE_LOGIN_PASSWORD_FILE", str(password_file)) - assert login_email_from_env() == "admin@localhost" - assert login_password_from_env() == "secret" - - monkeypatch.setenv("TRAILBASE_ADMIN_EMAIL", "admin-alias@localhost") - monkeypatch.setenv("TRAILBASE_ADMIN_PASSWORD", "admin-secret") - assert login_email_from_env() == "admin@localhost" - assert login_password_from_env() == "secret" - - monkeypatch.setenv("TRAILBASE_LOGIN_EMAIL", "login@localhost") - monkeypatch.setenv("TRAILBASE_LOGIN_PASSWORD", "login-secret") - assert login_email_from_env() == "login@localhost" - assert login_password_from_env() == "login-secret" - - -def test_client_logs_in_when_auth_token_is_missing() -> None: - seen: list[tuple[str, str]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - seen.append((request.method, request.url.path)) - if request.url.path == "/api/auth/v1/login": - assert json.loads(request.read()) == { - "email": "admin@localhost", - "password": "secret", - "response_type": "token", - } - return httpx.Response( - 200, - json={ - "auth_token": "fresh-token", - "refresh_token": "refresh-token", - "csrf_token": "fresh-csrf", - }, - ) - - assert request.url.path == "/api/_admin/info" - assert request.headers["authorization"] == "Bearer fresh-token" - assert request.headers["csrf-token"] == "fresh-csrf" - return httpx.Response(200, json={"ok": True}) - - client = TrailBaseClient( - base_url="http://trailbase.test", - login_email="admin@localhost", - login_password="secret", - transport=httpx.MockTransport(handler), - ) - - assert client.admin_info() == {"ok": True} - assert client.refresh_token == "refresh-token" - assert seen == [ - ("POST", "/api/auth/v1/login"), - ("GET", "/api/_admin/info"), - ] - - -def test_client_refreshes_expired_auth_token_before_request() -> None: - expired_payload = base64.urlsafe_b64encode( - json.dumps({"exp": int(time.time()) - 1}).encode() - ).decode().rstrip("=") - expired_token = f"header.{expired_payload}.signature" - - seen: list[tuple[str, str]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - seen.append((request.method, request.url.path)) - if request.url.path == "/api/auth/v1/refresh": - assert request.read() == b'{"refresh_token":"refresh-token"}' - return httpx.Response( - 200, - json={"auth_token": "fresh-token", "csrf_token": "fresh-csrf"}, - ) - - assert request.url.path == "/api/_admin/info" - assert request.headers["authorization"] == "Bearer fresh-token" - assert request.headers["csrf-token"] == "fresh-csrf" - return httpx.Response(200, json={"ok": True}) - - client = TrailBaseClient( - base_url="http://trailbase.test", - auth_token=expired_token, - refresh_token="refresh-token", - transport=httpx.MockTransport(handler), - ) - - assert client.admin_info() == {"ok": True} - assert seen == [ - ("POST", "/api/auth/v1/refresh"), - ("GET", "/api/_admin/info"), - ] - - -def test_client_sends_bearer_token_and_quotes_path_segments() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["authorization"] == "Bearer test-token" - assert request.url.raw_path == b"/api/records/v1/chat%20messages/id%2F1" - return httpx.Response(200, json={"id": "id/1"}) - - client = TrailBaseClient( - base_url="http://trailbase.test", - auth_token="test-token", - transport=httpx.MockTransport(handler), - ) - - assert client.get_record("chat messages", "id/1") == {"id": "id/1"} - - -def test_client_passes_record_list_query_parameters() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/api/records/v1/venue" - assert request.url.params["geojson"] == "geometry" - assert request.url.params["limit"] == "1024" - assert request.url.params["skip_cursor"] == "true" - assert request.url.params["cursor"] == "next-page" - return httpx.Response(200, json={"type": "FeatureCollection", "features": []}) - - client = TrailBaseClient( - base_url="http://trailbase.test", - transport=httpx.MockTransport(handler), - ) - - assert client.list_records( - "venue", - { - "geojson": "geometry", - "limit": 1024, - "skip_cursor": "true", - "cursor": "next-page", - }, - ) == {"type": "FeatureCollection", "features": []} - - -def test_client_generic_trailbase_request() -> None: - def handler(request: httpx.Request) -> httpx.Response: - assert request.method == "POST" - assert request.url.path == "/api/custom/search" - assert request.url.params["q"] == "coffee" - assert request.read() == b'{"limit":10}' - return httpx.Response(200, json={"ok": True}) - - client = TrailBaseClient( - base_url="http://trailbase.test", - transport=httpx.MockTransport(handler), - ) - - assert client.trailbase_request( - "POST", - "/api/custom/search", - params={"q": "coffee"}, - body={"limit": 10}, - ) == {"ok": True} - - -def test_generic_trailbase_request_rejects_absolute_urls() -> None: - assert validate_relative_path("/api/auth/v1/status") == "/api/auth/v1/status" - with pytest.raises(ValueError, match="server-relative"): - validate_relative_path("api/auth/v1/status") - with pytest.raises(ValueError, match="absolute URL"): - validate_relative_path("https://example.com/api") - - -def test_quote_sql_identifier_rejects_unsafe_names() -> None: - assert quote_sql_identifier("candyland_2") == '"candyland_2"' - with pytest.raises(ValueError, match="SQL identifier"): - quote_sql_identifier("candyland; drop table users") - with pytest.raises(ValueError, match="SQL identifier"): - quote_sql_identifier("candy-land") - - -def test_server_generic_trailbase_request_write_gate(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("TRAILBASE_MCP_ENABLE_WRITES", raising=False) - - with pytest.raises(RuntimeError, match="Write operations are disabled"): - trailbase_request("POST", "/api/auth/v1/login", body={}) - - -def test_trailbase_api_operation_catalog_covers_openapi_pages() -> None: - operations = list_api_operations() - operation_ids = {operation["operation_id"] for operation in operations} - - assert len(operations) == 36 - assert { - "auth_code_to_token_handler", - "login_handler", - "refresh_handler", - "callback_from_external_auth_provider", - "add_subscription_sse_handler", - "create_record_handler", - "json_schema_handler", - "update_record_handler", - }.issubset(operation_ids) - - assert get_api_operation("list_records_handler") == { - "operation_id": "list_records_handler", - "category": "records", - "method": "GET", - "path": "/api/records/v1/{name}", - "summary": "List records matching filters.", - "mcp_support": "list_records or call_trailbase_api_operation", - "requires_write_permission": False, - } - - assert [operation["category"] for operation in list_api_operations("oauth")] == [ - "oauth", - "oauth", - "oauth", - ] - - -def test_render_operation_path_quotes_parameters() -> None: - operation = get_api_operation("read_record_handler") - assert ( - render_operation_path(operation, {"name": "chat messages", "record": "id/1"}) - == "/api/records/v1/chat%20messages/id%2F1" - ) - - with pytest.raises(ValueError, match="Missing path parameter"): - render_operation_path(operation, {"name": "widgets"}) - - -def test_call_trailbase_api_operation(monkeypatch: pytest.MonkeyPatch) -> None: - seen = None - - class FakeClient: - def trailbase_request(self, method, path, *, params=None, body=None): - nonlocal seen - seen = (method, path, params, body) - return {"ok": True} - - monkeypatch.delenv("TRAILBASE_MCP_ENABLE_WRITES", raising=False) - monkeypatch.setattr(server_module, "_client", lambda: FakeClient()) - - assert call_trailbase_api_operation( - "read_record_handler", - path_params={"name": "widgets", "record": "1"}, - params={"expand": "author"}, - ) == {"ok": True} - assert seen == ( - "GET", - "/api/records/v1/widgets/1", - {"expand": "author"}, - None, - ) - - with pytest.raises(RuntimeError, match="Write operations are disabled"): - call_trailbase_api_operation( - "create_record_handler", - path_params={"name": "widgets"}, - body={"name": "Ada"}, - ) - - with pytest.raises(RuntimeError, match="long-running streaming endpoint"): - call_trailbase_api_operation( - "add_subscription_sse_handler", - path_params={"name": "widgets", "record": "1"}, - ) - - -def test_client_derives_csrf_header_from_jwt() -> None: - token = "header.eyJjc3JmX3Rva2VuIjoiY3NyZi0xMjMifQ.signature" - - def handler(request: httpx.Request) -> httpx.Response: - assert request.headers["csrf-token"] == "csrf-123" - return httpx.Response(200, json={"ok": True}) - - assert csrf_token_from_jwt(token) == "csrf-123" - client = TrailBaseClient( - base_url="http://trailbase.test", - auth_token=token, - transport=httpx.MockTransport(handler), - ) - - assert client.admin_info() == {"ok": True} - - -def test_client_raises_with_response_body() -> None: - client = TrailBaseClient( - base_url="http://trailbase.test", - transport=httpx.MockTransport(lambda _request: httpx.Response(401, text="nope")), - ) - - with pytest.raises(RuntimeError, match="HTTP 401: nope"): - client.admin_info() - - -def test_client_schema_modes_and_file_download() -> None: - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/api/records/v1/widgets/schema": - assert request.url.params["mode"] == "Select" - return httpx.Response(200, json={"title": "widgets"}) - - if request.url.path == "/api/records/v1/widgets/123/file/avatar": - return httpx.Response( - 200, - content=b"hello-file", - headers={"content-type": "text/plain"}, - ) - - raise AssertionError(request.url) - - client = TrailBaseClient( - base_url="http://trailbase.test", - transport=httpx.MockTransport(handler), - ) - - assert client.api_json_schema("widgets", mode="Select") == {"title": "widgets"} - downloaded = client.download_file("widgets", "123", "avatar") - assert downloaded["content_type"] == "text/plain" - assert downloaded["content_base64"] == "aGVsbG8tZmlsZQ==" - - -def test_file_upload_input_and_json_file_create() -> None: - seen_payload = None - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal seen_payload - seen_payload = request.read() - return httpx.Response(200, json={"ids": ["1"]}) - - upload = file_upload_input( - { - "field": "avatar", - "filename": "avatar.txt", - "content_type": "text/plain", - "content_base64": "aGVsbG8=", - } - ) - assert upload == { - "name": "avatar", - "filename": "avatar.txt", - "content_type": "text/plain", - "data": "aGVsbG8=", - } - - client = TrailBaseClient( - base_url="http://trailbase.test", - transport=httpx.MockTransport(handler), - ) - - assert client.create_record_with_file_uploads( - "profiles", - {"name": "Ada"}, - [ - { - "field": "avatar", - "filename": "avatar.txt", - "content_type": "text/plain", - "content_base64": "aGVsbG8=", - }, - { - "field": "attachments", - "filename": "notes.txt", - "content_base64": "bm90ZXM=", - "multiple": True, - }, - ], - ) == {"ids": ["1"]} - assert seen_payload is not None - payload = seen_payload.decode() - assert '"name":"Ada"' in payload - assert '"avatar":{"data":"aGVsbG8="' in payload - assert '"attachments":[{"data":"bm90ZXM="' in payload - - -def test_client_decodes_and_updates_protobuf_config() -> None: - config_response = config_api_pb2.GetConfigResponse() - config_response.hash = "hash-1" - config_response.config.email.smtp_host = "localhost" - config_response.config.server.application_name = "TrailBase" - config_response.config.auth.password_minimal_length = 8 - config_response.config.jobs.SetInParent() - - seen_update = None - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal seen_update - if request.method == "GET": - return httpx.Response(200, content=config_response.SerializeToString()) - - update = config_api_pb2.UpdateConfigRequest() - update.ParseFromString(request.content) - seen_update = update - return httpx.Response(200, content=b"") - - client = TrailBaseClient( - base_url="http://trailbase.test", - transport=httpx.MockTransport(handler), - ) - - decoded = client.admin_config() - assert decoded["hash"] == "hash-1" - assert decoded["config"]["server"]["application_name"] == "TrailBase" - - decoded["config"].setdefault("record_apis", []).append( - { - "name": "widgets", - "table_name": "widgets", - "acl_world": [1, 2, 4, 8, 16], - } - ) - assert client.update_config(decoded["config"], decoded["hash"]) == {"ok": True} - assert seen_update is not None - assert seen_update.hash == "hash-1" - assert seen_update.config.record_apis[0].name == "widgets" - - -def test_client_removes_record_api_before_drop_table() -> None: - config_response = config_api_pb2.GetConfigResponse() - config_response.hash = "hash-1" - config_response.config.email.smtp_host = "localhost" - config_response.config.server.application_name = "TrailBase" - config_response.config.auth.password_minimal_length = 8 - config_response.config.jobs.SetInParent() - config_response.config.record_apis.add( - name="widgets", - table_name="widgets", - acl_world=[1, 2], - ) - config_response.config.record_apis.add( - name="other_widgets", - table_name="widgets", - acl_world=[1], - ) - config_response.config.record_apis.add( - name="profiles", - table_name="profiles", - acl_world=[1], - ) - - updates: list[config_api_pb2.UpdateConfigRequest] = [] - queries: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - if request.url.path == "/api/_admin/config" and request.method == "GET": - return httpx.Response(200, content=config_response.SerializeToString()) - - if request.url.path == "/api/_admin/config" and request.method == "POST": - update = config_api_pb2.UpdateConfigRequest() - update.ParseFromString(request.content) - updates.append(update) - return httpx.Response(200, content=b"") - - if request.url.path == "/api/_admin/query": - queries.append(json.loads(request.read())["query"]) - return httpx.Response(200, json={"columns": None, "rows": []}) - - raise AssertionError(request.url) - - client = TrailBaseClient( - base_url="http://trailbase.test", - transport=httpx.MockTransport(handler), - ) - - result = client.drop_table("widgets") - assert result["ok"] - assert [api["name"] for api in result["removed_record_apis"]] == [ - "widgets", - "other_widgets", - ] - assert queries == ['DROP TABLE IF EXISTS "widgets"'] - assert len(updates) == 1 - assert [api.name for api in updates[0].config.record_apis] == ["profiles"] From 1610ef27a4d0cf5433f5fc8fa0fccab19a455018 Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 15:46:36 +1200 Subject: [PATCH 23/31] Add native MCP schema and config tools --- crates/core/src/mcp.rs | 153 ++++++++++++++++++++++++++++++++++++++--- mcp/README.md | 10 +++ 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs index 4b24156d2..7594ce5f6 100644 --- a/crates/core/src/mcp.rs +++ b/crates/core/src/mcp.rs @@ -38,6 +38,21 @@ struct AdminRequest { body: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct SqlRequest { + /// One or more SQLite statements. Schema-changing statements refresh TrailBase metadata. + query: String, + /// Optional configured attached database names. + #[serde(default)] + attached_databases: Option>, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct ConfigUpdateRequest { + /// Complete TrailBase config in protobuf text format, as returned by get_config. + config: String, +} + #[derive(Clone)] struct TrailBaseMcp { state: AppState, @@ -52,17 +67,8 @@ impl TrailBaseMcp { tool_router: Self::tool_router(), } } -} -#[tool_router] -impl TrailBaseMcp { - #[tool( - description = "Call a TrailBase admin API in-process. Paths are relative to /api/_admin. This exposes the same table, index, row, config, schema, query, user, log, backup, job, and WASM operations as the admin dashboard." - )] - async fn call_admin_api( - &self, - Parameters(request): Parameters, - ) -> Result, McpError> { + async fn dispatch_admin(&self, request: AdminRequest) -> Result, McpError> { let method = Method::from_bytes(request.method.as_bytes()) .map_err(|_| McpError::invalid_params("invalid HTTP method", None))?; let path = normalize_admin_path(&request.path)?; @@ -114,6 +120,91 @@ impl TrailBaseMcp { } } +#[tool_router] +impl TrailBaseMcp { + #[tool( + description = "Call a TrailBase admin API in-process. Paths are relative to /api/_admin. This exposes the same table, index, row, config, schema, query, user, log, backup, job, and WASM operations as the admin dashboard." + )] + async fn call_admin_api( + &self, + Parameters(request): Parameters, + ) -> Result, McpError> { + self.dispatch_admin(request).await + } + + #[tool(description = "List TrailBase tables, views, columns, indexes, triggers, and metadata.")] + async fn list_tables(&self) -> Result, McpError> { + self + .dispatch_admin(AdminRequest { + method: "GET".to_string(), + path: "tables".to_string(), + body: None, + }) + .await + } + + #[tool( + description = "Execute SQL using TrailBase's admin query handler. Supports reads and writes; schema changes refresh cached metadata." + )] + async fn execute_sql( + &self, + Parameters(request): Parameters, + ) -> Result, McpError> { + self + .dispatch_admin(AdminRequest { + method: "POST".to_string(), + path: "query".to_string(), + body: Some(json!({ + "query": request.query, + "attached_databases": request.attached_databases, + })), + }) + .await + } + + #[tool( + description = "Get the complete TrailBase configuration as protobuf text. Secret values are redacted." + )] + fn get_config(&self) -> Result { + let (config, _) = crate::config::redact_secrets(&self.state.get_config()) + .map_err(|err| McpError::internal_error(err.to_string(), None))?; + config + .to_text() + .map_err(|err| McpError::internal_error(err.to_string(), None)) + } + + #[tool( + description = "Validate and replace the TrailBase configuration using protobuf text from get_config. Existing secret values are preserved." + )] + async fn update_config( + &self, + Parameters(request): Parameters, + ) -> Result { + if self.state.demo_mode() { + return Err(McpError::invalid_request( + "config updates are disabled in demo mode", + None, + )); + } + + let config = crate::config::proto::Config::from_text(&request.config) + .map_err(|err| McpError::invalid_params(err.to_string(), None))?; + let current = self.state.get_config(); + let hash = crate::config::proto::hash_config(¤t); + let (_, secrets) = crate::config::redact_secrets(¤t) + .map_err(|err| McpError::internal_error(err.to_string(), None))?; + let config = + crate::config::merge_vault_and_env(config, crate::config::proto::Vault { secrets }) + .map_err(|err| McpError::invalid_params(err.to_string(), None))?; + self + .state + .validate_and_update_config(config, Some(hash)) + .await + .map_err(|err| McpError::invalid_params(err.to_string(), None))?; + Ok("Config updated".to_string()) + } +} + #[tool_handler] impl ServerHandler for TrailBaseMcp { fn get_info(&self) -> ServerInfo { @@ -674,4 +765,46 @@ mod tests { assert!(location.contains("response_type=code")); assert!(location.contains("pkce_code_challenge=")); } + + #[tokio::test] + async fn native_tools_use_live_admin_state() { + let state = test_state(None).await.unwrap(); + let server = TrailBaseMcp::new(state); + let tool_names: Vec<_> = server + .tool_router + .list_all() + .into_iter() + .map(|tool| tool.name.to_string()) + .collect(); + assert_eq!( + tool_names, + [ + "call_admin_api", + "execute_sql", + "get_config", + "list_tables", + "update_config" + ] + ); + + let config = server.get_config().unwrap(); + assert!(config.contains("auth")); + assert_eq!( + server + .update_config(Parameters(ConfigUpdateRequest { config })) + .await + .unwrap(), + "Config updated" + ); + + server + .execute_sql(Parameters(SqlRequest { + query: "CREATE TABLE mcp_native_tool_test (id INTEGER PRIMARY KEY)".to_string(), + attached_databases: None, + })) + .await + .unwrap(); + let tables = server.list_tables().await.unwrap(); + assert!(tables.0.to_string().contains("mcp_native_tool_test")); + } } diff --git a/mcp/README.md b/mcp/README.md index 1db143d70..a342bebb9 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -134,6 +134,16 @@ Treat MCP as an administrative surface: ## Tools +The native server provides focused tools for common work: + +- `list_tables`: table, view, column, index, trigger, and metadata discovery. +- `execute_sql`: read or mutate the main database and refresh metadata after + recognized schema changes. +- `get_config`: return redacted TrailBase protobuf-text configuration. +- `update_config`: validate and save configuration while preserving existing + secrets. +- `call_admin_api`: reach all remaining admin dashboard operations. + `call_admin_api(method, path, body?)` dispatches directly to TrailBase's in-process admin router. `path` is relative to `/api/_admin`; it may also be the full `/api/_admin/...` path. This means MCP and the dashboard use the same Rust From b2c7e937eac60309191a7fc63af9b41d550c6a2f Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 15:57:43 +1200 Subject: [PATCH 24/31] Bind OAuth tokens to the MCP resource --- crates/core/src/auth/jwt.rs | 11 ++++ crates/core/src/mcp.rs | 112 +++++++++++++++++++++++++++++++++--- mcp/README.md | 6 +- 3 files changed, 120 insertions(+), 9 deletions(-) diff --git a/crates/core/src/auth/jwt.rs b/crates/core/src/auth/jwt.rs index 8fca78636..c7c610ff4 100644 --- a/crates/core/src/auth/jwt.rs +++ b/crates/core/src/auth/jwt.rs @@ -311,6 +311,17 @@ impl JwtHelper { .map(|data| data.claims); } + pub(crate) fn decode_with_audience( + &self, + token: &str, + audience: &str, + ) -> Result { + let mut validation = self.validation.clone(); + validation.set_audience(&[audience]); + return jsonwebtoken::decode::(token, &self.decoding_key, &validation) + .map(|data| data.claims); + } + pub fn encode(&self, claims: &T) -> Result { return jsonwebtoken::encode::(&self.header, claims, &self.encoding_key); } diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs index 7594ce5f6..a59331585 100644 --- a/crates/core/src/mcp.rs +++ b/crates/core/src/mcp.rs @@ -1,12 +1,12 @@ use std::sync::Arc; +use axum::Router; use axum::body::Body; use axum::extract::{Form, Json as AxumJson, Path, Query, Request, State}; use axum::http::{HeaderValue, Method, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; -use axum::{RequestExt, Router}; use http_body_util::BodyExt; use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters}; use rmcp::model::{ErrorData as McpError, Implementation, ServerCapabilities, ServerInfo}; @@ -22,7 +22,7 @@ use tower::ServiceExt; use crate::admin; use crate::app_state::AppState; use crate::auth::util::is_admin; -use crate::auth::{AuthError, User}; +use crate::auth::{AuthError, AuthTokenClaims, User}; const MCP_SCOPE: &str = "mcp"; const MCP_PATH: &str = "/mcp"; @@ -264,12 +264,18 @@ pub(crate) fn router(state: &AppState) -> Router { async fn assert_mcp_access( State(state): State, - mut request: Request, + request: Request, next: Next, ) -> Response { - let authorized = match request.extract_parts_with_state::(&state).await { - Ok(user) => is_admin(&state, &user.uuid).await, - Err(_) => false, + let user_id = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .and_then(|token| mcp_user_id(&state, token)); + let authorized = match user_id { + Some(user_id) => is_admin(&state, &user_id).await, + None => false, }; if authorized { return next.run(request).await; @@ -287,6 +293,45 @@ async fn assert_mcp_access( response } +#[derive(Clone, Serialize, Deserialize)] +struct McpAccessTokenClaims { + #[serde(flatten)] + auth: AuthTokenClaims, + aud: String, + scope: String, +} + +#[derive(Clone, Deserialize)] +struct CompatibilityAccessTokenClaims { + #[serde(flatten)] + auth: AuthTokenClaims, + #[serde(default)] + aud: Option, +} + +fn mcp_user_id(state: &AppState, token: &str) -> Option { + let resource = external_url(state, MCP_PATH); + if let Ok(claims) = state + .jwt() + .decode_with_audience::(token, &resource) + { + if !claims.scope.split(' ').any(|scope| scope == MCP_SCOPE) { + return None; + } + return crate::util::b64_to_uuid(&claims.auth.sub).ok(); + } + + // Compatibility mode for callers that explicitly supply a normal TrailBase admin token. + let claims = state + .jwt() + .decode::(token) + .ok()?; + if claims.aud.is_some() { + return None; + } + crate::util::b64_to_uuid(&claims.auth.sub).ok() +} + #[derive(Serialize)] struct ProtectedResourceMetadata { resource: String, @@ -595,13 +640,22 @@ async fn oauth_token( } _ => return Err(OAuthError::invalid_grant("unsupported grant_type")), }; - let claims = crate::auth::AuthTokenClaims::from_auth_token(state.jwt(), &access_token) + let claims = AuthTokenClaims::from_auth_token(state.jwt(), &access_token) .map_err(|_| OAuthError::server("failed to decode issued access token"))?; + let expires_in = (claims.exp - chrono::Utc::now().timestamp()).max(0); + let access_token = state + .jwt() + .encode(&McpAccessTokenClaims { + auth: claims, + aud: external_url(&state, MCP_PATH), + scope: MCP_SCOPE.to_string(), + }) + .map_err(|err| OAuthError::server(err.to_string()))?; Ok(AxumJson(TokenResponse { access_token, token_type: "Bearer", - expires_in: (claims.exp - chrono::Utc::now().timestamp()).max(0), + expires_in, refresh_token, scope: MCP_SCOPE, })) @@ -807,4 +861,46 @@ mod tests { let tables = server.list_tables().await.unwrap(); assert!(tables.0.to_string().contains("mcp_native_tool_test")); } + + #[tokio::test] + async fn mcp_tokens_are_bound_to_the_mcp_resource() { + let state = test_state(None).await.unwrap(); + let user_id = uuid::Uuid::new_v4(); + let now = chrono::Utc::now().timestamp(); + let claims = AuthTokenClaims { + sub: crate::util::uuid_to_b64(&user_id), + iat: now, + exp: now + 60, + r#type: 1, + admin: true, + mfa: false, + provider: 0, + email: Some("admin@localhost".to_string()), + username: Some("admin".to_string()), + csrf_token: "csrf".to_string(), + }; + + let scoped = state + .jwt() + .encode(&McpAccessTokenClaims { + auth: claims.clone(), + aud: external_url(&state, MCP_PATH), + scope: MCP_SCOPE.to_string(), + }) + .unwrap(); + assert_eq!(mcp_user_id(&state, &scoped), Some(user_id)); + + let wrong_audience = state + .jwt() + .encode(&McpAccessTokenClaims { + auth: claims.clone(), + aud: "https://other.example/mcp".to_string(), + scope: MCP_SCOPE.to_string(), + }) + .unwrap(); + assert_eq!(mcp_user_id(&state, &wrong_audience), None); + + let legacy = state.jwt().encode(&claims).unwrap(); + assert_eq!(mcp_user_id(&state, &legacy), Some(user_id)); + } } diff --git a/mcp/README.md b/mcp/README.md index a342bebb9..b6e928cff 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -115,6 +115,8 @@ The native MCP implementation follows the HTTP MCP authorization flow: - OAuth Authorization Server Metadata (RFC 8414). - Dynamic Client Registration (RFC 7591). - Authorization Code flow with PKCE S256. +- MCP access tokens scoped to `mcp` and audience-bound to the instance's + public `/mcp` resource URL. - Access-token refresh. - `WWW-Authenticate` discovery on unauthenticated MCP requests. @@ -188,7 +190,9 @@ Authorization: Bearer The access token returned by `/api/auth/v1/login` is short-lived. Clients using this mode must manage `/api/auth/v1/refresh` themselves. Do not put an admin -password or long-lived refresh token in a shared project configuration. +password or long-lived refresh token in a shared project configuration. This +compatibility mode accepts only ordinary TrailBase tokens without an OAuth +audience; an MCP token minted for another TrailBase resource is rejected. ## Development validation From 246f75a9c2b7c2d5976ce8860276635a99d574a2 Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 16:18:40 +1200 Subject: [PATCH 25/31] Scope MCP authentication to the MCP route --- crates/core/src/mcp.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs index a59331585..55143ef19 100644 --- a/crates/core/src/mcp.rs +++ b/crates/core/src/mcp.rs @@ -234,13 +234,14 @@ pub(crate) fn router(state: &AppState) -> Router { .disable_allowed_hosts(), ); - let protected_mcp = - Router::new() - .nest_service("/mcp", service) - .layer(middleware::from_fn_with_state( - state.clone(), - assert_mcp_access, - )); + let protected_mcp = Router::new() + .nest_service("/mcp", service) + // Keep MCP authentication scoped to the MCP route. A normal layer also wraps this + // router's fallback and would intercept auth UI routes after this router is merged. + .route_layer(middleware::from_fn_with_state( + state.clone(), + assert_mcp_access, + )); Router::new() .merge(protected_mcp) From 69bcb581d26452b075f7d8fae01104083eb5197a Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 16:26:31 +1200 Subject: [PATCH 26/31] Clarify portable MCP client configuration --- mcp/README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index b6e928cff..148e899cf 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -44,7 +44,9 @@ Clients with native remote-MCP and OAuth support can connect directly to: https://trailbase.example.com/mcp ``` -For IDEs that accept only local command-based MCP servers, use `mcp-remote`: +For IDEs that accept only local command-based MCP servers, use the portable +configuration below. Replace the example hostname with the public URL of the +TrailBase installation: ```json { @@ -63,6 +65,16 @@ For IDEs that accept only local command-based MCP servers, use `mcp-remote`: } ``` +This configuration assumes Node.js 20.18.1 or newer, as required by the HTTP +client currently used by `mcp-remote`. It does not require `NODE_OPTIONS`, a +polyfill, a TrailBase bearer token, or any machine-specific paths. Upgrade the +Node.js runtime selected by the IDE if an older runtime reports that `File` is +not defined. + +For local development, change the URL to +`http://127.0.0.1:4000/mcp` and add `"--allow-http"` after the URL. Do not use +plain HTTP for a deployed instance. + On the first connection, the client opens a browser at TrailBase's login page. Sign in with a TrailBase administrator account. Credentials are submitted only to that TrailBase instance; they are not stored in the IDE configuration or From eaa6ea9f8cc1b9a0fd1382b93b04c5292d353afe Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 16:28:54 +1200 Subject: [PATCH 27/31] Use localhost in MCP quick start --- mcp/README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 148e899cf..1f1c9e9e3 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -41,12 +41,11 @@ The official Docker image already contains the auth UI component. Clients with native remote-MCP and OAuth support can connect directly to: ```text -https://trailbase.example.com/mcp +http://localhost:4000/mcp ``` -For IDEs that accept only local command-based MCP servers, use the portable -configuration below. Replace the example hostname with the public URL of the -TrailBase installation: +For IDEs that accept only local command-based MCP servers, this localhost +configuration is a quick way to get started: ```json { @@ -56,7 +55,8 @@ TrailBase installation: "args": [ "-y", "mcp-remote", - "https://trailbase.example.com/mcp", + "http://localhost:4000/mcp", + "--allow-http", "--static-oauth-client-metadata", "{\"scope\":\"mcp\"}" ] @@ -71,9 +71,15 @@ polyfill, a TrailBase bearer token, or any machine-specific paths. Upgrade the Node.js runtime selected by the IDE if an older runtime reports that `File` is not defined. -For local development, change the URL to -`http://127.0.0.1:4000/mcp` and add `"--allow-http"` after the URL. Do not use -plain HTTP for a deployed instance. +`localhost` can use any port on which TrailBase is listening, for example +`http://localhost:4100/mcp`. `--allow-http` is intended only for local +development. + +For Cloudflare Tunnel, a reverse proxy, or another deployed instance, replace +the localhost URL with the public HTTPS URL, such as +`https://trailbase.example.com/mcp`, and remove `"--allow-http"`. Configure +TrailBase's `--public-url` with the same public HTTPS origin so OAuth discovery, +redirects, and token audience validation agree. On the first connection, the client opens a browser at TrailBase's login page. Sign in with a TrailBase administrator account. Credentials are submitted only From 2c27a1f5d5fc5f2accf8d2d296beb1b1c1ce857a Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 16:55:29 +1200 Subject: [PATCH 28/31] Use depot flag in MCP deployment examples --- docker-compose.yml | 2 +- mcp/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5dbc2f437..13fb164d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,4 +16,4 @@ services: RUST_BACKTRACE: "1" # Add --mcp to expose the authenticated native MCP endpoint at /mcp. # Also set --public-url to the external HTTPS origin used by MCP clients. - # command: "/app/trail --data-dir /app/traildepot --public-url https://trailbase.example.com run --address 0.0.0.0:4000 --mcp" + # command: "/app/trail --depot /app/traildepot --public-url https://trailbase.example.com run --address 0.0.0.0:4000 --mcp" diff --git a/mcp/README.md b/mcp/README.md index 1f1c9e9e3..fc2b0e40c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -108,7 +108,7 @@ services: RUST_BACKTRACE: "1" command: - /app/trail - - --data-dir + - --depot - /app/traildepot - --public-url - https://trailbase.example.com From 286111aab8bfd80cc3d4066d7703be3fc41ca144 Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 16:59:28 +1200 Subject: [PATCH 29/31] Explain native MCP deployment arguments --- mcp/README.md | 49 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index fc2b0e40c..a2184feea 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -100,30 +100,49 @@ services: trail: image: docker.io/trailbase/trailbase:latest ports: - - "4000:4000" + - "4000:4000" # HOST_PORT:CONTAINER_PORT restart: unless-stopped volumes: - - /mnt/traildepot:/app/traildepot + - /mnt/traildepot:/app/traildepot # Persistent databases, files, and config. environment: RUST_BACKTRACE: "1" command: - - /app/trail - - --depot - - /app/traildepot - - --public-url - - https://trailbase.example.com - - run - - --address - - 0.0.0.0:4000 - - --mcp + - /app/trail # TrailBase executable. + - --depot # Persistent TrailBase data directory. + - /app/traildepot # Must match the container volume path above. + - --public-url # External origin used for OAuth and MCP tokens. + - https://trailbase.example.com # No /mcp suffix; include a non-default public port. + - run # Start the TrailBase HTTP server. + - --address # Listen inside the container on all interfaces. + - 0.0.0.0:4000 # Must match CONTAINER_PORT above. + - --mcp # Enable native MCP at /mcp on the same server. ``` +The command is explicit so the deployment does not depend on image defaults. +`--depot` replaces the deprecated `--data-dir`. `--public-url` is the origin a +browser and MCP client actually use; it must not include `/mcp` or an admin UI +path. `--mcp` is the only MCP-specific startup flag. + +The port mapping does not need to be `4000:4000`. For example, to expose host +port `5000` while TrailBase continues listening on container port `4000`, use: + +```yaml +ports: + - "5000:4000" # Host port 5000 forwards to container port 4000. +``` + +Keep `--address 0.0.0.0:4000`. When connecting directly on localhost, set +`--public-url` to `http://localhost:5000`; the MCP URL is then +`http://localhost:5000/mcp`. + Point the existing Cloudflare Tunnel or reverse proxy at port `4000`. The same hostname serves both TrailBase and `/mcp`; no public port `4001` or `8000` is -needed. Do not place Cloudflare Access or another interactive login layer in -front of only `/mcp`, because MCP clients need to reach TrailBase's OAuth -discovery and authorization endpoints. TLS termination at Cloudflare or the -reverse proxy is expected. +needed. For example, the tunnel can forward `https://trailbase.example.com` to +`http://trail:4000`; keep `--public-url https://trailbase.example.com`, and use +`https://trailbase.example.com/mcp` in the MCP client. Do not place Cloudflare +Access or another interactive login layer in front of only `/mcp`, because MCP +clients also need to reach TrailBase's OAuth discovery and authorization +endpoints. TLS termination at Cloudflare or the reverse proxy is expected. ## Authentication and security From 5bf2123aa7ebec3855c8c0ca564396aab204c28d Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 17:01:19 +1200 Subject: [PATCH 30/31] Add local and public MCP client examples --- mcp/README.md | 62 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index a2184feea..c538f716f 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -38,19 +38,22 @@ The official Docker image already contains the auth UI component. ## IDE configuration -Clients with native remote-MCP and OAuth support can connect directly to: +Clients with native remote-MCP and OAuth support can connect directly to either +the local or public MCP URL. For IDEs that accept only local command-based MCP +servers, use the matching `mcp-remote` configuration below. + +### Localhost + +Use this while TrailBase is running on the same machine as the IDE: ```text http://localhost:4000/mcp ``` -For IDEs that accept only local command-based MCP servers, this localhost -configuration is a quick way to get started: - ```json { "mcpServers": { - "trailbase": { + "trailbase-local": { "command": "npx", "args": [ "-y", @@ -65,22 +68,47 @@ configuration is a quick way to get started: } ``` -This configuration assumes Node.js 20.18.1 or newer, as required by the HTTP -client currently used by `mcp-remote`. It does not require `NODE_OPTIONS`, a -polyfill, a TrailBase bearer token, or any machine-specific paths. Upgrade the -Node.js runtime selected by the IDE if an older runtime reports that `File` is -not defined. +`localhost` can use any port on which TrailBase is exposed, for example +`http://localhost:4100/mcp`. `--allow-http` is required for a plain HTTP URL and +is intended only for local development. + +### Public HTTPS URL -`localhost` can use any port on which TrailBase is listening, for example -`http://localhost:4100/mcp`. `--allow-http` is intended only for local -development. +Use this for Cloudflare Tunnel, a reverse proxy, or another deployed TrailBase +instance: -For Cloudflare Tunnel, a reverse proxy, or another deployed instance, replace -the localhost URL with the public HTTPS URL, such as -`https://trailbase.example.com/mcp`, and remove `"--allow-http"`. Configure -TrailBase's `--public-url` with the same public HTTPS origin so OAuth discovery, +```text +https://trailbase.example.com/mcp +``` + +```json +{ + "mcpServers": { + "trailbase": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://trailbase.example.com/mcp", + "--static-oauth-client-metadata", + "{\"scope\":\"mcp\"}" + ] + } + } +} +``` + +Replace `trailbase.example.com` with the deployment's public hostname. Do not +add `--allow-http` for HTTPS. Configure TrailBase's `--public-url` as +`https://trailbase.example.com` without the `/mcp` suffix so OAuth discovery, redirects, and token audience validation agree. +These command-based configurations assume Node.js 20.18.1 or newer, as required +by the HTTP client currently used by `mcp-remote`. They do not require `NODE_OPTIONS`, a +polyfill, a TrailBase bearer token, or any machine-specific paths. Upgrade the +Node.js runtime selected by the IDE if an older runtime reports that `File` is +not defined. + On the first connection, the client opens a browser at TrailBase's login page. Sign in with a TrailBase administrator account. Credentials are submitted only to that TrailBase instance; they are not stored in the IDE configuration or From 9401046944d1bed13943cfd792c4c6abc0c8103d Mon Sep 17 00:00:00 2001 From: brigon Date: Sun, 9 Aug 2026 17:07:54 +1200 Subject: [PATCH 31/31] Explain MCP connection naming --- mcp/README.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index c538f716f..450b4413c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -103,11 +103,37 @@ add `--allow-http` for HTTPS. Configure TrailBase's `--public-url` as `https://trailbase.example.com` without the `/mcp` suffix so OAuth discovery, redirects, and token audience validation agree. +The name directly below `"mcpServers"` is chosen by the user and can describe +the connection. For example, `"trailbase"` can be changed to +`"production-database"`, `"my-trailbase-server"`, or `"trailbase-local"`: + +```json +{ + "mcpServers": { + "production-database": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://trailbase.example.com/mcp", + "--static-oauth-client-metadata", + "{\"scope\":\"mcp\"}" + ] + } + } +} +``` + +Do not rename `"mcp-remote"` in the arguments when using this configuration; +it is the npm package that `npx` downloads and runs. IDEs with native remote +HTTP MCP and OAuth support can use the `/mcp` URL directly without `npx` or +`mcp-remote`. + These command-based configurations assume Node.js 20.18.1 or newer, as required -by the HTTP client currently used by `mcp-remote`. They do not require `NODE_OPTIONS`, a -polyfill, a TrailBase bearer token, or any machine-specific paths. Upgrade the -Node.js runtime selected by the IDE if an older runtime reports that `File` is -not defined. +by the HTTP client currently used by `mcp-remote`. They do not require +`NODE_OPTIONS`, a polyfill, a TrailBase bearer token, or any machine-specific +paths. Upgrade the Node.js runtime selected by the IDE if an older runtime +reports that `File` is not defined. On the first connection, the client opens a browser at TrailBase's login page. Sign in with a TrailBase administrator account. Credentials are submitted only