The problem
Once a company has an AI assistant, someone asks whether it can answer questions over production data. I do data consulting for companies that keep everything on-premises, and I kept hearing that question.
The usual answer is a read-only database account plus a text-to-SQL prompt. That leaves three things unsolved:
- The account sees every row. Other tenants, other regions and personal data are all in reach.
- The model writes joins from column names it has to guess at.
- Nobody can say afterwards who read what.
I built Truejoin to close those three, on your own servers, with Oracle and SQL Server included.
What Truejoin is
Truejoin is an open-source (Apache 2.0) data gateway. It connects to PostgreSQL, MySQL/MariaDB, SQL Server and Oracle, and serves them three ways from one Python process and one config file:
- MCP: 18 tools for agents, over streamable HTTP or stdio
- REST: an API per table with a typed OpenAPI schema
- Analytical SQL: in-memory OLAP on DuckDB
Every query, whoever wrote it, runs under the caller's row rules and column masks and leaves one audit event. It is not a warehouse, an ETL tool or a BI dashboard. It reads the databases you already have, live.
Rules are applied by rewriting the query
Filtering the result is too late. An aggregate over rows the caller may not see has already leaked, and so has a masked column read through an alias, a join or a subquery.
So Truejoin rewrites the query before it runs. Each table the query names is replaced by a subquery that carries that caller's row conditions and masks. Aliases, joins, CTEs and aggregates then all read the restricted view, because there is nothing else to read.
The rules are a few lines of config (trimmed here; the full nesting is in config/reference.yaml):
row_filters: orders: [{column: tenant_id, value: "${tenant}"}] row_filter_exempt_roles: [dpo] masking: enabled: true rules: {email: partial, national_id: hash} Three things follow from doing it this way:
- It fails closed. Anything that cannot be wrapped, a table function for example, refuses the query.
- Model-written SQL gets no special path. It goes through the same rewrite as a person's.
- The audit event never holds the SQL. It records who asked, which tables, how many rows, and which columns were masked.
A model drafts the catalog, a person approves it
A model reads the schema and drafts table and column descriptions, PII labels, relationships and business terms (truejoin catalog analyze). Sample values are masked before they reach it, and with Ollama nothing leaves your network.
A person then reviews, approves and certifies the draft. Nothing unapproved is used.
The approved catalog does two jobs. Masks key off its semantic types, and questions are read against its business terms.
Questions in words, without a model at query time
answer_question first reads a question against the approved vocabulary, with no model involved. It returns the request it understood and the SQL it compiled, so you can check what was asked before you trust the answer. Joins follow declared foreign keys, so a request cannot write a wrong join.
Only a question the vocabulary cannot read goes to a model, and that answer is marked as model-written.
I benchmarked the approaches on a banking-shaped schema with qwen2.5-coder:7b, graded by result set against a gold query. On the 16 held-out questions:
| Approach | Correct (of 16) | Hallucinated |
|---|---|---|
| Model writes SQL from the DDL | 8 | 31.2% |
| Model writes SQL from the catalog | 10 | 18.8% |
| Approved terms, no model | 12 | 0% |
Sixteen questions is a small set and the intervals are wide. The table in the repo shows 13 for the last row; one of those was right by luck and is now refused, so I count 12. The method and every caveat are in bench/README.md.
Where it came from: Warp, Fusion and Stargate
Truejoin merges three earlier projects of mine into one package and one process:
- Warp: database to governed REST, with the catalog
- Fusion: DuckDB OLAP and the agent tools
- Stargate: OpenAPI to MCP. It lives on as
truejoin mcp convert, which turns an OpenAPI or Postman description into an MCP server definition.
Their code moved over with its history. The query rewriter is the one component with no ancestor in the three, and it carries the privacy promise, so its tests were written first.
Try it
You need Python 3.12+ and uv.
git clone https://github.com/yasinyaman/truejoin.git cd truejoin uv sync --all-extras export TRUEJOIN_SECRET_KEY=$(uv run python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') uv run truejoin config validate -c config/database.yaml uv run truejoin users add admin --permission all -c config/database.yaml uv run truejoin serve -c config/database.yaml The console is at http://localhost:8000/ui. Any MCP client can connect over HTTP at http://<host>:8000/mcp with an API key in X-API-Key. For a desktop client such as Claude Desktop, run the tools over stdio:
{ "mcpServers": { "truejoin": { "command": "uv", "args": ["run", "--directory", "/path/to/truejoin", "truejoin", "serve", "--transport", "stdio", "-c", "config/database.yaml"] } } } Limits, and what I'd like to hear
- 1.0.0 is still in development. It installs from source and is not on PyPI yet.
- SQL Server and Oracle connect through ODBC.
- A restricted caller's analytical queries always run locally in DuckDB, and one that is not a flat SELECT loads whole tables for that caller.
- The benchmark is small, and it is one schema.
The feedback I want most is on the rewriter. Try to read around a row rule or a mask. If a query gets through, please report it privately through a GitHub security advisory rather than a public issue.
The code is at github.com/yasinyaman/truejoin and the docs are at yaman.dev/truejoin.


