open source · Apache-2.0 · pre-alpha

Let agents query your warehouse — knowing what the data means.

A read-only MCP server that lets any AI agent work with SQL databases the way an analyst does. Give it your semantic model in an open format (Apache Ossie), your own metadata, context and documents — and agents find the right tables, read what they mean and query them, safely.

  • runuvx --from 'semantic-warehouse-mcp[all]' semantic-warehouse-mcp
  • installpip install 'semantic-warehouse-mcp[postgres]'
  • imageghcr.io/alexeiveselov92/semantic-warehouse-mcp
  • 13 read-only tools
  • ClickHouse, PostgreSQL and DuckDB
  • stdio or Streamable HTTP
An agent asks, semantic-warehouse-mcp answers read-only

youWhat was the shop's revenue in March 2026, by currency?

→ list_skills()└ one skill: revenue-questions — which metric, which grouping, what to check
→ get_skill(name="revenue-questions")└ use the revenue metric; group by currency; say the period, in UTC
→ describe_metric(metric="revenue")└ SUM(orders.amount), where the field amount is amount - refunded_amount
→ run_query(sql=…){  "connection": "shop",  "sql": "SELECT currency, SUM(amount - refunded_amount) AS revenue\nFROM marts.orders\nWHERE order_date >= DATE '2026-03-01' AND order_date < DATE '2026-04-01'\nGROUP BY currency ORDER BY currency",  "started_at": "2026-10-24T21:06:07+00:00",  "seconds": 0.028,  "columns": [    {"name": "currency", "type": "VARCHAR"},    {"name": "revenue", "type": "DECIMAL(38,2)"}  ],  "row_count": 2,  "truncated": false,  "rows": [["EUR", 35057.66], ["USD", 12560.35]]}

agentMarch 2026, in UTC, net of refunds: EUR 35,057.66 and USD 12,560.35 — kept apart, never added together. The SQL that ran, and when, are in the answer above.

Reads
  • ClickHouse
  • PostgreSQL
  • DuckDB
next:
  • MySQL
  • BigQuery
  • Snowflake
Answers
  • Claude Code
  • Claude Desktop
  • Cursor
  • Codex
  • any MCP client

semantic layer · apache ossie

What the data means, written once — in an open format.

A semantic model in Apache Ossie says which table holds what, what each column is, how tables join and how a number is counted — with ai_context for agents. The server checks it when it starts and serves it as written, beside the tables as the database holds them now. Semantic models →

The model, in Ossie

One document per model: datasets, fields, relationships, metrics. A field may be a calculation, not a column.

semantic/shop.yaml
version: "0.2.0.dev0"name: shopdescription: Orders, customers and site events of a made-up online shop.ai_context:  instructions: >-    Revenue is net of refunds. A day is a calendar day in UTC.    Never add revenue of different currencies — group by currency.datasets:  - name: orders    source: marts.orders    primary_key: [order_id]    description: |-      One row per paid order; test orders are removed.      The amount is what the customer paid net of refunds, in the order's currency.    ai_context:      synonyms: [purchases, sales]    fields:      …      - name: order_date        expression:          dialects:            - dialect: ANSI_SQL              expression: order_date        dimension:          is_time: true        datatype: Date      …      - name: amount        expression:          dialects:            - dialect: ANSI_SQL              expression: amount - refunded_amount        description: The amount paid, net of refunds.        datatype: Decimal  …metrics:  - name: revenue    expression:      dialects:        - dialect: ANSI_SQL          expression: SUM(orders.amount)    description: Revenue net of refunds, in the order's currency.  …

A metric, down to its columns

The expression as written, and every field it reads — with the field's own expression.

describe_metric
→ describe_metric(metric="revenue"){  "connection": "shop",  "model": "shop",  "metric": "revenue",  "expression": {    "dialects": [      {"dialect": "ANSI_SQL", "expression": "SUM(orders.amount)"}    ]  },  "description": "Revenue net of refunds, in the order's currency.",  …  "datasets": [    {      "dataset": "orders",      "source": "marts.orders",      "fields": [        {          "name": "amount",          "expression": {            "dialects": [              {                "dialect": "ANSI_SQL",                "expression": "amount - refunded_amount"              }            ]          }        }      ]    }  ]}

