Skip to main content
Glama
kshash919

horizon-mcp

by kshash919
README.md
# horizon-mcp

![Python](https://img.shields.io/badge/python-3.11%2B-blue?logo=python&logoColor=white)
![MCP](https://img.shields.io/badge/MCP-compatible-blueviolet?logo=anthropic&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-green)

**Natural language access to Snowflake-managed Iceberg tables via Horizon IRC, using DuckDB as the query engine.**

Query your Snowflake Iceberg catalog through Claude Desktop without spinning up a Snowflake warehouse — no compute credits, full RBAC enforcement, vended credentials only.

---

## Architecture

```
┌─────────────────┐       stdio / MCP        ┌──────────────────────┐
│  Claude Desktop │ ◄────────────────────── ► │    horizon-mcp       │
└─────────────────┘                           │  (this server)       │
                                              └──────────┬───────────┘
                                                         │
                              ┌──────────────────────────▼────────────────────────────┐
                              │          Snowflake Horizon IRC REST Catalog            │
                              │   GET /v1/{database}/namespaces                        │
                              │   GET /v1/{database}/namespaces/{ns}/tables            │
                              │   GET /v1/{database}/namespaces/{ns}/tables/{table}    │
                              └──────────────────────────┬────────────────────────────┘
                                                         │
                                               Vended credentials
                                         (ephemeral STS / SAS tokens)
                                                         │
                              ┌──────────────────────────▼────────────────────────────┐
                              │                     DuckDB                             │
                              │   ATTACH via Iceberg extension + OAuth secret          │
                              │   Reads Parquet files directly from cloud storage      │
                              └──────────────────────────┬────────────────────────────┘
                                                         │
                              ┌──────────────────────────▼────────────────────────────┐
                              │         Iceberg Parquet files (S3 / Azure ADLS)        │
                              │        managed by Snowflake external volumes            │
                              └───────────────────────────────────────────────────────┘
```

---

## How It Works

### 1 — PAT Authentication

`horizon-mcp` authenticates to the Horizon IRC API using a Snowflake Personal Access Token (PAT). On startup (and before token expiry) the server exchanges the PAT for a short-lived OAuth bearer token:

```
POST /polaris/api/catalog/v1/oauth/tokens
  grant_type=client_credentials
  scope=session:role:<ROLE>
  client_secret=<PAT>
→ { "access_token": "...", "expires_in": 3600 }
```

The bearer token is cached in memory and refreshed automatically 120 seconds before expiry.

### 2 — Horizon IRC REST Catalog

All catalog discovery goes through Snowflake's Horizon Iceberg REST Catalog (IRC) API:

| Operation | HTTP call |
|---|---|
| List schemas | `GET /v1/{database}/namespaces` |
| List tables in a schema | `GET /v1/{database}/namespaces/{namespace}/tables` |
| Load table metadata + credentials | `GET /v1/{database}/namespaces/{namespace}/tables/{table}` |

Snowflake enforces your full RBAC posture on every call — the role baked into the OAuth scope determines exactly which namespaces and tables are visible.

### 3 — Vended Credentials

When `load_table` is called, Horizon returns the Iceberg table metadata **plus** ephemeral, scoped storage credentials (AWS STS tokens for S3, SAS tokens for Azure ADLS). These credentials:

- Are scoped to the specific table being accessed
- Expire after a short TTL (typically 1 hour)
- Are never stored to disk — they live only in memory for the duration of the query

### 4 — DuckDB Query Engine

DuckDB reads Parquet/Iceberg files directly from cloud storage using the vended credentials. The server uses DuckDB's native `iceberg` extension with an OAuth secret:

```sql
CREATE SECRET horizon_secret (
    TYPE iceberg,
    CLIENT_SECRET '<PAT>',
    OAUTH2_SERVER_URI '...',
    OAUTH2_GRANT_TYPE 'client_credentials',
    OAUTH2_SCOPE 'session:role:<ROLE>'
)

ATTACH 'MYDB' AS mydb (
    TYPE iceberg,
    SECRET horizon_secret,
    ENDPOINT 'https://<account>.snowflakecomputing.com/polaris/api/catalog'
)
```

**No Snowflake warehouse is started. No compute credits are charged for reads.** Query compute runs entirely in DuckDB inside the MCP server process on your local machine.

---

## Semantic Backends

`horizon-mcp` supports two backends for the `discover_datasets` tool, controlled by `SEMANTIC_BACKEND` in `.env`:

| Backend | How it works | Best for |
|---|---|---|
| `none` *(default)* | Live Horizon IRC browsing — fetches all namespaces and tables on every search, scores by keyword match against table and column names | Demos, smaller catalogs (< 50 tables), always in sync with the catalog |
| `lancedb` | Local vector search index built by `horizon-seed`, uses `sentence-transformers` for embeddings, cosine similarity search | Large catalogs with 50+ tables, faster discovery, offline-capable |

> **Note:** With `lancedb` you must run `horizon-seed` after setup and re-run it whenever the catalog changes to keep the index fresh.

---

## MCP Tools

Five tools are exposed to Claude Desktop:

### `list_namespaces`
Lists all schemas (namespaces) visible to the current role.

> *"What schemas are in the catalog?"*

### `describe_table(namespace, table)`
Returns the full column schema, partition spec, current snapshot ID, metadata location, and an example SQL query for a specific table. Always fetches live from Horizon IRC.

> *"What columns does the ORDERS table have?"*

### `discover_datasets(intent, max_results?)`
Finds tables relevant to a natural language intent. Uses the configured semantic backend (live browse or LanceDB vector search). Always call this first before writing SQL.

> *"Find me data about customer refunds"*

### `execute_sql(sql)`
Executes raw DuckDB SQL against your Iceberg tables. Table references must be fully qualified:

```sql
SELECT * FROM horizonmcpdb.NORTHMART.ORDERS LIMIT 10
```

> *"Run this SQL: SELECT region, SUM(revenue) FROM ..."*

### `ask_data(question, namespace?, table?)`
Full NL → SQL → result pipeline. Discovers relevant tables, generates SQL via an LLM, executes via DuckDB, and returns results. Requires `OPENAI_API_KEY` in `.env`.

> *"What's the total revenue by region for this quarter?"*

---

## Prerequisites

- **OS:** WSL2 Ubuntu 24.04 (or any Linux/macOS with Python 3.11+)
- **Python:** 3.11 or later
- **Claude Desktop:** Latest version with MCP support enabled
- **Snowflake account** with:
  - Iceberg tables managed by Snowflake (not externally managed)
  - Polaris/Horizon IRC enabled on the account
  - An external volume configured for your Iceberg tables

---

## Snowflake Setup

Run the following as `ACCOUNTADMIN` in a Snowflake worksheet:

```sql
-- ── 1. Create the MCP role ──────────────────────────────────────────────────
CREATE ROLE IF NOT EXISTS HORIZON_MCP_ROLE;

-- ── 2. Grant catalog access ─────────────────────────────────────────────────
-- Replace MYDB and MYSCHEMA with your actual database/schema names
GRANT USAGE ON DATABASE MYDB TO ROLE HORIZON_MCP_ROLE;
GRANT USAGE ON SCHEMA MYDB.MYSCHEMA TO ROLE HORIZON_MCP_ROLE;

-- ── 3. Grant read access to Iceberg tables ──────────────────────────────────
-- Grant on all existing tables in the schema
GRANT SELECT ON ALL TABLES IN SCHEMA MYDB.MYSCHEMA TO ROLE HORIZON_MCP_ROLE;
-- Grant on future tables automatically
GRANT SELECT ON FUTURE TABLES IN SCHEMA MYDB.MYSCHEMA TO ROLE HORIZON_MCP_ROLE;

-- ── 4. Grant access to the external volume ──────────────────────────────────
-- Required for Horizon to vend storage credentials
GRANT USAGE ON EXTERNAL VOLUME <your_external_volume_name> TO ROLE HORIZON_MCP_ROLE;

-- ── 5. Create a service user ────────────────────────────────────────────────
CREATE USER IF NOT EXISTS MCP_SERVICE_USER
    TYPE = SERVICE
    DEFAULT_ROLE = HORIZON_MCP_ROLE
    COMMENT = 'Service user for horizon-mcp MCP server';

GRANT ROLE HORIZON_MCP_ROLE TO USER MCP_SERVICE_USER;

-- ── 6. Create an authentication policy scoped to Horizon IRC ────────────────
CREATE AUTHENTICATION POLICY IF NOT EXISTS HORIZON_MCP_AUTH_POLICY
    AUTHENTICATION_METHODS = ('PROGRAMMATIC_ACCESS_TOKEN')
    COMMENT = 'Allow only PAT auth for horizon-mcp service user';

ALTER USER MCP_SERVICE_USER
    SET AUTHENTICATION POLICY HORIZON_MCP_AUTH_POLICY;

-- ── 7. Generate a PAT ───────────────────────────────────────────────────────
-- Log in as MCP_SERVICE_USER and run:
ALTER USER MCP_SERVICE_USER ADD PROGRAMMATIC ACCESS TOKEN HORIZON_MCP_PAT
    ROLE_RESTRICTION = HORIZON_MCP_ROLE
    DAYS_TO_EXPIRY = 31
    COMMENT = 'PAT for horizon-mcp MCP server';

-- Copy the token value — it is shown only once.
```

> **Tip:** Set a calendar reminder to rotate the PAT before `DAYS_TO_EXPIRY` lapses. Update `SNOWFLAKE_PAT` in `.env` and restart the MCP server.

---

## Installation

```bash
# 1. Clone the repository
git clone https://github.com/kshash919/snowflake-horizon-irc-mcp.git
cd snowflake-horizon-irc-mcp

# 2. Create and activate a Python virtual environment
python3 -m venv .venv
source .venv/bin/activate   # Windows WSL2: same command

# 3. Install the package and all dependencies
pip install -e .

# 4. Configure credentials
cp .env.example .env
# Edit .env and fill in all values (see .env.example for field descriptions)

# 5. Verify the connection
python scripts/test_connection.py
```

A successful run looks like:

```
── Horizon IRC MCP Connection Test ─────────────────────

  OK  Config loaded
       account  = MYACCOUNT-MYLOCATOR
       database = MYDB
       role     = HORIZON_MCP_ROLE
       backend  = none
  OK  PAT -> bearer token  (length=640)
  OK  Namespaces: ['NORTHMART', 'ANALYTICS']
  OK  Tables in 'NORTHMART': ['ORDERS', 'CUSTOMERS', 'PRODUCTS']
  OK  Schema of NORTHMART.ORDERS: ['order_id', 'customer_id', 'order_date', ...]
  OK  Vended credentials present  keys=['s3.access-key-id', 's3.secret-access-key', ...]
  OK  DuckDB Iceberg secret configured
  OK  Semantic backend: none (live Horizon IRC browsing)

── All checks passed ────────────────────────────────────
```

---

## Claude Desktop Configuration

Add the following to your Claude Desktop config file:

**macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`  
**Windows (WSL2):** `%APPDATA%\Claude\claude_desktop_config.json`

```json
{
  "mcpServers": {
    "horizon-iceberg": {
      "command": "wsl",
      "args": [
        "--",
        "/home/<your-wsl-username>/openclaw-workspace/mcp-servers/snowflake/horizon-mcp/.venv/bin/python",
        "-m",
        "horizon_mcp.server"
      ],
      "cwd": "/home/<your-wsl-username>/openclaw-workspace/mcp-servers/snowflake/horizon-mcp"
    }
  }
}
```

> **WSL2 note:** The `"command": "wsl"` entry tells Claude Desktop to launch the server inside WSL2. The path to `python` must be the full absolute path to the venv interpreter. Replace `<your-wsl-username>` with your actual WSL username (run `whoami` in WSL to check).

After editing the config, fully quit and relaunch Claude Desktop. The `horizon-iceberg` tool should appear in the MCP tools panel.

---

## Example Queries

Once connected, try these in Claude Desktop:

### Catalog Discovery

```
What data do you have access to?
```
```
List all the schemas and tables in the catalog.
```

### Simple Queries

```
Show me the first 5 orders.
```
```
How many customers are there per country?
```
```
What are the 10 most expensive products?
```

### Analytical

```
What is total revenue by region for the last 12 months?
```
```
What's the refund rate by product category?
```
```
Which day of the week has the highest order volume?
```

### Multi-Table Joins

```
Which customer segment has the highest refund rate?
```
```
What's the average order value per customer cohort (by signup month)?
```

### Raw SQL via `execute_sql`

```
Run this SQL:
SELECT
    customer_id,
    COUNT(*) AS order_count,
    SUM(total_amount) AS lifetime_value,
    RANK() OVER (ORDER BY SUM(total_amount) DESC) AS ltv_rank
FROM horizonmcpdb.northmart.orders
GROUP BY customer_id
ORDER BY ltv_rank
LIMIT 20
```

### The Governance Question

```
What credentials are you using to read this data? Are the storage keys long-lived?
```

> Claude will explain the PAT → OAuth → vended ephemeral credentials chain accurately, because the MCP server instructions describe it.

---

## Switching Semantic Backends

Edit `SEMANTIC_BACKEND` in your `.env`:

```bash
# Live Horizon IRC browsing (default, no index needed)
SEMANTIC_BACKEND=none

# Local vector search (faster for large catalogs)
SEMANTIC_BACKEND=lancedb
```

When switching to `lancedb`, build the index first:

```bash
source .venv/bin/activate
horizon-seed
```

`horizon-seed` crawls all namespaces and tables visible to your role, generates sentence embeddings for each table (using `all-MiniLM-L6-v2` locally — no API key needed), and stores them in a local LanceDB database at `~/.horizon-mcp/lancedb/`.

Re-run `horizon-seed` after schema changes or when new tables are added.

| | `none` | `lancedb` |
|---|---|---|
| Index required | No | Yes (`horizon-seed`) |
| Always in sync | ✅ | ❌ (re-seed required) |
| Works offline | ❌ | ✅ (after seeding) |
| Best for | < 50 tables, demos | 50+ tables, production |
| Extra dependencies | None | ~500 MB (torch, transformers) |

---

## Troubleshooting

### `HTTP 401` — Token exchange failed

The PAT has expired or is invalid.

```sql
-- Regenerate in Snowflake as MCP_SERVICE_USER
ALTER USER MCP_SERVICE_USER ADD PROGRAMMATIC ACCESS TOKEN HORIZON_MCP_PAT
    ROLE_RESTRICTION = HORIZON_MCP_ROLE
    DAYS_TO_EXPIRY = 31;
```

Update `SNOWFLAKE_PAT` in `.env` and rerun `python scripts/test_connection.py`.

---

### `HTTP 403` — Missing role grants

`HORIZON_MCP_ROLE` is missing `USAGE` or `SELECT` on the relevant object.

```sql
-- Check grants
SHOW GRANTS TO ROLE HORIZON_MCP_ROLE;

-- Fix missing grants
GRANT USAGE ON DATABASE MYDB TO ROLE HORIZON_MCP_ROLE;
GRANT USAGE ON SCHEMA MYDB.MYSCHEMA TO ROLE HORIZON_MCP_ROLE;
GRANT SELECT ON ALL TABLES IN SCHEMA MYDB.MYSCHEMA TO ROLE HORIZON_MCP_ROLE;
```

---

### `HTTP 404 — Unable to find matching target resource method`

The account identifier format in `SNOWFLAKE_ACCOUNT` is wrong. It must be in `ORGNAME-ACCOUNTNAME` format, **not** the legacy `ACCOUNT.REGION.cloud` format.

```bash
# Wrong
SNOWFLAKE_ACCOUNT=myaccount.us-east-1.aws

# Correct
SNOWFLAKE_ACCOUNT=MYORG-MYACCOUNT
```

Find your org + account name in Snowflake: **Admin → Accounts**.

---

### `NoSuchNamespaceException: Namespace does not exist: ""`

Horizon IRC returned an empty namespace. This usually means:

1. `HORIZON_MCP_ROLE` has no `USAGE` on the database or external volume — check grants above.
2. The catalog name in `SNOWFLAKE_DATABASE` doesn't match the Polaris catalog name exactly (case-sensitive).
3. Polaris/Horizon IRC is not fully enabled for the account — contact Snowflake support.

---

### MCP server not showing up in Claude Desktop

1. Validate the JSON syntax of `claude_desktop_config.json` — a trailing comma or missing brace will silently prevent the server from loading. Use a JSON linter.
2. Fully **quit** Claude Desktop (not just close the window) and relaunch.
3. Check Claude Desktop logs for MCP errors:
   - **macOS:** `~/Library/Logs/Claude/mcp*.log`
   - **Windows:** `%APPDATA%\Claude\logs\mcp*.log`
4. Test the server manually from the WSL terminal:
   ```bash
   cd ~/openclaw-workspace/mcp-servers/snowflake/horizon-mcp
   source .venv/bin/activate
   python -m horizon_mcp.server
   ```
   It should start without errors (press Ctrl+C to stop).

---

### Empty `columns` array in `describe_table`

**Known limitation:** Some versions of the Horizon IRC `load_table` response nest the Iceberg schema differently. `horizon-mcp` looks for `metadata.schema.fields` — if Snowflake returns the schema under a different key in your account version, the columns list will be empty. The table is still queryable via DuckDB; use `DESCRIBE <table>` in `execute_sql` as a workaround:

```sql
DESCRIBE SELECT * FROM horizonmcpdb.MYSCHEMA.MYTABLE LIMIT 0;
```

---

## Project Structure

```
horizon-mcp/
├── pyproject.toml                  # Package metadata and dependencies
├── .env.example                    # Environment variable template
├── .gitignore                      # Excludes .env, .venv, __pycache__
├── README.md                       # This file
│
├── scripts/
│   └── test_connection.py          # End-to-end smoke test (run before first use)
│
└── src/
    └── horizon_mcp/
        ├── __init__.py             # Package init
        ├── config.py               # Loads and validates .env into a Config dataclass
        ├── horizon_client.py       # Horizon IRC REST API client (auth + catalog calls)
        ├── duckdb_engine.py        # DuckDB + Iceberg extension query engine
        ├── server.py               # MCP server — 5 tools, FastMCP transport
        ├── seed.py                 # horizon-seed CLI — builds LanceDB index
        ├── nl_to_sql.py            # NL → SQL via OpenAI-compatible API
        ├── semantic.py             # Backend factory (returns none or lancedb backend)
        └── semantic/
            ├── __init__.py         # Semantic subpackage init
            ├── base.py             # Abstract SemanticBackend base class
            ├── none_backend.py     # Live IRC browse + keyword scoring
            └── lancedb_backend.py  # LanceDB vector search backend
```

---

## Billing Note

Horizon IRC API calls will be billed as **Cloud Services** at **0.5 credits per million API calls** — negligible for typical MCP usage patterns. **No Snowflake warehouse compute credits are charged** for data reads; DuckDB reads Parquet files directly from cloud storage.

---

## Security

| Concern | How it's handled |
|---|---|
| Credentials never exposed through MCP | The MCP tools return query results only — PAT, bearer tokens, and vended storage keys are never included in tool responses |
| `.env` is gitignored | `.gitignore` excludes `.env` — credentials are never committed to source control |
| PAT scoped to a single role | `ROLE_RESTRICTION = HORIZON_MCP_ROLE` in the PAT definition prevents privilege escalation |
| Vended credentials are ephemeral | Horizon-vended STS/SAS tokens expire after ~1 hour and are scoped to the specific table being queried |
| No long-lived storage keys | DuckDB never holds permanent cloud storage credentials — only ephemeral tokens vended per-query |

---

## License

MIT