Skip to main content
Glama
wuqunfei

duckdb-mcp

by wuqunfei
README.md
<div align="center">

# ๐Ÿฆ† DuckDB MCP Server

### Give your AI assistant a blazing-fast, persistent SQL engine.

A minimal [**Model Context Protocol**](https://modelcontextprotocol.io) server that plugs [DuckDB](https://duckdb.org) straight into Claude โ€” query CSVs, Parquet, and cloud data in plain language, at in-process speed.

[![CI](https://github.com/wuqunfei/duckdb-mcp-mini/actions/workflows/ci.yml/badge.svg)](https://github.com/wuqunfei/duckdb-mcp-mini/actions/workflows/ci.yml)
![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776AB?logo=python&logoColor=white)
![DuckDB](https://img.shields.io/badge/DuckDB-1.5.2-FFF000?logo=duckdb&logoColor=black)
![MCP](https://img.shields.io/badge/MCP-2.0-6E56CF)
![Code style](https://img.shields.io/badge/code%20style-black-000000)
![License](https://img.shields.io/badge/license-MIT-3DA639)

**โšก ~10 ms / query ยท ๐Ÿชถ 2 runtime deps ยท ๐Ÿงฐ 12 tools ยท ๐Ÿ”’ read-only & secret-safe**

</div>

---

## โœจ Why this one?

| | |
|---|---|
| โšก **Fast** | One persistent connection for the whole session โ€” no subprocess spawn, no reconnect. ~10 ms per query. |
| ๐Ÿชถ **Tiny** | A focused `src/` package, just **two** runtime dependencies (`duckdb`, `mcp`). No bloat, no magic. |
| ๐Ÿงฐ **Complete** | 12 tools covering query, write, CSV/Parquet loading, and full catalog/schema/table introspection. |
| ๐Ÿ”’ **Safe by default** | Engine-enforced `--read-only` mode, plus a `list_environments` tool that **masks every secret** (`****`). |
| โ˜๏ธ **Cloud-ready** | Query `s3://` Parquet directly, with `${VAR}` interpolation to keep credentials out of config files. |
| ๐Ÿงฉ **Extensible** | Opt-in unsigned/community extensions (e.g. TA-Lib) via a single flag or env var. |
| ๐Ÿ”Œ **Local or remote** | Run over **stdio** (Claude Desktop) or **streamable HTTP** โ€” one `--transport` flag. |
| ๐Ÿ”‘ **Token auth** | Protect the HTTP transport with a bearer token from a single env var. |
| โœ… **Trustworthy** | Fully typed, linted, and tested โ€” CI runs black + ruff + mypy + pytest on Python 3.12 & 3.13. |

---

## ๐Ÿš€ Quick start

**1. Run it โ€” no install needed** (via [uv](https://docs.astral.sh/uv/)):

```bash
uvx --from git+https://github.com/wuqunfei/duckdb-mcp-mini duckdb-mcp --db :memory:
```

**2. Point Claude Desktop at it** โ€” add this to `claude_desktop_config.json` and restart Claude:

```json
{
  "mcpServers": {
    "duckdb": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/wuqunfei/duckdb-mcp-mini", "duckdb-mcp", "--db", ":memory:"]
    }
  }
}
```

**3. Ask away** ๐Ÿ’ฌ

> *"Load `~/data/sales.csv` and show me total revenue by region."*

That's it. Tools are available immediately. ๐ŸŽ‰

---

## ๐Ÿ“ฆ Install

```bash
pip install .            # from a clone, into the current environment
pip install -e ".[dev]"  # for development (editable + dev tools)
```

Or run straight from Git without installing:

```bash
uvx --from git+https://github.com/wuqunfei/duckdb-mcp-mini duckdb-mcp --db :memory:
```

> โ„น๏ธ This package is **not published to PyPI** โ€” install from source or run from Git as shown above.

**Requirements:** ๐Ÿ Python 3.11โ€“3.14 ยท ๐Ÿฆ† DuckDB 1.5.2 (pinned in `pyproject.toml`, easy to change) ยท ๐Ÿ”Œ MCP SDK 2.0.0+

---

## ๐Ÿƒ Run

The installed console script is `duckdb-mcp`. It speaks MCP over **stdio**, so you'll normally launch it from an MCP client โ€” but you can start it directly too:

```bash
duckdb-mcp                                             # in-memory, read-write
duckdb-mcp --db /path/to/analytics.duckdb --schema main
duckdb-mcp --db analytics.duckdb --init-sql init.sql
duckdb-mcp --db analytics.duckdb --read-only

python -m duckdb_mcp.cli --db :memory:                 # module form
uv run duckdb-mcp --db :memory:                        # via uv, no install
```

### CLI arguments

| Flag | Description |
|------|-------------|
| `--db`, `--database` | Database path (`:memory:` or `/path/to/db.duckdb`). Default: `:memory:` |
| `--schema` | Default schema. Default: `main` |
| `--init-sql` | Path to a SQL file executed once on startup |
| `--read-only` | Open the database read-only (default: read-write) |
| `--allow-unsigned-extensions` | Allow unsigned/community extensions (default: off; env: `ALLOW_UNSIGNED_EXTENSIONS`) |
| `--transport` | `stdio` (default) or `http` (streamable HTTP) |
| `--host` | Bind host for the `http` transport (default: `127.0.0.1`) |
| `--port` | Bind port for the `http` transport (default: `8000`) |
| `--log-level` | Log level to stderr: `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL` (default: `WARNING`; env: `LOG_LEVEL`). `DEBUG` logs every tool request |

> ๐Ÿ› **Debugging:** run with `--log-level DEBUG` (or `LOG_LEVEL=DEBUG`) to log every incoming tool request โ€” name and arguments โ€” to **stderr**, so you can see exactly what a client sends:
>
> ```
> DEBUG duckdb_mcp: tool request: query args={'sql': 'SELECT 42 AS answer'}
> ```
>
> Logs always go to stderr (never stdout, which is the protocol channel in stdio mode).

### ๐Ÿ”’ Read-only mode

`--read-only` opens the connection via DuckDB's own read-only flag, so writes are blocked at the **engine level** (not by inspecting SQL):

- โœ… `SELECT` works normally
- ๐Ÿšซ `INSERT` / `UPDATE` / `DELETE` / `CREATE` / `DROP` are rejected by DuckDB
- ๐Ÿ’ก Perfect for shared analytics/reporting databases where accidental writes must be impossible

---

## ๐Ÿ”Œ Transports

The server speaks two MCP transports โ€” pick with `--transport`:

| Transport | Flag | Use it for |
|-----------|------|-----------|
| **stdio** (default) | `--transport stdio` | Local clients that launch the process, e.g. Claude Desktop |
| **streamable HTTP** | `--transport http` | Remote / networked clients โ€” the current MCP HTTP transport (single `/mcp` endpoint) |

```bash
# stdio (default) โ€” the process talks over stdin/stdout
duckdb-mcp --db analytics.duckdb
```

---

## ๐ŸŒ Run an HTTP server

Configuration splits cleanly in two: **args** set the database and network binding, **environment variables** carry secrets and toggles (so they stay out of `ps` and shell history).

```bash
# 1) Environment โ€” secrets & toggles
export MCP_AUTH_TOKEN="a-long-random-secret"     # require Bearer auth (recommended)
export ALLOW_UNSIGNED_EXTENSIONS=true            # optional: allow community extensions
export AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}"  # optional: for s3:// queries
export AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}"

# 2) Args โ€” database + network binding
duckdb-mcp \
  --transport http \
  --host 0.0.0.0 \
  --port 8000 \
  --db /data/analytics.duckdb \
  --init-sql /data/init.sql \
  --read-only

# โ†’ serving at  http://0.0.0.0:8000/mcp
```

`--init-sql` runs a SQL file once, when the connection opens. Combined with `--read-only` it must be read-only-safe (loading extensions or setting options is fine โ€” creating tables is not):

```sql
-- /data/init.sql
INSTALL httpfs; LOAD httpfs;     -- read remote/S3 data
SET memory_limit = '4GB';
```

> Drop `--read-only` if your init script needs to create tables or views.

No install? Run the same thing straight from Git:

```bash
MCP_AUTH_TOKEN="a-long-random-secret" \
uvx --from git+https://github.com/wuqunfei/duckdb-mcp-mini duckdb-mcp \
  --transport http --host 0.0.0.0 --port 8000 --db :memory:
```

| Setting | Kind | Notes |
|---------|------|-------|
| `--transport http` | arg | Required to serve over HTTP |
| `--host` / `--port` | arg | Bind address (default `127.0.0.1:8000`) |
| `--db` / `--schema` / `--init-sql` / `--read-only` | arg | Same as stdio mode |
| `MCP_AUTH_TOKEN` | env | Require `Authorization: Bearer <token>` (env-only, no flag) |
| `ALLOW_UNSIGNED_EXTENSIONS` | env | Community extensions (or the `--allow-unsigned-extensions` flag) |
| `AWS_*`, any `${VAR}` | env | Cloud credentials for `s3://` reads (see the **Environment variables & cloud data** section) |
| `--log-level` / `LOG_LEVEL` | both | `DEBUG` logs every tool request to stderr |

**Verify it's up** (with `MCP_AUTH_TOKEN` set, a missing/wrong token returns `401`):

```bash
curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST http://127.0.0.1:8000/mcp \
  -H 'Authorization: Bearer a-long-random-secret' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
# 401 = missing/invalid token   ยท   400 = reached the server (a real client does the full MCP handshake)
```

### ๐Ÿ”‘ Bearer token

Setting `MCP_AUTH_TOKEN` turns on auth for the HTTP transport:

- ๐ŸŒฑ **Environment only** โ€” read from `MCP_AUTH_TOKEN`, never a CLI flag, so it stays out of `ps` and shell history.
- ๐Ÿ”Œ **HTTP only** โ€” ignored for `stdio` (the client owns the process); setting it there prints a warning.
- โš ๏ธ **Access control, not identity** โ€” every caller with the token gets full database access. Pair it with `--read-only` and network controls; it is *not* per-user auth.

> ๐Ÿ”’ `--host` defaults to `127.0.0.1` (localhost only). Bind to `0.0.0.0` only on a trusted network, and always with `MCP_AUTH_TOKEN` set.

### ๐Ÿ”— Connect a client

**MCP client config** (Claude Desktop or any client that supports an HTTP URL) โ€” omit `headers` if you didn't set a token:

```json
{
  "mcpServers": {
    "duckdb": {
      "url": "http://127.0.0.1:8000/mcp",
      "headers": { "Authorization": "Bearer a-long-random-secret" }
    }
  }
}
```

**From Python** (the `mcp` client libraries ship `httpx2`):

```python
import asyncio
import httpx2
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

URL = "http://127.0.0.1:8000/mcp"
HEADERS = {"Authorization": "Bearer a-long-random-secret"}  # omit if no MCP_AUTH_TOKEN

async def main():
    async with httpx2.AsyncClient(headers=HEADERS) as http:
        async with streamable_http_client(URL, http_client=http) as streams:
            read, write = streams[0], streams[1]
            async with ClientSession(read, write) as session:
                await session.initialize()
                tools = await session.list_tools()
                print("tools:", [t.name for t in tools.tools])
                result = await session.call_tool("query", {"sql": "SELECT 42 AS answer"})
                print(result.content[0].text)

asyncio.run(main())
```

---

## ๐Ÿ–ฅ๏ธ Configure Claude Desktop

Add one of the following to your `claude_desktop_config.json`, then restart Claude.

**Run from Git (no install):**

```json
{
  "mcpServers": {
    "duckdb": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/wuqunfei/duckdb-mcp-mini", "duckdb-mcp", "--db", ":memory:"]
    }
  }
}
```

**Run from a local clone:**

```json
{
  "mcpServers": {
    "duckdb": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/duckdb-mcp-mini", "duckdb-mcp", "--db", "/home/user/analytics.duckdb", "--schema", "main"]
    }
  }
}
```

**After `pip install .` (command on PATH):**

```json
{
  "mcpServers": {
    "duckdb": {
      "command": "duckdb-mcp",
      "args": ["--db", "/home/user/analytics.duckdb", "--read-only"]
    }
  }
}
```

### ๐Ÿ“ Config file locations

- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Linux:** `~/.config/Claude/claude_desktop_config.json`
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`

### ๐Ÿ” Multiple servers

Add more entries under `mcpServers` with distinct names (e.g. `duckdb-dev`, `duckdb-prod`), each with its own `--db`/`--schema`.

---

## โ˜๏ธ Environment variables & cloud data

Any variables set in the client's `env` block are available to DuckDB during connection (handy for S3/cloud credentials). Values support **`${VAR_NAME}` interpolation**, so you can reference the system environment instead of hardcoding secrets into the config file:

```json
{
  "mcpServers": {
    "duckdb": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/wuqunfei/duckdb-mcp-mini", "duckdb-mcp", "--db", ":memory:"],
      "env": {
        "AWS_ACCESS_KEY_ID": "${AWS_ACCESS_KEY_ID}",
        "AWS_SECRET_ACCESS_KEY": "${AWS_SECRET_ACCESS_KEY}",
        "AWS_REGION": "${AWS_REGION}",
        "S3_BUCKET": "my-data-bucket"
      }
    }
  }
}
```

Set the referenced variables in your shell first:

```bash
export AWS_ACCESS_KEY_ID="your-key"
export AWS_SECRET_ACCESS_KEY="your-secret"
export AWS_REGION="us-east-1"
```

Then query cloud data directly:

```sql
SELECT * FROM read_parquet('s3://my-data-bucket/data.parquet')
```

> ๐Ÿ” Interpolation happens **before** the DuckDB connection is opened, so credentials are ready in time for connection setup and any `--init-sql`. Use the `list_environments` tool to confirm what's set โ€” values are always masked.

---

## ๐Ÿงฉ Community & unsigned extensions

DuckDB only loads **signed** extensions by default. To use community or self-hosted extensions, enable unsigned extensions with the `--allow-unsigned-extensions` flag **or** the `ALLOW_UNSIGNED_EXTENSIONS` environment variable (default: off):

```bash
duckdb-mcp --db :memory: --allow-unsigned-extensions
# or
ALLOW_UNSIGNED_EXTENSIONS=true duckdb-mcp --db :memory:
```

In a Claude Desktop config:

```json
{
  "mcpServers": {
    "duckdb": {
      "command": "uvx",
      "args": ["--from", "git+https://github.com/wuqunfei/duckdb-mcp-mini", "duckdb-mcp", "--db", ":memory:", "--allow-unsigned-extensions"]
    }
  }
}
```

The flag is applied as a **connection-time** setting, so it's already on โ€” just `INSTALL` and `LOAD` through the `execute` tool. For example, [neuesql/atm_talib](https://github.com/neuesql/atm_talib) (TA-Lib for DuckDB):

```sql
INSTALL talib FROM 'https://neuesql.github.io/atm_talib';
LOAD talib;
```

> โš ๏ธ Unsigned extensions run native code with full trust. Only enable this for extensions from sources you trust.

---

## ๐Ÿงฐ Tools (12)

| Category | Tool | Purpose |
|----------|------|---------|
| ๐Ÿ”Ž Core | `query` | Run a `SELECT` and return the result as a text table |
| โœ๏ธ Core | `execute` | Run a write statement (`INSERT`/`UPDATE`/`DELETE`/`CREATE`/`DROP`); returns a status message |
| ๐Ÿ“ฅ File I/O | `read_csv` | Load a CSV file into a table (`table_name` defaults to `data`) |
| ๐Ÿ“ฅ File I/O | `read_parquet` | Load a Parquet file into a table (`table_name` defaults to `data`) |
| ๐Ÿ“š Introspection | `list_catalogs` | List catalogs |
| ๐Ÿ“š Introspection | `list_databases` | List databases |
| ๐Ÿ“š Introspection | `list_schemas` | List schemas |
| ๐Ÿ“š Introspection | `list_tables` | List tables in the current schema |
| ๐Ÿ“š Introspection | `list_columns` | Describe a table's columns |
| ๐Ÿ“š Introspection | `list_extensions` | List loaded extensions |
| ๐Ÿ” Introspection | `list_environments` | List environment variables as `key: value`, with values masked (`****`, or `empty` when unset) |
| ๐Ÿท๏ธ Introspection | `check_version` | Report the DuckDB version |

**`query` vs `execute`** โ€” `query` is for reads (fetches rows, formats a table); `execute` is for writes/DDL (runs the statement, returns `"Executed successfully"`).

---

## ๐Ÿ—๏ธ Architecture

```
launch (cli.main)
   โ†“
DuckDBSession(...)                     # src/duckdb_mcp/session.py
   โ”œโ”€ interpolate ${VAR} across os.environ   โ† runs BEFORE connecting
   โ”œโ”€ duckdb.connect(db, read_only=...)      โ† one persistent connection
   โ””โ”€ run --init-sql (if provided)           โ† falls back to :memory: on error
   โ†“
create_server(session)                 # src/duckdb_mcp/server.py
   โ””โ”€ registers 12 tools on an mcp MCPServer; each calls dispatch_tool()
   โ†“
_serve(session, transport=...)         # src/duckdb_mcp/cli.py
   โ””โ”€ run_stdio_async() | run_streamable_http_async()
```

- ๐Ÿงฉ `session.py` holds `DuckDBSession` with **no MCP imports**, so the connection/formatting logic is unit-testable with only `duckdb`.
- ๐Ÿ—‚๏ธ `server.py` holds the `_HANDLERS` registry (single source of truth for the tool set) plus `dispatch_tool()` and the thin typed MCP registrations.
- ๐ŸŽ›๏ธ `cli.py` parses args, builds the session, and serves.

**Good to know:**
- ๐Ÿ›Ÿ If connecting to `--db` (or running `--init-sql`) fails, the session degrades to an in-memory read-write connection rather than crashing.
- โš ๏ธ SQL is f-string interpolated (table names, filepaths). This is intentional for a local single-user tool โ€” inputs are **not** sanitized.

---

## ๐Ÿ› ๏ธ Development

```bash
pip install -e ".[dev]"      # or: uv sync --extra dev

black --check src tests      # format (drop --check to apply)
ruff check src tests         # lint
mypy src                     # type check
pytest                       # tests

pytest tests/test_server.py::test_dispatch_query   # run a single test
```

### ๐Ÿ“ Project layout

```
duckdb-mcp-mini/
โ”œโ”€โ”€ src/duckdb_mcp/
โ”‚   โ”œโ”€โ”€ __init__.py        # package version + DuckDBSession export
โ”‚   โ”œโ”€โ”€ session.py         # persistent DuckDB session (no MCP deps)
โ”‚   โ”œโ”€โ”€ server.py          # tool registry + dispatch + MCP server wiring
โ”‚   โ””โ”€โ”€ cli.py             # argument parsing + entrypoint
โ”œโ”€โ”€ tests/                 # pytest suite
โ”œโ”€โ”€ .github/workflows/
โ”‚   โ”œโ”€โ”€ ci.yml             # lint + type + test on py3.11โ€“3.14
โ”‚   โ””โ”€โ”€ release.yml        # build on tag (PyPI publish disabled)
โ”œโ”€โ”€ pyproject.toml
โ”œโ”€โ”€ LICENSE
โ””โ”€โ”€ README.md
```

### ๐Ÿ”„ CI / CD

- **CI** (`.github/workflows/ci.yml`) runs black, ruff, mypy, and pytest on Python 3.11, 3.12, 3.13, and 3.14 for every push/PR to `main`, and checks that `duckdb-mcp --help` works.
- **CD** (`.github/workflows/release.yml`) builds the sdist/wheel on a `v*` tag and attaches them to the GitHub Release. **PyPI publishing is intentionally disabled** โ€” the `publish-pypi` job is gated behind `if: false`; see the comments in that file to enable it later.

### ๐Ÿค Contributing

Issues and PRs are welcome! Please keep the footprint small (the minimalism is a feature ๐Ÿชถ), and make sure `black` / `ruff` / `mypy` / `pytest` all pass before opening a PR.

---

<div align="center">

Built with ๐Ÿฆ† + ๐Ÿ”Œ ยท Licensed under [MIT](./LICENSE)

</div>

TDQS

A3.7/5.0

Scored across 12 tools

Disambiguation5/5

Each tool targets a distinct action or resource: query vs execute clearly separate read vs write operations, read_csv vs read_parquet handle different file formats, and the list_* tools each inspect a unique metadata level (catalogs, databases, schemas, tables, columns, extensions, environments). There is no overlap or ambiguity.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern in snake_case (e.g., list_tables, read_csv, check_version). The verbs vary appropriately for the action, but the naming style is uniform and predictable.

Tool Count5/5

With 12 tools, the server is well-scoped for a DuckDB interface. The set covers querying, executing statements, file ingestion, and metadata inspection without being bloated or too sparse.

Completeness4/5

Core workflows are covered: SQL query/execute, CSV/Parquet ingestion, and full metadata listing. Minor gaps exist, such as no write_csv/write_parquet output tools and no extension management beyond listing, but these are not critical for most DuckDB use cases.

Maintenance

ActivitySlowing
ResponsivenessNo issues