A dataset, with the live table beside it

Each field as the team wrote it, with its column as the database holds it; the columns no field names in other_columns.

describe_dataset
→ describe_dataset(dataset="orders"){  "connection": "shop",  "model": "shop",  "dataset": "orders",  "source": "marts.orders",  "primary_key": ["order_id"],  "description": "One row per paid order; test orders are removed.\nThe amount is what the customer paid net of refunds, in the order's currency.",  "ai_context": {"synonyms": ["purchases", "sales"]},  …  "table": {    "name": "marts.orders",    "kind": "table",    "comment": "One row per paid order. Test orders are removed."  },  …  "other_columns": [    {      "name": "amount",      "type": "DECIMAL(12,2)",      "nullable": true,      "comment": "What the customer paid, before refunds, in the order's currency."    },    {      "name": "refunded_amount",      "type": "DECIMAL(12,2)",      "nullable": true,      "comment": "What was refunded; 0 when nothing was."    }  ],  …  "fields": [    …    {      "name": "order_date",      "expression": {        "dialects": [          {"dialect": "ANSI_SQL", "expression": "order_date"}        ]      },      "dimension": {"is_time": true},      "datatype": "Date",      "custom_extensions": [        {"vendor_name": "acme", "data": {"tags": ["timestamp"]}}      ],      "column": {"type": "DATE", "nullable": true}    },    …    {      "name": "amount",      "expression": {        "dialects": [          {            "dialect": "ANSI_SQL",            "expression": "amount - refunded_amount"          }        ]      },      "description": "The amount paid, net of refunds.",      "datatype": "Decimal",      "custom_extensions": [        {"vendor_name": "acme", "data": {"tags": ["money"]}}      ]    }  ]}
  • Checked at start — against the schema of its Ossie version, Ossie's own rules, and the database: every source readable and there.
  • Served as written — descriptions, ai_context and your own metadata reach agents in the team's words, never the server's.
  • Two versions read — 0.2.0.dev0, and 0.1.1 as released under the name OSI.

your own metadata · vocabularies

Your categories, owners and links — and what each one means.

Anything of your own goes where Ossie keeps it — custom_extensions, under your vendor_name. A vocabulary declares its keys: where each may be written, its type, a closed list of values with their definitions. The model is checked against it at start, and every answer carries the definitions of the values it shows. Vocabularies →

vocabulary/acme.yaml
vendor_name: acmeunknown_keys: refuse    # an undeclared key stops the server keys:  tags:    description: What a table or a column is about — a business area, or the kind of column it is.    levels: [dataset, field]    type: list    in_lists: true      # shown in list_datasets too    values:      sales: Paid orders and their refunds.      customers: The people who buy, and where they live.      …      money: An amount of money, in the currency of its row — never add amounts of different currencies.      …  layer:    description: Where the table sits in the pipeline that builds it.    levels: [dataset]    in_lists: true    values:      raw: As the application sent it — events, not facts; duplicates possible.      mart: Business-ready — one row per entity or fact, test data removed.  written_by:    description: Where the data of this table comes from in the application's code.    levels: [dataset]    type: list          # each item free: a topic, a link  owner:    description: The team to ask about it.    levels: [dataset, metric]

The server knows no key of any team's: categories, layers, owners, links to the code that writes a table — each is yours, declared once per vendor_name.

semantic/shop.yaml · the dataset's own
  - name: orders    source: marts.orders    …    custom_extensions:      - vendor_name: acme        data:          tags: [sales]          layer: mart          owner: billing team          written_by:            - topic: shop.order_paid              repository: https://github.com/acme/shop-backend              code: https://github.com/acme/shop-backend/blob/main/billing/order_paid.go
