Skip to main content

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.

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

realy_mcp-0.1.1.tar.gz (136.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

realy_mcp-0.1.1-py3-none-any.whl (88.8 kB view details)

Uploaded Python 3

File details

Details for the file realy_mcp-0.1.1.tar.gz.

File metadata

  • Download URL: realy_mcp-0.1.1.tar.gz
  • Upload date:
  • Size: 136.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.7

File hashes

Hashes for realy_mcp-0.1.1.tar.gz
Algorithm Hash digest
SHA256 f726559c9bdb583ef88e0161c1a49dd04ae6f2f7864be02cf77b3e5dc475427c
MD5 4b6256679fe20c7198ff38a2fcdc4aaa
BLAKE2b-256 676ef92694a81960309a16f70ba20576d646bfda956aa29354fd7b3c66a20843

See more details on using hashes here.

File details

Details for the file realy_mcp-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: realy_mcp-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 88.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.7

File hashes

Hashes for realy_mcp-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 44ad6b4af64affb2be40f16042a33e80261955981c9f367ad4206550940a4a37
MD5 a627b577d45354fdbe5ac3573f1bc66e
BLAKE2b-256 fec18cff6053f46c77670eff0598614782d90deba7a209ab5ec082d4f20fa6b6

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.2

2 files

This release

0.1.1 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page