Metadata-Version: 2.4
Name: realy-mcp
Version: 0.1.0
Summary: MCP server exposing Realy's validated table-operation retrieval agent over a customer-owned database.
Classifier: Environment :: Console
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: <3.14,>=3.10
Description-Content-Type: text/markdown
Requires-Dist: mcp<3,>=2.1.1
Requires-Dist: psycopg[binary]<4,>=3.1
Requires-Dist: click<9,>=8.1
Requires-Dist: uvicorn<1,>=0.31.1
Requires-Dist: starlette<2,>=0.48
Requires-Dist: langgraph>=1.0.2
Requires-Dist: langchain-core>=1.0.0
Requires-Dist: sqlalchemy>=2.0.43
Requires-Dist: pydantic>=2.11.0
Requires-Dist: tomli>=2.0; python_version < "3.11"
Provides-Extra: openai
Requires-Dist: langchain-openai>=1.0.0; extra == "openai"
Provides-Extra: anthropic
Requires-Dist: langchain-anthropic>=1.0.0; extra == "anthropic"
Provides-Extra: google
Requires-Dist: langchain-google-genai>=3.0.0; extra == "google"
Provides-Extra: groq
Requires-Dist: langchain-groq>=1.0.0; extra == "groq"
Provides-Extra: duckdb
Requires-Dist: duckdb-engine>=0.13.0; extra == "duckdb"
Requires-Dist: duckdb>=1.0.0; extra == "duckdb"
Provides-Extra: postgres
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=6; extra == "dev"
Requires-Dist: httpx>=0.28; extra == "dev"
Requires-Dist: httpx2>=2.5; extra == "dev"
Requires-Dist: langchain-openai>=1.0.0; extra == "dev"
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: duckdb-engine>=0.13.0; extra == "dev"
Requires-Dist: duckdb>=1.0.0; extra == "dev"

# 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](docs/THREAT_MODEL.md).

---

## Install

```bash
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`](realy.example.toml) and edit it. The short version:

```toml
[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:


```bash
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](https://docs.astral.sh/uv/getting-started/installation/) 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:

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

```bat
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):

```bash
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/`).

```json
{
  "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`):

```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](https://developers.openai.com/codex/mcp).

### Claude Code

With the database and model variables exported in the launching shell:

```text
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](https://code.claude.com/docs/en/mcp) and
[Cursor MCP configuration](https://cursor.com/docs/context/mcp).

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

```toml
[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.

```text
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](https://developers.openai.com/api/docs/guides/developer-mode)
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:

```text
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](examples/postgres/) supplies a seeded demo database.
Older [demo instructions](docs/DEMO.md) use the optional HTTP token mode.

## Build and publish to PyPI

```text
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`](../.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](https://docs.pypi.org/trusted-publishers/using-a-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:

```jsonc
{
  "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:

```jsonc
// 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](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](docs/OPERATIONS.md).

## Development

```bash
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](docs/QA.md) for what that does and does not cover, and for the
manual checklist before a release.