list_datasets · found by its tag
→ list_datasets(search="sales"){  "vocabulary": {    "acme": {      "tags": {        "description": "What a table or a column is about — a business area, or the kind of column it is.",        "values": {"sales": "Paid orders and their refunds."}      },      "layer": {        "description": "Where the table sits in the pipeline that builds it.",        "values": {          "mart": "Business-ready — one row per entity or fact, test data removed."        }      }    }  },  "datasets": [    {      "connection": "shop",      "model": "shop",      "dataset": "orders",      "source": "marts.orders",      "description": "One row per paid order; test orders are removed.…",      "synonyms": ["purchases", "sales"],      "custom_extensions": [        {          "vendor_name": "acme",          "data": {"tags": ["sales"], "layer": "mart"}        }      ]    }  ]}
--check · two typos, named
$ semantic-warehouse-mcp --config config.yaml --checksemantic-warehouse-mcp: the configuration config.yaml has 2 problems:  - connections[0] "shop" → semantic_models → semantic/shop.yaml → datasets[0] "orders" → custom_extensions[0] (acme) → layer: 'marts' is not one of its values — did you mean 'mart'?  - connections[0] "shop" → semantic_models → semantic/shop.yaml → datasets[0] "orders" → fields[4] "amount" → custom_extensions[0] (acme) → tags: 'mony' is not one of its values — did you mean 'money'?

dbt importer

Described in dbt already? Don't describe it twice.

import-dbt reads your dbt project's manifest.json and writes an Ossie document: tables, columns, descriptions, meta and tags as written. dbt's own primary keys, event_time and relationships tests are read by code; which of your keys mean a time column or a primary key, your configuration says. The dbt importer →

  1. Your dbt project

    Its manifest.json, from dbt parse: models, seeds, snapshots and sources, as the team described them.

  2. import-dbt

    Your mapping in import.yaml. The document is read back as the server reads it before it is written — never one it would refuse.

  3. An Ossie document

    Committed beside your configuration and reviewed like any file; --check in CI says whether it is current.

models/marts/_marts.yml · dbt
models:  - name: orders    description: |-      One row per paid order; test orders are removed.      amount is what the customer paid before refunds, refunded_amount what was refunded: revenue is      amount - refunded_amount, in the order's currency. Never add amounts of different currencies.    config:      event_time: order_date      tags: [daily]      meta:        layer: mart        tags: [sales]        owner: billing team    columns:      - name: order_id        description: The order.        config:          meta: { tags: [identifier] }        data_tests: [unique, not_null]      - name: order_date        description: The day the order was paid, UTC.      …
import.yaml
manifest: manifest.json   # from dbt parseoutput: shop-dbt.yaml     # the Ossie document written model:  name: shop_dbt  description: The made-up shop's tables, as its dbt project describes them.  ai_context:    instructions: Revenue is net of refunds. Never add amounts of different currencies — group by currency. tables: ["marts.*", "raw.events", "dictionaries.*"]vendor_name: acme         # meta goes under it, as writtenplaces:                   # your keys that are Ossie's places  time_column:    - { meta_key: tags, value: timestamp }
import-dbt
$ semantic-warehouse-mcp import-dbt import.yamlWrote shop-dbt.yaml — the Ossie model shop_dbt:  4 datasets (2 models, 2 sources), 17 fields, 2 relationships  2 primary keys, 3 time columns  acme's data on 4 datasets and 7 fields$ semantic-warehouse-mcp import-dbt import.yaml --check   # in CIshop-dbt.yaml is current.
shop-dbt.yaml · written, never edited
# Written by semantic-warehouse-mcp import-dbt from manifest.json (dbt 1.10.15).# Edit the dbt project or the importer's configuration and import again: an edit here is overwritten.version: 0.2.0.dev0name: shop_dbt…  - name: orders    source: marts.orders    primary_key: [order_id]    …    fields:      …      - name: order_date        expression:          dialects:            - {dialect: ANSI_SQL, expression: order_date}        dimension: {is_time: true}        description: The day the order was paid, UTC.      …    custom_extensions:      - vendor_name: acme        data:          layer: mart          tags: [sales]          owner: billing team

Metrics defined in dbt's MetricFlow are left to Ossie's own converter, and the import names them. Manifests of dbt 1.6 and later.

safety · the guard

Read-only — and checked twice.

One SELECT at a time, parsed in the database's own dialect by sqlglot and by the database itself; only the tables you allow, wherever they are read; large tables bounded. Underneath, the database's own read-only means hold the line. The guard →

A write never runs

Anything that writes, locks or runs a command is refused before it reaches the database — in a CTE or a subquery too.

→ run_query(sql="DELETE FROM marts.orders WHERE order_id = 'o000042'")Error executing tool run_query: Refused by the guard (not_a_select): it is DELETE; only SELECT (with WITH, UNION, …) runs here.

