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:
I built Truejoin to close those three, on your own servers, with Oracle and SQL Server included.
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:
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.
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:
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.
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.
Truejoin merges three earlier projects of mine into one package and one process:
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.
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"]
}
}
}
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.