realy-mcp
An MCP server that answers natural-language questions about your database by composing validated table operations, and returns typed rows plus a reviewable trace of every step.
You run it. In your environment. Against your database. On your model credentials. Nothing is sent anywhere you did not configure — see docs/THREAT_MODEL.md.
Install
pip install "realy-mcp[openai,postgres]"
Extras select what gets installed: providers (openai, anthropic, google,
groq) and the optional DuckDB backend (duckdb). PostgreSQL/psycopg is included by default;
postgres remains a compatibility extra. Pick only what you use — a
customer on Azure OpenAI never has an Anthropic SDK on disk.
Python 3.10–3.13. The install is a database driver and an SDK; there is no pandas, no numpy, no ML stack.
Configure
Copy realy.example.toml and edit it. The short version:
[connection]
id = "main"
url_env = "REALY_DB_URL" # env var NAME, not the URL
read_only = true
[[connection.tables]]
name = "claims"
source_table = "v_claims_scoped" # a view you control
description = "One row per submitted claim. `amount` is USD..."
[model]
provider = "openai"
model = "gpt-4o"
api_key_env = "REALY_MODEL_API_KEY"
base_url = "https://my-azure.openai.azure.com/v1" # optional
Two things to get right:
Secrets are named, not written. url_env and api_key_env hold the names
of environment variables. The config file itself contains no credentials, so it
can be committed and reviewed.
description is the highest-value field in the file. It is what the calling
agent reads to decide whether a question is answerable here, and what stops the
agent guessing at a column's meaning. Write it as if explaining the table to a
new analyst — including the traps ("paid_amount is NULL until settlement").
Check it before you wire it up:
export REALY_DB_URL='postgresql+psycopg://realy_ro:...@db.internal/warehouse'
export REALY_MODEL_API_KEY='...'
realy-mcp --config realy.toml --check
--check validates the file, connects, reflects every configured table, builds
the model client, and exits. Any problem is reported naming the variable or
table to fix.
Local execution and development
The Git repository root is table_reasoning; the Python package root is
realy-mcp. Run the following commands from table_reasoning/realy-mcp.
Install uv for uvx.
Use Python 3.10?3.13. Provider SDKs remain optional: plain uvx realy-mcp
launches the CLI; actual retrieval requires the extra for your configured model.
PowerShell:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -e .
pip install -e ".[dev,openai]"
Copy-Item realy.example.toml realy.toml
# Edit realy.toml to match your database, tables, and model.
$env:REALY_CONFIG = (Resolve-Path .\realy.toml).Path
$env:REALY_DB_URL = "postgresql+psycopg://realy_ro:password@localhost/warehouse"
$env:REALY_MODEL_API_KEY = "your-model-key"
realy-mcp --check
uvx --from . --with langchain-openai realy-mcp --check
python -m pytest -q
Windows CMD:
python -m venv .venv
.venv\Scripts\activate.bat
pip install -e .
pip install -e ".[dev,openai]"
copy realy.example.toml realy.toml
rem Edit realy.toml before checking it.
set "REALY_CONFIG=%CD%\realy.toml"
set "REALY_DB_URL=postgresql+psycopg://realy_ro:password@localhost/warehouse"
set "REALY_MODEL_API_KEY=your-model-key"
realy-mcp --check
uvx --from . --with langchain-openai realy-mcp --check
python -m pytest -q
Bash / Zsh (macOS and Linux):
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
pip install -e ".[dev,openai]"
cp realy.example.toml realy.toml
# Edit realy.toml before checking it.
export REALY_CONFIG="$PWD/realy.toml"
export REALY_DB_URL='postgresql+psycopg://realy_ro:password@localhost/warehouse'
export REALY_MODEL_API_KEY='your-model-key'
realy-mcp --check
uvx --from . --with langchain-openai realy-mcp --check
python -m pytest -q
The requested baseline uvx --from . realy-mcp --check validates the local
package but needs the provider SDK inside uvx's isolated environment. The
--with langchain-openai variant above supplies it; installing an extra in
.venv does not install it into uvx. For DuckDB, also pass --with duckdb-engine.
--check requires a real config, database and model credential; it reflects
tables and constructs the model client but does not send a model inference
request. It is not a credential-free package health check; use --help for that.
All check diagnostics go to stderr. Exit codes: 0 success, 2 invalid setup,
130 interruption. Paths use pathlib.Path, expand ~, and accept spaces when quoted.
Connect local IDEs with uvx
After publication, use uvx --from "realy-mcp[openai]" realy-mcp.
Before publication replace the --from value with an absolute package directory
and add --with langchain-openai before realy-mcp. Pin the release in shared
configurations, for example realy-mcp[openai]==0.1.0.
Cursor and Claude Desktop
Use this mcpServers block in Cursor's .cursor/mcp.json or Claude Desktop's
claude_desktop_config.json (Windows: %APPDATA%\Claude\; macOS:
~/Library/Application Support/Claude/).
{
"mcpServers": {
"realy": {
"command": "uvx",
"args": ["--from", "realy-mcp[openai]", "realy-mcp", "--transport", "stdio", "--config", "C:/Users/you/realy/realy.toml"],
"env": {
"REALY_DB_URL": "postgresql+psycopg://realy_ro:password@db.internal/warehouse",
"REALY_MODEL_API_KEY": "your-model-key"
}
}
}
}
Use /Users/you/realy/realy.toml on macOS or /home/you/realy/realy.toml
on Linux. JSON Windows paths can use / as above or escaped \\.
If a GUI cannot find uvx, set command to its absolute executable path
(uvx.exe on Windows), then restart the client. Keep real credentials out of
version-controlled client configuration.
Codex
In ~/.codex/config.toml (Windows: %USERPROFILE%\.codex\config.toml):
[mcp_servers.realy]
command = "uvx"
args = ["--from", "realy-mcp[openai]", "realy-mcp", "--transport", "stdio", "--config", "C:/Users/you/realy/realy.toml"]
env_vars = ["REALY_DB_URL", "REALY_MODEL_API_KEY"]
startup_timeout_sec = 60
Set those environment variables before launching Codex. See the official Codex MCP guide.
Claude Code
With the database and model variables exported in the launching shell:
claude mcp add --transport stdio realy -- uvx --from "realy-mcp[openai]" realy-mcp --config "/absolute/path/realy.toml"
On Windows replace the config path with C:/Users/you/realy/realy.toml.
See Claude Code MCP configuration and
Cursor MCP configuration.
Hosted SSE and Streamable HTTP
The same two tools and resource definitions in server.py serve every
transport. cli.py loads configuration; transports.py owns HTTP routing,
CORS, bearer verification and Uvicorn. Default transport is stdio unless
changed in [server] or by --transport.
[server]
transport = "sse"
host = "0.0.0.0"
port = 8080
public_host = "realy.example.com"
api_key_env = "REALY_API_KEY"
cors_origins = ["https://app.example.com"]
Add this section to your existing connection/model configuration. Set
REALY_API_KEY to a long random secret in your hosting provider's secret store.
When api_key_env is named, a missing or empty variable prevents startup.
Otherwise bearer auth is optional and enabled automatically when REALY_API_KEY
is present. It protects both the SSE GET and message POST, as well as HTTP.
realy-mcp --config realy.toml --transport sse --host 0.0.0.0 --port 8080 --public-host realy.example.com --api-key-env REALY_API_KEY --cors-origin https://app.example.com
CLI overrides environment overrides TOML defaults for host/port/public hostname.
Port environment precedence is REALY_PORT, then cloud provider PORT, then
TOML/default 8080. SSE works locally at http://localhost:8080/sse; configure
--public-host for the actual external hostname (no scheme or path). Host
validation allows loopback plus that hostname. * explicitly disables it.
CORS defaults to no allowed browser origins; repeat --cors-origin as needed.
CORS is not authentication, and server-to-server web clients need no CORS entry.
Browser clients must send the bearer header using an MCP client/fetch that
supports headers; native EventSource cannot attach an Authorization header.
| Mode | Client URL | Notes |
|---|---|---|
stdio |
Child process | No network listener |
sse |
https://realy.example.com/sse |
Legacy HTTP+SSE; POST endpoint /messages/ |
http with bearer |
https://realy.example.com/mcp |
Streamable HTTP; stateless protocol sessions |
http without bearer |
Printed /mcp/<token> URL |
Existing demo behavior is preserved |
--path-token-env REALY_PATH_TOKEN optionally adds a stable token to SSE URLs
(/sse/<token> and /messages/<token>/) or HTTP (/mcp/<token>).
Tokens may contain letters, digits, _ and -. Treat token URLs as secrets;
path tokens do not provide per-user identity. A bearer key plus a path token
requires both. Uvicorn access logging is disabled to avoid logging token URLs.
ChatGPT Web / Custom Agents
ChatGPT connects to a public HTTPS URL, not localhost or uvx. Deploy the
SSE service, then enable Developer mode and create a remote MCP app/plugin
using https://realy.example.com/sse (or the printed token URL). Select the app
in the conversation or in an agent surface that supports connected MCP tools.
The official developer-mode guide
documents SSE and streaming HTTP plus OAuth, No Authentication and Mixed
Authentication; availability and workspace permissions vary.
A static bearer key is not ChatGPT OAuth. For private data, put an
MCP-compatible OAuth resource/authorization gateway in front of this service,
with the upstream bearer key held by the gateway. This package does not
implement OAuth discovery, registration or per-user authorization. A Custom
Agent/SDK that supports explicit headers can instead connect to the SSE URL
with Authorization: Bearer <REALY_API_KEY> on both GET and POST.
For a disposable demo database only, omit api_key_env, leave REALY_API_KEY
unset, configure path_token_env, and choose No Authentication in ChatGPT.
Use the printed token URL; anyone possessing it can access the configured
scope. Do not select No Authentication against a bearer-protected endpoint:
that connection will return 401. GPT Actions use an OpenAPI interface; this
MCP URL is for MCP-capable clients, not an OpenAPI schema.
Docker and cloud providers
From the package root:
docker build -t realy-mcp .
docker run --rm -p 8080:8080 --mount type=bind,source=/absolute/path/realy.toml,target=/etc/realy/realy.toml,readonly -e REALY_DB_URL -e REALY_MODEL_API_KEY -e REALY_API_KEY -e REALY_PUBLIC_HOST realy-mcp
Use source=C:/Users/you/realy/realy.toml on Windows. Set the four env vars
before running; the command forwards them without placing secrets in the image.
The image runs as a non-root user, includes PostgreSQL and the OpenAI provider,
and starts SSE on 0.0.0.0:8080. Select another provider using
docker build --build-arg REALY_EXTRAS=anthropic -t realy-mcp ..
Only package code and metadata enter the build context; .env, local configs,
databases and tests are excluded.
For Render/Railway, set the service root/build context to realy-mcp, use its
Dockerfile, inject secrets and REALY_PUBLIC_HOST, and mount the TOML config
at /etc/realy/realy.toml (or set REALY_CONFIG to the mounted location).
The server honors the provider's PORT. On Fly.io build from this directory,
set internal_port = 8080, configure secrets and provide the config via a
mounted file or a deployment-specific image containing only non-secret TOML.
Use the provider's HTTPS termination and TCP health checks. Configure reverse
proxies to disable response buffering and permit long-lived SSE connections.
Run one worker and one instance per configured database scope. SSE sessions and traces are in process memory; reconnect after a restart, and do not route SSE GET and POST to different replicas. All clients use the same database role and shared trace store; this is not a multi-tenant authorization service. The PostgreSQL example supplies a seeded demo database. Older demo instructions use the optional HTTP token mode.
Build and publish to PyPI
python -m pip install -e ".[dev]"
python -m build
python -m twine check dist/*
python tests/check_distribution.py
The setuptools wheel contains only realy_mcp and distribution metadata.
The sdist includes examples, docs and tests, but excludes secrets and caches.
The console entry is realy-mcp = realy_mcp.cli:main; python -m realy_mcp
is equivalent. The dependency lower bound matches the MCP 2 API used here.
GitHub discovers ../.github/workflows/publish.yml
at the Git root, not the old nested realy-mcp/.github directory. It tests
Python 3.10 and 3.13 on Ubuntu, Windows and macOS, checks wheel/sdist metadata
and installed entry points, exercises uvx --from ., and builds Docker.
Before publishing, create the realy-mcp PyPI project (or a pending publisher),
configure a PyPI Trusted Publisher
for your repository, workflow publish.yml and GitHub environment pypi.
Create that GitHub environment with your desired release protection rules.
No PyPI API token secret is required.
Set project.version in pyproject.toml and __version__ in
src/realy_mcp/__init__.py, commit, then push a matching tag such as v0.1.0.
Only v* tags publish, after tests and image build pass; a mismatched version
fails the release. Ordinary branches, PRs and manual runs only validate.
Publishing is configured here but does not happen until you push a release tag
and complete the external publisher setup. Confirm PyPI name ownership and
choose a distribution license before your first public release.
What the agent sees
Two tools. That is the whole surface.
realy_describe_scope — what can be asked: the tables, your descriptions of
them, and their sizes. Curated, not a schema dump; full column lists sit behind
a resource so they do not land in the calling model's context by default.
realy_retrieve(request) — answers a request and returns:
{
"status": "ok",
"summary": "The 412 unpaid claims over $10,000, with the submitting provider.",
"columns": [
{"name": "claim_number", "type": "string", "source_table": "v_claims_scoped"},
{"name": "amount", "type": "decimal", "source_table": "v_claims_scoped"},
{"name": "provider_npi", "type": "string", "source_table": "v_providers"}
],
"rows": [["CLM-0001", "10450.00", "1234567890"]],
"row_count": 412,
"truncated": false,
"plan_id": "9d200af1b3534d87",
"trace": "realy://trace/9d200af1b3534d87",
"steps": ["realy://trace/9d200af1b3534d87/step/1", "..."]
}
The operation catalog — filter, join, aggregate — is deliberately not exposed. Composing those operations, validating each one, and recovering when one fails is the product; publishing them individually would move planning into the calling model, which cannot see the schema or the data.
Types
columns[].type is the contract. Values whose JSON representation would be
lossy arrive as strings in canonical form, because JSON has no decimal type
and float-encoding a currency amount or a lab value is a correctness bug:
| type | encoding | example |
|---|---|---|
decimal |
string, declared scale preserved | "1.10" (not 1.1) |
date |
ISO-8601 | "2026-01-04" |
timestamp |
ISO-8601, no offset invented | "2026-01-04T09:30:00" |
timestamptz |
ISO-8601 with explicit offset | "2026-01-04T09:30:00+00:00" |
interval |
ISO-8601 duration | "P1DT2H30M" |
uuid, bytes |
string / base64 | |
int, float, bool, string |
native JSON | |
| SQL NULL | JSON null |
distinct from "null" |
Types come from your database by default. Override per column in the config where the storage type is right but the meaning is not.
Statuses
Most failures are successful calls carrying a status, not protocol errors, because a protocol error tells the calling agent to consider retrying and that is usually wrong here.
| status | meaning |
|---|---|
ok |
Data. |
ok_zero_rows |
An answer. The query ran and nothing matched. Do not retry. |
no_plan_found |
Ran out of operations. Retrying identically will not help. |
unsupported_operation |
Needs arithmetic/string/date-part work outside the grammar. |
result_too_large |
Truncated; the rest is behind the pages resources. |
access_denied |
The configured identity may not read that. |
timeout, model_unavailable, database_unavailable |
Retryable. |
Each carries retryable and a guidance string saying what to do.
The trace
Every result links its steps rather than inlining them, so an answer costs the caller no more context than the answer plus links. Fetch one to see what it did:
// realy://trace/9d200af1b3534d87/step/2
{
"step": 2, "operation": "f_filter_rows", "succeeded": true,
"reasoning": "unpaid claims only",
"arguments": {"table_name": "claims", "column": "paid_amount", "op": "is_null"},
"input_shape": [50123, 9], "output_shape": [412, 9],
"sql": "SELECT ... FROM v_claims_scoped WHERE paid_amount IS NULL",
"duration_ms": 41.2
}
Traces live in memory for the life of the process and are never written to disk. SQL is recorded parameterized — bound values are your data and do not appear in it.
What this does not do
Stated plainly, because the alternative is a surprise in production:
- It does not ask for clarification. Given an ambiguous request it picks an
interpretation, proceeds, and states the interpretation in
summary. Read the summary. - It runs as one identity. The configured database role, for every request. It does not impersonate end users and does not implement row-level access. Scope it with a role and views. See docs/THREAT_MODEL.md.
- It has no planner or backtracking search. The model is the planner. A failed operation returns an error, the model reads it and tries again. The whole attempt history is in the trace.
- The operation grammar is bounded. Filter, sort, select, aggregate
(count/sum/mean/min/max), join. No arithmetic between columns, no string
manipulation, no date-part extraction — those return
unsupported_operation. - No caching and no telemetry. SSE/HTTP offer optional bearer auth and legacy path tokens, with one shared database identity and no per-user access control.
Operating it
Diagnostics and audit records go to stderr as JSON lines, one per request — what was asked, which operations ran, row counts, timings. Not the returned values. Format documented in docs/OPERATIONS.md.
Development
python -m venv .venv && .venv/bin/pip install -e ".[dev,duckdb,openai]"
.venv/bin/python -m pytest
The suite needs no credentials and makes no network calls: the database is real, the SQL is real, the protocol is real, and only the model is scripted. See docs/QA.md for what that does and does not cover, and for the manual checklist before a release.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file realy_mcp-0.1.0.tar.gz.
File metadata
- Download URL: realy_mcp-0.1.0.tar.gz
- Upload date:
- Size: 137.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
392bb4f00cfb2204c2bd997d59f6f39b16eea056174462e8d907949ea9202c0f
|
|
| MD5 |
c58c81244143a45e30fe0c761452e75a
|
|
| BLAKE2b-256 |
ecd7ff0bedf251e27d43b8f7754e913c52db835a913b451257d156fec859a36f
|
File details
Details for the file realy_mcp-0.1.0-py3-none-any.whl.
File metadata
- Download URL: realy_mcp-0.1.0-py3-none-any.whl
- Upload date:
- Size: 89.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d62bb341b0ab5a94a033d9467b6ff2afb41a4e317187b899454aab165930d2a5
|
|
| MD5 |
7792c2a56c39fbef6d7005d58fad22c0
|
|
| BLAKE2b-256 |
e7ad36b7caeedbf47027a6fef47c32003f750b85c6572e2ae3e12deaa143860c
|