Skip to main content
Glama
TrustDesk MCP Server

TrustDesk MCP Server

by elchinoo

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 is the complete guide (ecosystem fit, architecture, install, configuration, operations, limitations); docs/VIDEO-SCRIPT.md is a 6-minute tutorial video script; API.md and SECURITY.md cover the client API and the security model. The section 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). 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 and 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.

  • Client API (/api/v1, see 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.

  • 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.

Related MCP server: KSoR

Quick start

Requires Docker and uv (or any Python 3.11+ environment).

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).

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 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.

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 hasnot 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. 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 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):

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 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.

export TRUSTDESK_TOKEN=<your token>

Run in a terminal:

claude mcp add --transport http trustdesk https://<your-host>/mcp \
  --header "Authorization: Bearer $TRUSTDESK_TOKEN"

Save as .vscode/mcp.json in your workspace (VS Code asks for the key once and stores it securely):

{
  "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
    }
  ]
}

Add to opencode.json (project) or ~/.config/opencode/opencode.json:

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "trustdesk": {
      "type": "remote",
      "url": "https://<your-host>/mcp",
      "enabled": true,
      "headers": {
        "Authorization": "Bearer {env:TRUSTDESK_TOKEN}"
      }
    }
  }
}

Add to ~/.codex/config.toml:

[mcp_servers.trustdesk]
url = "https://<your-host>/mcp"
bearer_token_env_var = "TRUSTDESK_TOKEN"

Add to ~/.cursor/mcp.json (or .cursor/mcp.json in a project):

{
  "mcpServers": {
    "trustdesk": {
      "url": "https://<your-host>/mcp",
      "headers": {
        "Authorization": "Bearer ${env:TRUSTDESK_TOKEN}"
      }
    }
  }
}

Add to ~/.codeium/windsurf/mcp_config.json:

{
  "mcpServers": {
    "trustdesk": {
      "serverUrl": "https://<your-host>/mcp",
      "headers": {
        "Authorization": "Bearer ${env:TRUSTDESK_TOKEN}"
      }
    }
  }
}

Add to ~/.gemini/settings.json:

{
  "mcpServers": {
    "trustdesk": {
      "httpUrl": "https://<your-host>/mcp",
      "headers": {
        "Authorization": "Bearer $TRUSTDESK_TOKEN"
      }
    }
  }
}

Add to cline_mcp_settings.json (Cline: MCP Servers, then Configure):

{
  "mcpServers": {
    "trustdesk": {
      "type": "streamableHttp",
      "url": "https://<your-host>/mcp",
      "headers": {
        "Authorization": "Bearer <your token>"
      }
    }
  }
}

Add to Zed settings.json:

{
  "context_servers": {
    "trustdesk": {
      "url": "https://<your-host>/mcp",
      "headers": {
        "Authorization": "Bearer <your token>"
      }
    }
  }
}

Claude Desktop needs a local bridge for bearer-key servers. Add to claude_desktop_config.json (requires Node.js):

{
  "mcpServers": {
    "trustdesk": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://<your-host>/mcp",
        "--header",
        "Authorization:${AUTH}"
      ],
      "env": {
        "AUTH": "Bearer <your token>"
      }
    }
  }
}

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 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:

    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 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.

TRUSTDESK_RETRIEVAL_BACKEND

app

app (plain Postgres, the app's own search and the gateway) or aidb (see AIDB mode). 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.

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.

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.

  • 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.

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:

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.

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

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 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    A Streamable HTTP MCP server that packages QMD search and document retrieval with bounded index-maintenance operations, without exposing arbitrary shell execution.
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Serves a governed knowledge system of record over MCP, letting agents answer questions with citations drawn only from approved documents and explicitly decline when the record does not cover the question.
    73 npm
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Exposes a markdown knowledge base to MCP clients so they can search pages, fetch individual documents, and ask grounded questions that come back with numbered citations or a refusal. Retrieval is graded before answering, with a single query rewrite before giving up when nothing relevant is found.
    1
    MIT