Only the tables you allow

readable holds wherever a table is read — FROM, a join, a subquery, a CTE, a dictionary, and as the database itself says it reads it.

→ run_query(sql="SELECT table_schema, table_name FROM information_schema.tables")Error executing tool run_query: Refused by the guard (table_not_readable): information_schema.tables is not readable here (readable: marts.*, dictionaries.*, raw.events).

Large tables bounded

A table named in large_tables is read only with its column bounded from below, in the query that reads it.

→ run_query(sql="SELECT event_name, count(*) AS events FROM raw.events GROUP BY event_name")Error executing tool run_query: Refused by the guard (large_table_unbounded): raw.events is a large table: bound event_date from below in the WHERE of the query that reads it, e.g. WHERE event_date >= <a date> — the column itself, not a function of it.

Never cut without saying so.

Every answer carries the SQL exactly as it ran, when it started and how long it took — so a number can be checked. Rows and time are capped per connection; a cut answer says so, and how to ask for less.

  • Read-only by the database's own means — DuckDB's file opened read-only with external access off and its configuration locked; ClickHouse over HTTP GET, which it runs read-only whoever the user is; PostgreSQL in a read-only transaction, a superuser refused. Give the server a user that reads only what agents may.
  • Limits that refuse, never cut silently — on ClickHouse every limit refuses on overflow; a user's profile that would cut silently is overridden, or refused at start. A sum over part of a table never looks like the whole.
  • Answers capped — 40,000 characters by default: clients cut long answers silently, so the server cuts first, and says what it left out.
  • Twelve refusals, each with its code and how to fix the query: emptyunparsablenot_one_statementnot_a_selectnot_read_onlyquery_settingsfunction_deniedtable_functiontable_not_readablelarge_table_unboundedunsupportedengine_parse
run_query · past the connection's rows
→ run_query(sql="SELECT * FROM marts.orders ORDER BY order_id"){  "connection": "shop",  "sql": "SELECT * FROM marts.orders ORDER BY order_id",  "started_at": "2026-10-24T21:06:07+00:00",  "seconds": 0.02,  …  "row_count": 500,  "truncated": true,  "truncated_by": ["rows"],  "note": "The query returned more than 500 rows, the limit of shop; only the first 500 were read. Aggregate, filter, or add LIMIT.",  "rows": [    ["o000001", "2026-02-08", "c00002", "USD", 44.7, 0],    ["o000002", "2026-02-17", "c00002", "USD", 149.7, 0],    ["o000003", "2026-02-21", "c00002", "USD", 29.8, 0],    …  ]}

context · skills

What the team knows reaches the agent — as the team wrote it.

Beyond what a table says: how it is made, what never to do, how the team answers a kind of question. For the whole server or one connection — served as written, read anew when an agent asks. Context and skills →

instructions
short rules in the configuration — in the server's instructions the moment an agent connects
context
documents, kept where you keep them — list_context, get_context
skills
folders in the Agent Skills format — a SKILL.md and the files it refers to, checked at start — list_skills, get_skill
repositories
context and skills fetched straight from git — one commit, only the paths named; a new commit looked for every 10 minutes and served only when it passes the checks
config.yaml · a repository
repositories:  - name: kit    url: https://github.com/alexeiveselov92/semantic-warehouse-mcp.git    ref: main    # token_env: KIT_TOKEN  — for a private one skills: [{ repository: kit, path: examples/skills }] connections:  - name: shop    engine: duckdb    path: data/shop.duckdb    readable: ["marts.*", "dictionaries.*", "raw.events"]    large_tables:      - { table: raw.events, bound_by: event_date }    context: [{ repository: kit, path: examples/context }]

A token is named by the variable that holds it and sent to the repository's own address only — never in a file, a command line, a log or an answer.

get_skill · its files listed
→ get_skill(name="revenue-questions"){  "name": "revenue-questions",  "file": "SKILL.md",  "files": ["references/grouping.md"],  "characters": 412,  "offset": 0,  "text": "---\nname: revenue-questions\ndescription: How to answer a question about revenue in the shop — which metric, which grouping, what to check.\n---\n\n1. Use the `revenue` metric; never sum `amount` by hand.\n2. Group by `currency` unless the question names one — the rules are in\n   [references/grouping.md](references/grouping.md).\n3. State the period and the time zone (UTC) in the answer, and show the SQL that ran.\n"}
get_context · fetched from kit
→ get_context(name="context/shop.md"){  "name": "context/shop.md",  "connection": "shop",  "fetched_from": "kit",  "commit": "a5d595d202f284fb43ab9e75e893d312e94c5fd1",  "characters": 422,  "offset": 0,  "text": "# The shop's data — what an analyst would tell a new colleague\n\nA made-up example of a context document: served to agents as written (`get_context`).\n\n- Orders are paid orders only; abandoned carts are in `raw.events` (`event_name = 'cart_abandoned'`).\n- Test orders from `@example.com` customers are removed in `marts.orders` already.\n- Prices changed on 2026-03-01: compare revenue across that date by week, not by day.\n"}
list_connections · the repository's state
→ list_connections(){  "skills": ["revenue-questions"],  "repositories": [    {      "name": "kit",      "url": "https://github.com/alexeiveselov92/semantic-warehouse-mcp.git",      "ref": "main",      "commit": "a5d595d202f284fb43ab9e75e893d312e94c5fd1",      "fetched_at": "2026-10-24T21:07:30+00:00",      "checked_at": "2026-10-24T21:07:30+00:00",      "refresh_minutes": 10    }  ],  …}

any MCP client

One person over stdio. A whole team over HTTP.

Over stdio, each person's agent starts its own server. For a team, run one: one configuration, one read-only user per database, nothing to install on people's computers. Running it →

stdio — one person
# one person's agent starts the server beside it, over stdio$ uvx --from 'semantic-warehouse-mcp[all]' semantic-warehouse-mcp \    --config config.yaml --checkThe configuration config.yaml is fine:  - shop (duckdb): reached; readable marts.*, dictionaries.*, raw.events    semantic model shop (Ossie 0.2.0.dev0): 3 datasets, 2 metrics    context: 1 document  - vocabulary acme (vocabulary/acme.yaml): 4 keys  - for every connection, skills: revenue-questions$ claude mcp add warehouse -- uvx --from \    'semantic-warehouse-mcp[all]' semantic-warehouse-mcp \    --config /path/to/config.yaml
Streamable HTTP — a team
# one server for a team: Streamable HTTP, at /mcp$ export SWM_TOKEN=…        # a long random value$ uvx --from 'semantic-warehouse-mcp[all]' semantic-warehouse-mcp \    --config config.yaml --http --host 0.0.0.0 --token-env SWM_TOKEN$ curl -s localhost:8000/health{"status":"ok","name":"semantic-warehouse-mcp","version":"0.3.0"} # each person's agent$ claude mcp add --transport http warehouse \    https://mcp.example.com/mcp \    --header "Authorization: Bearer $SWM_TOKEN"

Every tool tells the client it is read-only (readOnlyHint), and none is destructive: aboutlist_connectionslist_tablesdescribe_tablerun_querylist_datasetsdescribe_datasetlist_metricsdescribe_metriclist_contextget_contextlist_skillsget_skill

where it fits

What it is — and isn't.

Deliberately narrow: SQL databases with what they mean, for agents, read-only.

It is

  • A read-only MCP server for SQL databases, with a semantic layer in Apache Ossie, your own metadata, context and skills beside the data.
  • Configured in one YAML file: connections, readable tables, large tables, limits — secrets named by their variables, never written in it.
  • For any MCP client, over stdio or Streamable HTTP; ClickHouse, PostgreSQL and DuckDB today.
  • Open source, Apache-2.0.

It isn't

  • A writer — nothing is inserted, updated, created or dropped through it.
  • A translator — a model's expressions are served as written; the SQL an agent runs is its own, and shown.
  • A metrics engine, yet — metrics are served as definitions; asking for a metric by name is next.
  • A dashboard tool — saved query sets, a dashboard's substance as SQL and filters, come next; drawing it never will.
  • Per-person access, yet — one token for everyone over HTTP, or a gateway in front decides who calls.

Point your agent at your warehouse.

Run it with uvx, describe the connection in one YAML file, add your semantic model — written by hand or imported from dbt — and connect your agent.

Pre-alpha — on PyPI and GHCR. Metrics, saved query sets and more databases next.