TrustDesk MCP Server
by elchinoo
README.md
<p align="center"><img src="src/trustdesk/static/logo.svg" alt="TrustDesk logo" width="96" height="96"></p>
<h1 align="center">TrustDesk</h1>
A **compliance workspace** built on **Postgres + pgvector**, with a web app, a REST API and an **MCP server**. Load your company rules (HR, travel, expenses, security), let your applications send employee requests (vacation, sick leave, trips, expense reimbursements and anything else), and have every request **pre-validated by an AI model against your own documents** before an approver decides. The advice comes with the reasons and the quoted passages it is based on. You can also search the documents and ask questions that cite their sources.
**Documentation:** [docs/GUIDE.md](docs/GUIDE.md) is the complete guide (ecosystem fit, architecture, install, configuration, operations, limitations); [docs/VIDEO-SCRIPT.md](docs/VIDEO-SCRIPT.md) is a 6-minute tutorial video script; [API.md](API.md) and [SECURITY.md](SECURITY.md) cover the client API and the security model. The section [EDB and AgentPG capabilities used by this app](docs/GUIDE.md#edb-and-agentpg-capabilities-used-by-this-app) lists the database features it uses (insights, masked read-only access, `unaccent`, `tcn` live updates) and what degrades without them.
> [!WARNING]
> **Hosted sandboxes can pause themselves and then answer `503`.**
> If you deploy TrustDesk on a platform whose free tier auto-pauses idle workloads (AgentPG sandboxes do), the app stops after a period of inactivity and the platform returns an empty **HTTP 503** for every URL until you resume it. Your data is safe (it lives in the database), but the app is unreachable.
>
> - **Resume it:** `agentpg sandbox resume <sandbox-id>`.
> - **Then unlock it:** a restart locks the stored provider keys until you sign in again (see [How secrets are handled](#how-secrets-are-handled)). Search with an external embedding provider and Ask need the keys.
> - **Plan for it:** resume a few minutes before any demo.
> - **Details and fixes:** see [Troubleshooting](#troubleshooting) and [Deploy on AgentPG](#deploy-on-agentpg).
## Features
- **Requests with AI pre-validation:** 19 request types (leave, trips, expenses, remote work, overtime, equipment, anything else). Each new request gets a recommendation (approve, reject, needs information), the reasons, and the policy passages it rests on, with quotes checked word for word against the documents. A person always decides; going against the advice needs a written justification. See [Compliance workflow](#compliance-workflow).
- **Client API** (`/api/v1`, see [API.md](API.md)) for applications that create employees and send requests, with a token scope that cannot see the AI advice or decide anything.
- **Reports:** overview, all requests, compliance by type, out of compliance, decisions against AI advice, expenses, leave, ageing and upcoming absences, as tables and CSV.
- **Web app** at `/`: requests queue and review window, employees, reports, documents, search with explainable results, **Ask** (an answer with citations from the model you pick), drag-and-drop upload, document viewer, provider accounts, API tokens, and copy-paste MCP setup for ten clients. Light and dark mode; works on phones.
- **Multiple provider accounts** (OpenAI, OpenRouter, GLM, Kimi, Claude, any OpenAI-compatible server), configured in the browser. Choose one for embeddings and one per question.
- **Hybrid retrieval**: vector and full-text results fused with reciprocal rank fusion (RRF), with provenance on every result.
- **Safe by default**: no secret files, admin password set on first run, hashed API tokens, encrypted provider keys. See [How secrets are handled](#how-secrets-are-handled).
- **MCP server** at `/mcp` (streamable HTTP): `search_knowledge`, `get_policy_passage`, `list_documents`.
- Plain-SQL migrations (no ORM), Markdown/text/PDF ingest with heading-aware chunking, idempotent and versioned.
- Works offline with a built-in demo embedder, and ships a fictional "Acme Cloudworks" policy pack for demos.
## Quick start
Requires Docker and [uv](https://docs.astral.sh/uv/) (or any Python 3.11+ environment).
```bash
cp .env.example .env # then set POSTGRES_PASSWORD; .env is git-ignored
docker compose up -d db
uv venv && uv pip install -e ".[dev]"
set -a; . ./.env; set +a
.venv/bin/trustdesk migrate
.venv/bin/trustdesk serve # http://localhost:8000
```
`pyproject.toml` takes the shared kit (`agentpg-kit`) from the same release tag the Docker image installs (`v0.5.1`), so no sibling checkout is needed. If you are also changing the kit, install your checkout over it: `uv pip install -e ../agentpg-kit` (or `make kit-local`).
The first time the server starts it prints a **one-time setup code** to its console:
```
=== TrustDesk first-run setup ===
Open the app in your browser and enter this one-time code to create the admin password:
XXXX-XXXX-XXXX
```
Open http://localhost:8000, enter the code and choose an admin password (12+ characters). Then go to **Documents** and click **Load demo policies**. Everything else is configured in the browser; there are no key files to create.
Everything in containers: `docker compose up --build`, then read the setup code from `docker compose logs api`.
### Embeddings
With no provider configured, a built-in demo embedder is used so everything runs with no credentials. It matches on words, not meaning. For real semantic search, add a provider account and switch embeddings to it (see [Using external LLMs](#using-external-llms-openai-openrouter-glm-kimi-claude-and-others)).
## How secrets are handled
Files full of credentials are the usual way these tools leak, so TrustDesk avoids them:
| Secret | Where it lives | How it is protected |
|---|---|---|
| **Admin password** | Entered once in the browser (first run needs the setup code from the server log) | Stored only as a salted **scrypt** hash. |
| **Browser session** | An `HttpOnly`, `SameSite=Strict` cookie (`Secure` over HTTPS) | Random value, only its SHA-256 stored server-side, 12-hour expiry, signing out or changing the password ends every session. No credential is kept in page storage. State-changing browser requests also need a custom header. |
| **API tokens** (MCP, scripts) | Created in **Settings**, shown **once** | Only a SHA-256 hash is stored. Revocable. `read` tokens can search and list but cannot change anything or spend provider credit. |
| **Provider API keys** (OpenAI etc.) | Entered in **Settings** | Encrypted (AES via Fernet) with a random **data key**. The data key is never stored in the clear: it is wrapped under a key derived from your admin password (scrypt). The database alone reveals nothing. Keys are never returned by the API (only the last 4 characters) and never logged. |
**What this means in practice**
- After a **restart** the stored provider keys are **locked** until you sign in or use the unlock box. The unwrapped key exists only in the server's memory. Until then, external embeddings and Ask return "locked" (searching with the demo embedder keeps working).
- For unattended restarts, you may *additionally* provide a platform-managed secret as `TRUSTDESK_ENCRYPTION_KEY` (or a file at `/etc/trustdesk/encryption_key`). The server then wraps the data key a second time and unlocks itself. This reintroduces one secret outside the app; keep it in your platform's secret store, never in git or an image.
- **Sealed boot (optional hardening):** anything in a container's environment, config files or image is readable by whoever breaches the platform. To deploy with none of it, set **only** `APGKIT_SEALED`: one blob, encrypted with AES-256-GCM under an operator passphrase that is never stored on the platform. Create it with `ecoctl seal` from agentpg-ecosystem, putting `DATABASE_URL`, `TRUSTDESK_OIDC_CLIENT_SECRET`, `LLM_API_KEY` and `TRUSTDESK_API_KEY` in it. TrustDesk then starts **sealed**: every route answers 503 except `/_seal/*` and `/healthz`, and it does not touch the database. The operator runs `ecoctl unseal` (the passphrase travels encrypted to a key that exists only in the running process); the app opens the blob, migrates, unlocks the vault with the same passphrase (`TRUSTDESK_ENCRYPTION_KEY` is ignored) and starts serving, including the background jobs and the MCP server. A restart seals it again. With no `APGKIT_SEALED`, nothing changes.
- **Row MACs (hardening):** while the vault is unlocked (always, with sealed boot) every row that grants access or carries trust holds a MAC, an HMAC-SHA256 under a subkey of the vault data key. Someone who can only **write** to the database (stolen credentials) cannot forge one, so a planted or edited row is **treated as missing** and logged once per row (table and key, never values). Covered: web sessions and API tokens (signed by the kit), provider accounts (`llm_account`: id, provider, base URL, chat and embedding model and dimension, which decide where requests are sent, and the encrypted provider key, so a key cannot be blanked, moved to another account or put back to an older value), and the settings that choose the validating and embedding account and the validation rates (`app_setting`, those four keys). After upgrading run `trustdesk migrate` (it clears the provider-account MACs written before the key was covered), then `trustdesk adopt-rowmacs` once (it asks for the admin password) to sign the rows that exist; **it trusts the database as it is at that moment**. Rows written while the vault is locked have no MAC and are ignored once it opens until adopted. Rotating the data key re-signs the rows that verified. With the vault locked nothing is checked and behaviour is unchanged. **Honest limits:** an older authentic row can be put back from a backup (replay); deleting a row is still possible (a deleted session or account is a denial of service, a deleted setting reverts to its default); employees, requests, documents and every column outside the list above are not covered; an attacker who can also read the process memory or the data key is out of scope.
- **Forgot the admin password?** Run `trustdesk reset-admin` on the server (it needs database access). It clears the password, the vault and **every stored provider key**, and the setup flow starts again. There is no back door: that is the point.
- Optionally, a static `TRUSTDESK_API_KEY` (environment variable or file) still works as a full-access credential for automation. Prefer API tokens.
- **Outbound requests:** provider URLs you enter are checked before every call. Loopback, private, link-local (cloud metadata) and carrier-grade-NAT addresses are refused, along with embedded credentials and plain `http` to non-local hosts; redirects are never followed. To allow a local model server such as Ollama, the operator sets `TRUSTDESK_ALLOW_PRIVATE_LLM_URLS=true`.
See [SECURITY.md](SECURITY.md) for the full model and its limits.
## Using external LLMs (OpenAI, OpenRouter, GLM, Kimi, Claude and others)
A language model does two different jobs here, configured in different places:
| Job | Where | Notes |
|---|---|---|
| **Embeddings**: turn documents and questions into vectors for search | **Settings > Search embeddings**: pick one account | Needs an OpenAI-compatible `/embeddings` endpoint. Switching models re-embeds your passages in the background, with progress, and changes nothing if the provider call fails. |
| **Answering**: write the reply from retrieved passages | **Ask**: pick an account for each question | Needs an OpenAI-compatible `/chat/completions` endpoint. The answer cites the numbered passages; an answer that cites none is flagged. |
| **Answering through your own tools** | Your MCP client's model | Claude Code uses Claude, Codex uses OpenAI models, and so on; see [Using the MCP server](#using-the-mcp-server). |
### Add accounts in the browser
Open **Settings > Provider accounts > Add account**, choose a provider (this pre-fills the URL and suggestions), paste the API key, set the chat and/or embedding model names, and use **Test chat** / **Test embeddings** to confirm it works. You can add as many accounts as you like. Model names change often, so use the ones from your provider's documentation.
| Provider | Base URL | Notes |
|---|---|---|
| OpenAI | `https://api.openai.com/v1` | Embedding model `text-embedding-3-small` supports a custom size, so 768 works. |
| OpenRouter | `https://openrouter.ai/api/v1` | One key for many models; has an OpenAI-style embeddings endpoint (for example `openai/text-embedding-3-small`). |
| Zhipu GLM | `https://open.bigmodel.cn/api/paas/v4` | Documented as OpenAI-compatible; embedding model `embedding-3`. Note `/v4`, not `/v1`. |
| Moonshot Kimi | `https://api.moonshot.ai/v1` | Documented for chat only; use another account for embeddings. |
| Anthropic Claude | `https://api.anthropic.com/v1` | Anthropic's OpenAI-compatibility endpoint, which they describe as not for production use. Chat only: Anthropic offers no embedding model. |
| Self-hosted (Ollama, vLLM, LiteLLM) | for example `http://localhost:11434/v1` | Needs `TRUSTDESK_ALLOW_PRIVATE_LLM_URLS=true` on the server. |
> [!NOTE]
> These providers were checked against their public documentation only. The request format is covered by automated tests against a mock server and was exercised end to end in the UI against a fake local provider, but it has **not** been run against each live provider. Test with a few documents first.
**Choosing the embedding dimension.** It must match what the model returns; if it doesn't, TrustDesk tells you the number the model actually returned. The index supports at most 2000 dimensions, so models whose default is larger (for example 3072) must be asked for a smaller size.
**Privacy.** With an external provider, the text of document passages, search queries and questions is sent to that provider. Don't use it for documents you aren't allowed to share with them, and check their data-retention terms.
### Environment configuration (optional)
You can also configure a single provider from the environment instead of the browser. It appears read-only in Settings as "Environment".
| Variable | Meaning |
|---|---|
| `LLM_BASE_URL` | Provider base URL. Empty means the demo embedder. |
| `LLM_API_KEY` | Provider key. Prefer entering keys in the browser. |
| `EMBEDDING_MODEL`, `EMBEDDING_DIM` | Embedding model and vector size (default `768`). |
| `LLM_CHAT_MODEL` | Chat model, to use this account in Ask. |
| `EMBEDDING_SEND_DIMENSIONS` | `false` if the provider rejects the `dimensions` field. |
| `VALIDATE_BACKLOG_PER_MINUTE` | How many waiting requests the background pass validates per minute (default `10`, `0` = off, at most 60). Settings can override it at runtime. |
| `VALIDATE_MAX_ATTEMPTS` | How many times in all a failed validation is attempted automatically (default `3`, `0` = never retry, at most 10). Settings can override it at runtime. |
**The environment account is the default.** With `LLM_BASE_URL` and `LLM_CHAT_MODEL` set, nothing has to be chosen in Settings: Request validation, Ask and search embeddings use it ("Using the environment account: default"). A choice saved in Settings always wins, including **None** (no AI validation); picking **Default** forgets the saved choice. The dropdown shows what is really saved, and Save confirms on the page. If a stored account has the same base URL and chat model as the environment one, Settings notes "Same endpoint as the environment account" (nothing is deleted for you).
**Catching up.** Requests that arrived while no account was usable are marked "validation skipped". As soon as an account is available, a background pass (once a minute, `VALIDATE_BACKLOG_PER_MINUTE` requests at most, oldest first, recorded in the history like any validation) validates them. It does nothing while there is no account, or while a stored account's key is locked, so it never records a failure for a problem a person can fix. Settings shows how many requests are waiting, lets you change the rate, and keeps **Validate waiting requests** for doing it right now.
**Retrying failures.** A validation that ended in "failed" (provider error, revoked key, upstream 402/502, an answer that was not a valid verdict) is retried by the same background pass after 5, then 15, then 60 minutes, at most `VALIDATE_MAX_ATTEMPTS` attempts in all (the count is kept per request in the database, and every attempt is a line in the request history saying whether another try is coming). A failure that is the provider refusing that very request (HTTP 400, 413 or 422) is not retried. Requests that were already stuck in "failed" before this existed count as one failed attempt, so they are picked up once the cause is fixed. Settings shows how many requests are waiting, failed and will be retried, and failed and gave up, and lets you change the number of attempts. **Validate waiting requests** and **Run again** include failed requests at once and start their attempt count over; editing a request after a question does too.
From a terminal, `trustdesk reembed` re-embeds everything after you change these.
If the database was created with another size than `EMBEDDING_DIM` now says, startup no longer fails: with up to 5000 chunks it re-embeds by itself (atomic; if the provider is down nothing changes and one warning is logged); with more, or for a size above 2000, it logs a warning and starts anyway. Until re-embedded, search uses full-text only, uploads answer 503, and `GET /api/embeddings/status` and `/api/stats` report `needs_reembedding` (the UI shows a banner). Run `trustdesk reembed`, or activate the embedding account again in Settings, to fix it.
## Single sign-on and roles (the ecosystem identity provider)
TrustDesk can run **standalone** (the first-run setup code and an admin password, as above) or **federated** with the ecosystem's
[identity provider](https://github.com/elchinoo/identity). Register TrustDesk there as a `web` app with client id
`trustdesk`, redirect URI `https://<trustdesk>/auth/callback`, and these roles, then set:
| Variable | Meaning |
|---|---|
| `TRUSTDESK_OIDC_ISSUER` | The identity provider's URL. Federation is off when unset. |
| `TRUSTDESK_OIDC_CLIENT_ID` | Default `trustdesk`. |
| `TRUSTDESK_OIDC_CLIENT_SECRET[_FILE]` | The app's secret from the provider. |
| `TRUSTDESK_PUBLIC_URL` | The URL TrustDesk is reached at (used to build the callback). |
| `TRUSTDESK_LOCAL_LOGIN` | `false` removes the admin-password login when SSO is on (default keeps it as a break-glass). |
| `TRUSTDESK_ENCRYPTION_KEY[_FILE]` | With SSO only (no admin password) this lets TrustDesk create and unlock its vault for stored provider keys. |
| Role | May |
|---|---|
| `admin` | everything, including documents (upload, delete), provider accounts, tokens, validation settings and demo data |
| `approver` | read and search, **ask** (the Ask tab, with a list of account names only), submit requests, **decide requests**, run validation again, open reports. Cannot change documents, accounts or settings |
| `viewer` | read and search, see requests, their AI advice and reports; cannot decide or ask |
Documents can be uploaded and deleted only with the `kb:write` permission, which only `admin` holds. Asking spends provider credit, so it is its own permission (`ask`); approvers have it and get a narrow, read-only list of the accounts that can chat (`GET /api/ask/accounts`: name and model, no URL or key details). The full accounts listing (`/api/accounts`) stays admin-only.
Services use `client_credentials` instead of a hand-copied token: scope `trustdesk.requests.write` makes a service a client
application (create employees and requests, no AI advice, no decisions), `trustdesk.kb.read` lets it search, `trustdesk.metrics`
lets it scrape. Local API tokens keep working beside them. Decisions are recorded under the approver's name.
## Compliance workflow
1. **Load the rules.** Under **Documents**, upload your policies (Markdown, text or PDF) and tag them (HR, travel, expense, governance, security). **Load demo policies** adds a fictional "Acme" pack covering vacation, sick and parental leave, remote work, overtime, travel, expenses, customer entertainment and who approves what.
2. **Connect an AI account.** Add an account with a chat model under **Settings > Provider accounts**, then choose it under **Request validation**. Without one, requests still arrive but without advice.
3. **Requests arrive** from your applications through the [client API](API.md) or by hand (**New request**). TrustDesk checks the fields, stores the request and starts the pre-validation in the background.
4. **Pre-validation** (a few seconds). The system computes the facts a model gets wrong when it guesses: working days, notice given, overlapping leave, leave already taken this year, expenses already approved, possible duplicates, missing receipts. It retrieves the policy passages that relate to the request type and the request text, and asks the model for one structured verdict. TrustDesk then checks that verdict before anyone sees it: citations must point at real passages, each quote is matched word for word against the passage and marked verified or not, a "yes" or "no" with no citable basis becomes "needs information", and an approval is not suggested while an automatic check fails (for example an overlap with approved leave).
5. **The review window** shows the request, the employee, the recommendation, the reasons, the foundation (document, heading, version, quote, full passage), the automatic checks and the history. You **approve**, **reject**, or **ask for information** (which sends the request back to the employee). Rejecting or asking needs a note the employee will see. Approving against a "reject" recommendation, or rejecting against an "approve", needs a written justification and is listed under **Reports > Decisions against AI advice**.
6. **Reports** cover volumes and outcomes, compliance by request type, anything out of compliance (including requests approved anyway), expenses by department, approved leave, open requests by age and upcoming absences. Each can be downloaded as CSV.
What goes to the AI provider: the request text and details, the system-computed facts (department, job title, level, country, tenure, dates, amounts) and the retrieved policy passages. **Not** names, e-mail addresses or employee numbers. Free text written by an employee can still contain personal data, so use a provider you are allowed to send it to.
Limits worth knowing: the AI only advises and can be wrong, so read the quoted passages. Working days exclude weekends but not public holidays. There is one approver identity (the signed-in admin); there are no per-employee sign-ins, e-mail notifications or receipt uploads, and validation is only as good as the documents you load. Validation quality has been exercised with a scripted stand-in model in tests and for the UI, not benchmarked against real providers.
## Using the web app
- **Requests:** the queue (open by default) with counts, filters and the review window described above. **New request** adds one on behalf of an employee.
- **Employees:** add, edit, deactivate; **Requests** jumps to that person's requests.
- **Reports:** pick a report and dates, run it, print it or download the CSV.
- **Search:** ask in plain language. Each result shows its document, heading, version and how it ranked in the vector, full-text and typo-tolerant (`pg_trgm`) searches, so a misspelt query like "vaction polcy" still finds the vacation policy. "Copy citation" copies a ready-to-paste reference.
- **Ask:** choose an account and ask a question. You get an answer with `[1]`-style citations that jump to the numbered source passages. Treat it as a draft: check the sources.
- **Documents:** drag files in (Markdown, text, PDF; 10 MB max each), choosing the type first (HR, travel, expense, governance, security or general). Re-uploading a changed file replaces the old version. **Load demo policies** adds a fictional policy pack. **View** shows how a document was split into chunks.
- **Settings:** provider accounts, which account provides embeddings, your admin password and API tokens.
- **Database:** (admin) slow statements, tuner recommendations and index hints from EDB's extensions, where the database has them.
- **Connect:** pick your AI client and copy a ready-made configuration with this server's URL filled in.
Scripting uploads needs an API token with **full** access (Settings > API tokens):
```bash
for f in src/trustdesk/seed/acme/*.md; do
curl -H "Authorization: Bearer $TRUSTDESK_TOKEN" -F file=@"$f" https://<your-host>/api/documents
done
```
## Using the MCP server
TrustDesk serves [MCP](https://modelcontextprotocol.io) over streamable HTTP at `https://<your-host>/mcp`, authenticated with `Authorization: Bearer <token>`. Create a **read-only** token under **Settings > API tokens**. Browser sessions are not accepted on `/mcp`. Tools:
| Tool | Purpose |
|---|---|
| `search_knowledge(query, limit?, doc_type?)` | Hybrid search; returns passages with document, version and heading to cite |
| `get_policy_passage(chunk_id)` | Fetch one passage by id |
| `list_documents()` | List what is in the knowledge base |
Set the token in the environment where the client runs, then add the server to your client. The same snippets, with your URL filled in, are on the app's **Connect** tab. Replace `<your-host>` below; for a local server use `http://localhost:8000/mcp`.
```bash
export TRUSTDESK_TOKEN=<your token>
```
<details>
<summary><b>Claude Code</b></summary>
Run in a terminal:
```bash
claude mcp add --transport http trustdesk https://<your-host>/mcp \
--header "Authorization: Bearer $TRUSTDESK_TOKEN"
```
</details>
<details>
<summary><b>VS Code (GitHub Copilot)</b></summary>
Save as .vscode/mcp.json in your workspace (VS Code asks for the key once and stores it securely):
```json
{
"servers": {
"trustdesk": {
"type": "http",
"url": "https://<your-host>/mcp",
"headers": {
"Authorization": "Bearer ${input:trustdesk-key}"
}
}
},
"inputs": [
{
"type": "promptString",
"id": "trustdesk-key",
"description": "TrustDesk API token",
"password": true
}
]
}
```
</details>
<details>
<summary><b>OpenCode</b></summary>
Add to opencode.json (project) or ~/.config/opencode/opencode.json:
```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"trustdesk": {
"type": "remote",
"url": "https://<your-host>/mcp",
"enabled": true,
"headers": {
"Authorization": "Bearer {env:TRUSTDESK_TOKEN}"
}
}
}
}
```
</details>
<details>
<summary><b>Codex CLI</b></summary>
Add to ~/.codex/config.toml:
```toml
[mcp_servers.trustdesk]
url = "https://<your-host>/mcp"
bearer_token_env_var = "TRUSTDESK_TOKEN"
```
</details>
<details>
<summary><b>Cursor</b></summary>
Add to ~/.cursor/mcp.json (or .cursor/mcp.json in a project):
```json
{
"mcpServers": {
"trustdesk": {
"url": "https://<your-host>/mcp",
"headers": {
"Authorization": "Bearer ${env:TRUSTDESK_TOKEN}"
}
}
}
}
```
</details>
<details>
<summary><b>Windsurf</b></summary>
Add to ~/.codeium/windsurf/mcp_config.json:
```json
{
"mcpServers": {
"trustdesk": {
"serverUrl": "https://<your-host>/mcp",
"headers": {
"Authorization": "Bearer ${env:TRUSTDESK_TOKEN}"
}
}
}
}
```
</details>
<details>
<summary><b>Gemini CLI</b></summary>
Add to ~/.gemini/settings.json:
```json
{
"mcpServers": {
"trustdesk": {
"httpUrl": "https://<your-host>/mcp",
"headers": {
"Authorization": "Bearer $TRUSTDESK_TOKEN"
}
}
}
}
```
</details>
<details>
<summary><b>Cline</b></summary>
Add to cline_mcp_settings.json (Cline: MCP Servers, then Configure):
```json
{
"mcpServers": {
"trustdesk": {
"type": "streamableHttp",
"url": "https://<your-host>/mcp",
"headers": {
"Authorization": "Bearer <your token>"
}
}
}
}
```
</details>
<details>
<summary><b>Zed</b></summary>
Add to Zed settings.json:
```json
{
"context_servers": {
"trustdesk": {
"url": "https://<your-host>/mcp",
"headers": {
"Authorization": "Bearer <your token>"
}
}
}
}
```
</details>
<details>
<summary><b>Claude Desktop</b></summary>
Claude Desktop needs a local bridge for bearer-key servers. Add to claude_desktop_config.json (requires Node.js):
```json
{
"mcpServers": {
"trustdesk": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://<your-host>/mcp",
"--header",
"Authorization:${AUTH}"
],
"env": {
"AUTH": "Bearer <your token>"
}
}
}
}
```
</details>
Then ask your assistant something like: *"Using trustdesk, how long do we retain customer data after termination?"*
Notes:
- The server authenticates with a bearer token, not OAuth, so web-based connector UIs that require OAuth (such as claude.ai custom connectors) are not supported. Use a client above.
- Client configuration formats change between releases. If a snippet is rejected, check that client's MCP documentation for the current field names (`url` vs `serverUrl` vs `httpUrl`, how headers are declared, and whether it expands environment variables).
- If you use an external embedding provider and the server has restarted, searches return "locked" until you unlock the vault in the web app (or the operator configured a platform secret).
- Treat search results as untrusted text: they come from your documents and may contain instructions. Don't give an assistant that reads them more authority than you would give the document authors.
## AIDB mode (optional: EDB AI Accelerator inside the database)
With `TRUSTDESK_RETRIEVAL_BACKEND=aidb` the database does the AI plumbing that the app does itself otherwise. The default is `app`, which is plain Postgres and unchanged; every test of that path still runs and passes. `aidb` is opt-in and **falls back to the `app` path, loudly, whenever it cannot be used**. Section "AIDB mode" of [SECURITY.md](SECURITY.md) has the security model.
> [!NOTE]
> **What was verified, and what was not.** Everything below ran against **AIDB 7.9.0 in a Docker image** (`aidb-postgres:17`, built from the ecosystem repository) with the app connecting as an unprivileged role, and the tests are in `tests/test_aidb_integration.py` (marker `aidb`; `make test-aidb`). **No real chat model was run**: the agent's model is a scripted OpenAI-compatible server, so the tests prove the plumbing, the confinement and the checks, not how well a real model follows AIDB's simulated tool-calling format. It has **not** been run on an AgentPG-hosted instance (where the login role does not hold the AIDB grants today, which this mode reports as `unavailable` and serves from the `app` path). Agent **memory** (an AIDB preview feature) is not used. The agent framework and the governance tables are what AIDB 7.9 ships; treat them as moving.
### Before and after
| | `app` (default) | `aidb` |
|---|---|---|
| Embedding the policy text | `ingest.py` calls the embedding provider and stores `kb_chunk.embedding` (`embeddings.py`, `embedcfg.py`, vector size fixed in the table, `reembed`, startup reconcile, `reembed_job.py`) | A **Live AIDB pipeline** (`td_policy`) over `kb_chunk.embed_text` embeds new and changed rows by itself, with AIDB's local `bge-small-en-v1.5-f16` (384 dimensions, no key). A chunk inserted by the app was retrievable within seconds, with no app code. |
| Retrieval | The app's SQL: vector, full text and trigram, fused with RRF (`search.py`) | `apgkit.retrieval.select` picks the AIDB knowledge base: its vector search fused with full text, with the **filters in the same query**: organisation, document type, department, and effective date (applied after retrieval, see limits). Same result shape, so Ask, pre-validation, the REST API and MCP are unchanged. The app's own quote check of every citation stays as the second check. |
| AI pre-validation | The app builds the prompt and calls the gateway or a provider account (`validator.py`, `ask.py`, `accounts.py`, `llm_http.py`) | An **AIDB agent** `td_prevalidator` with two **custom SQL tools** (`td_employee_balance`: leave days and spend already approved or open; `td_travel_policy_lookup`) and **structured output** (the verdict's fields). The app still builds the prompt (the passages, the system facts, the fenced request), then verifies the agent's answer exactly as it verifies a gateway answer: every citation must point at a real passage, every quote is matched word for word, an approval without a citable basis is downgraded. |
| What the AI may read | Application checks ("the model gets no tools") | A **purpose**: the agent's tools run as the NOLOGIN Postgres role `aidb_p_td_prevalidation`, which can `SELECT` two views (`td_policy_v`, `td_employee_balance_v`) and nothing else: no write, no other table, no name, e-mail or employee number. Postgres enforces it. |
| Audit of what it tried | Request history and the validation row | Plus AIDB's decision records (`aidb_otel.traces`): every allowed and every **denied** tool statement, listed in the **Governance** tab and `GET /api/aidb/governance`. |
| Tamper evidence | Row MACs on accounts, settings, sessions, tokens | The same, extended to the AIDB definitions (pipeline, model, tools, purpose and its grants, agent): sealed with the vault, verified at start-up, every minute, and right before each agent call; a mismatch turns AIDB mode off and counts in `trustdesk_tampered_rows_total{table="aidb_agent"}` and so on. |
The `app` code is **not deleted**: it is the fallback and the other half of every comparison, so both paths stay testable. In `aidb` mode the app still embeds at ingest (the `kb_chunk.embedding` column is required by the `app` path and by the fallback), so the saving is at run time and in what you have to operate once you stop using the `app` path: the vector index, the re-embed job, the provider account for embeddings, and the retrieval SQL. Measured on this repository: the AIDB layer is `aidbmode.py` (about 550 lines with its operator setup), `aidbschema.py` (70) and small hooks, against `search.py`, `embeddings.py`, `embedcfg.py`, `reembed_job.py` and the re-embed and reconcile code of `migrate.py` (about 450 lines) that the `aidb` path no longer needs.
### Turn it on
1. The database needs the AIDB extension and a role for the app that is a member of `aidb_users` (the setup command grants it). Creating roles, purposes and models needs more than the app's own role may have, so it is an **operator command** with a privileged connection that is asked for on a prompt or read from `TRUSTDESK_AIDB_ADMIN_DSN` (or `_FILE`), used for that command only, never stored and never printed:
```bash
export LLM_BASE_URL=https://gateway.example/v1 LLM_CHAT_MODEL=chat-default LLM_API_KEY=... # the gateway AIDB's agent model calls (https)
trustdesk aidb-setup # asks for the admin password (opens the vault) and the privileged connection string
```
It is idempotent (a repeat prints `unchanged` for everything). Without `LLM_BASE_URL` and `LLM_CHAT_MODEL` it creates the pipeline, tools and purpose and says the agent was skipped. `--runtime-role` names the role the app runs as (default: the user of `DATABASE_URL`); `--reseal` accepts AIDB objects that no longer match their seal, after you inspected them; `--skip-model-check` registers the model without AIDB calling the endpoint first.
2. Set `TRUSTDESK_RETRIEVAL_BACKEND=aidb` and restart. `trustdesk aidb-status` prints what is in use and why not, pipeline lag, the seals and the recent decisions; `/readyz` says `{"aidb": {"requested": "aidb", "retrieval": true, "agent": true}}` (a one-word `problem` when not, never a failure: it stays `ready`).
### When it falls back to the app path (all of these were tested)
AIDB not installed or not granted to the app's role; the pipeline or agent not set up; the vault locked (the seals cannot be checked, so AIDB mode waits for it); any seal that does not match; an AIDB search that raises (that call only, then the next call looks again); an agent that errors, times out, is refused or answers something that is not a verdict (the gateway path answers, or the request is `skipped` as before when no account exists). Each is logged as `AIDB MODE FALLBACK` or the kit's `RETRIEVAL BACKEND FALLBACK` with the reason.
### Honest limits
- **Not the same search.** AIDB mode embeds with its own local model (384 dimensions) instead of the configured embedding account, has no trigram typo tolerance (`pg_trgm` stays in the `app` path) and uses the plain `english` text-search configuration (no accent folding). Scores are comparable within one backend only. For obvious queries both paths return the same top passage (checked on the demo pack, with its offline demo embedder); for others they differ.
- **`department` and `as_of` are AIDB-mode filters.** A document gets them from its `meta` (upload with `?department=finance&effective_date=2027-01-01`; without them a chunk is for `all` departments and effective from its upload day). The kit's filters are equality filters, so `as_of` drops passages not yet in force **after** retrieval. The `app` path ignores both. Nothing in the app sets them by default.
- **Lag.** A Live pipeline embeds after the row commits; a request validated in the first seconds after an upload may not see the new passage. `trustdesk reembed` rewrites every chunk row; how the Live pipeline reacts to that was not measured.
- **Detection, not prevention.** Seals notice a database write that changes an AIDB definition or the purpose role's grants; a superuser can still change them, an older authentic definition can be replayed from a backup, and the check runs at start-up, every minute and before each agent call. Not covered: the contents of `kb_chunk`, `td_governance_decisions_v`, who holds `USAGE` on the gateway credential, AIDB's own code and server settings. Any member of `aidb_users` can create AIDB agents; one named `td_*` that nobody sealed is reported, one with another name is not ours to judge. AIDB adds a built-in `sleep` tool to every agent.
- **The tools are as broad as their views.** `td_employee_balance` takes a request id and returns aggregates (days, amounts, department, level, country) for it; the agent could ask for any request's. No personal data is in the view.
- **Backlog.** The background catch-up pass runs only while a provider account is usable; with only the agent configured, new requests are validated at once but a request that failed is not retried by the pass.
- **Needs https for a real gateway.** AIDB refuses plain `http` for model calls except to private addresses with the server option `aidb.allow_insecure_egress=on`, which is test-only.
## Monitoring TrustDesk (metrics)
TrustDesk exposes Prometheus metrics at `/metrics` with the same names PagerLens understands (`http_requests_total`, `http_request_duration_seconds`, `db_up`, `app_info`, `trustdesk_documents`, `process_*`) plus `trustdesk_requests{status}`, `trustdesk_oldest_open_request_seconds`, `trustdesk_validations_total{state,recommendation}` and `trustdesk_validation_duration_seconds`. Create a **Metrics only** token under **Settings > API tokens**: it can scrape `/metrics` and nothing else (it is refused on `/mcp` and on the REST API). Then point [`pagerlens-agent`](https://github.com/elchinoo/PagerLens) at it to get health probes, alerts and incident analysis; see that project's README.
## Configuration
| Variable | Default | Meaning |
|---|---|---|
| `DATABASE_URL` / `DB_EXTERNAL_DSN` / `PSQL_CONNECTION_STRING` | `PG*` variables | Postgres URI; first one set wins. If none is set, libpq uses `PGHOST`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`. The `vector` and `pg_trgm` extensions must exist (or the role must be allowed to create them). |
| `HOST` / `PORT` | `0.0.0.0` / `8000` | Bind address (`TRUSTDESK_HOST` overrides `HOST`). The app uses the same value to decide whether `TRUSTDESK_ALLOW_ANONYMOUS` may apply. |
| `TRUSTDESK_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Proxy addresses (comma-separated, CIDRs allowed) whose `X-Forwarded-*` headers uvicorn trusts. Never `*`. |
| `TRUSTDESK_TRUSTED_HOPS` | `1` | How many reverse proxies sit in front of the app; the client address used for rate limiting is that entry of `X-Forwarded-For` counted from the right. `0` uses the socket address. |
| `AUTO_SEED` | `false` | With `trustdesk start`, load the demo pack if the knowledge base is empty. The vault is opened from `TRUSTDESK_ENCRYPTION_KEY` first (the seed embeds through the configured account); if the seed still has to be skipped (vault locked, provider down) it is retried in the background once the app is serving, not at the next restart. |
| `TRUSTDESK_ENCRYPTION_KEY` | none | Optional platform-managed secret that lets the vault unlock itself after a restart (file alternative: `/etc/trustdesk/encryption_key`). |
| `TRUSTDESK_ALLOW_PRIVATE_LLM_URLS` | `false` | Allow provider URLs on loopback/private networks, for a local model server. Link-local is always blocked. |
| `TRUSTDESK_API_KEY` | none | Optional static full-access credential for automation (file alternative: `/etc/trustdesk/api_key`). Must be at least 32 characters (for example `openssl rand -base64 36`); a shorter key is refused and an error is logged. A warning is logged at startup while one is enabled. Prefer API tokens. |
| `TRUSTDESK_ALLOW_ANONYMOUS` | `false` | `true` disables authentication entirely, but only when the bind address is loopback (`HOST=127.0.0.1`) or `TRUSTDESK_ALLOW_ANONYMOUS_PUBLIC=1` is also set. Throwaway local demos only. |
| `LLM_BASE_URL`, `LLM_API_KEY`, `EMBEDDING_MODEL`, `EMBEDDING_DIM`, `LLM_CHAT_MODEL`, `EMBEDDING_SEND_DIMENSIONS` | | Optional environment provider; see [Environment configuration](#environment-configuration-optional). |
| `TRUSTDESK_RETRIEVAL_BACKEND` | `app` | `app` (plain Postgres, the app's own search and the gateway) or `aidb` (see [AIDB mode](#aidb-mode-optional-edb-ai-accelerator-inside-the-database)). An unknown value counts as `app` and is logged. |
| `TRUSTDESK_AIDB_ADMIN_DSN[_FILE]` | none | Read by `trustdesk aidb-setup` only: the privileged connection string for that one command (otherwise it is asked on a prompt). Never put it in the app's environment, compose file or image. |
## Container image
`.github/workflows/build-image.yml` lints, tests, then publishes `ghcr.io/<owner>/<repo>` (`latest`, `sha-*`, version tags) from `main`, with a nightly rebuild for base-image updates.
The image runs `trustdesk start`: connect to the database (retrying while a scaled-to-zero database wakes), apply migrations, optionally seed, then serve. It runs as a non-root user and sets `SSL_CERT_FILE` so `sslmode=verify-full` works with the bundled libpq.
## Deploy on AgentPG
> [!IMPORTANT]
> **Free-tier sandboxes auto-pause when idle and cannot be kept awake.** `agentpg sandbox status` shows `Auto-pause: true`, and the AgentPG docs state it can't be disabled on the Free tier (plan changes were also unavailable during the private preview). A paused sandbox answers **503** and does not wake on a request. Before you rely on a deployment, read [Troubleshooting](#troubleshooting).
AgentPG sandboxes run any public image and can be linked to a Postgres instance, which injects the connection details (`PSQL_CONNECTION_STRING` and the `PG*` variables). TrustDesk reads those automatically. **No key files are needed.**
```bash
agentpg login
agentpg create --name <instance-name> --size small # or reuse an instance; note its id
agentpg extensions create vector <instance-id> # may need elevated rights, so enable them here
agentpg extensions create pg_trgm <instance-id>
agentpg sandbox create trustdesk \
--image ghcr.io/<owner>/trustdesk:latest \
--instance <instance-id> --port 8000
agentpg sandbox status <sandbox-id> # prints the public HTTPS URL
agentpg sandbox logs <sandbox-id> # contains the one-time setup code
```
Then open the URL, enter the **setup code from the logs**, choose an admin password, and click **Load demo policies** on the Documents tab. Add provider accounts under Settings.
Notes:
- `--port 8000` is required: without it the platform replaces the image's command with an idle process.
- The image must be **public** on GHCR (package settings > change visibility).
- Setup is protected by the code in the logs, which only someone who can read the sandbox logs has. Complete setup promptly after creating the sandbox: until then, anyone who finds the URL can try to guess the code (attempts are rate limited).
- `agentpg sandbox create` accepts `--env KEY=VALUE` (repeatable), e.g. `--env AUTO_SEED=true` to load the demo policies on first start. Keep secrets out of it: everything sensitive is configured in the browser, and values passed with `--env` are not a secret store.
- Free-tier sandboxes auto-pause when idle and do **not** wake on a request: a paused sandbox returns 503 until you run `agentpg sandbox resume`. After a resume, **unlock the provider keys** with your admin password. See [Troubleshooting](#troubleshooting).
- Optional: the documented way to give a sandbox a file is `--file`. If you want unattended unlock after restarts, mount a platform-managed secret at `/etc/trustdesk/encryption_key`, created outside this repository, and never commit it. The trade-offs are described in [How secrets are handled](#how-secrets-are-handled).
## Troubleshooting
### I get a 503 on every page
**Cause:** the sandbox is paused, not broken. Auto-pause is on by default and can't be turned off on the Free tier. While paused, the platform's proxy returns an empty 503 for *every* path, including `/healthz` and the static files, before any request reaches TrustDesk. Nothing expires on the app side.
**How to confirm:**
```bash
agentpg sandbox status <sandbox-id> # look for "Status: paused"
curl -i https://<your-host>/healthz # a 503 with no security headers (no content-security-policy) came from the platform, not the app
```
**Fix:** `agentpg sandbox resume <sandbox-id>`, reload the page and sign in. The web app explains a 502/503/504 itself and offers a Retry button.
### The page says "Provider keys are locked"
The server restarted (a resume is a restart), and the stored provider keys can only be unwrapped with your admin password, which is not stored anywhere. Enter the password in the banner. Until then, external embeddings and Ask are paused; searching with the demo embedder still works. To avoid this on unattended restarts, see the platform secret under [How secrets are handled](#how-secrets-are-handled).
### I forgot the admin password
Run `trustdesk reset-admin` where the server can reach the database (on AgentPG: `agentpg sandbox console <sandbox-id>`). This clears the password, the vault and every stored provider key, then restarts the first-run setup (a new setup code is printed to the log on the next start). Re-add your provider accounts' keys afterwards.
### What survives a pause
Your documents, search index, accounts (without their keys being usable until unlocked), API tokens and sessions live in the Postgres instance and are unaffected. Only the app process restarts. Pausing is not data loss.
### Ways to avoid surprises
- Resume the sandbox shortly before a demo or a test session and check `/readyz` first.
- Ask your platform provider whether the idle timeout can be lengthened or auto-pause turned off for your account.
- If you need an always-on service, run the container on infrastructure without idle auto-pause, such as a small VM or a platform tier that doesn't pause.
### `/readyz` returns 503 with `waking-database`
This one comes from TrustDesk itself and is different from the platform 503 above: the app is running but cannot reach the database yet, for example while a scaled-to-zero database wakes. Wait a few seconds and retry; the app retries its connection with backoff.
### Search says "re-embedding is in progress"
You switched embedding providers. Search and uploads pause while every passage is re-embedded (progress is shown in Settings). If the provider fails midway, nothing is changed.
## Tests
```bash
make test # unit tests, no database needed
make test-db # integration tests against a throwaway database on the compose Postgres
# (needs POSTGRES_PASSWORD exported: set -a; . ./.env; set +a)
make test-aidb # the whole suite against a throwaway Docker Postgres with AIDB (image aidb-postgres:17), including the `aidb` tests
```
The tests marked `aidb` (`tests/test_aidb_integration.py`) skip unless `AIDB_TEST_URL` (a superuser DSN of a Postgres with AIDB) is set, which is why CI runs everything except them: there is no AIDB image or EDB token in CI. The switch, every fallback, the plain-Postgres objects and the CLI on a database without AIDB are in `tests/test_aidb_switch.py` and run everywhere.
## Security
Read [SECURITY.md](SECURITY.md) before exposing this to a network. In short: no secret files, a salted-hash admin password, hashed revocable tokens, encrypted provider keys that stay locked after a restart, rate-limited sign-in, strict CSP, SSRF-guarded provider URLs, bounded uploads, non-root container. The knowledge base is single-tenant: anyone with full access can read and change every document.
## Design notes
- **Just Postgres:** SQL lives in `src/trustdesk/migrations/` and `src/trustdesk/search.py`.
- **Cold-start tolerant:** `db.connect` retries with backoff and `/readyz` reports `waking-database` rather than failing hard.
- **Read-only MCP tools:** the MCP server only searches and lists; retrieved text is data, never instructions.
- **Limits:** 10 MB uploads, 500-page PDFs, text extraction only (no OCR).
## License
Apache License 2.0. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues