Skip to content

nlink-jp/data-toolbox-mcp

Repository files navigation

data-toolbox-mcp

DuckDB analysis and containerized Python execution, exposed as a single-binary MCP server. Bring your own LLM client.

data-toolbox-mcp lets any MCP client (Claude Desktop, Cursor, ...) load tabular data into a per-workspace DuckDB and run SQL or Python against it inside a Podman sandbox. Nine tools are exposed:

  • load_data(workspace_id, file_path, table_name)
  • query_data(workspace_id, sql) — auto-LIMIT returns truncated + total (v0.4.0)
  • execute_code(workspace_id, language, code)
  • list_workspaces() — discover prior workspaces across sessions
  • delete_workspace(workspace_id, dry_run?) — irreversible by default; dry_run: true shows what would be removed (v0.4.0)
  • describe_runtime() — what the container ships (python, packages, fonts, network)
  • attach_files(workspace_id, paths) — return /work files as inline MCP image / text content
  • load_from_work(workspace_id, file_path, table_name) — table-ize a file already in /work
  • describe_workspace(workspace_id) — every table's column schema in the workspace (v0.4.0)

The server is LLM-agnostic: it speaks plain MCP over stdio and never talks to any LLM provider itself.

日本語版 README

Why this exists

shell-agent-v2 bundles a Wails GUI, an LLM client, and a DuckDB + Podman tool layer in one process. When you want the same data tools from a different LLM client, you'd have to reach inside that bundle. data-toolbox-mcp extracts the tool layer alone and ships it as a reusable MCP server, so any compliant client can use it.

Features

  • Three MCP tools for the load → query → analyze loop.
  • workspace_id scoping: each workspace owns one container and one DuckDB file; state persists across server restarts. (ADR-0001)
  • Podman sandbox with network=none by default; CPU / memory / timeout caps configurable. (ADR-0002)
  • Python runtime (duckdb, pandas, polars, pyarrow bundled). (ADR-0003)
  • stdio transport only — no network exposure, no auth needed. (ADR-0004)
  • No registry push — the runtime Dockerfile is go:embed-ed and built locally on first use. (ADR-0005)
  • Single binary, single version: serve / build-runtime / doctor / version subcommands all ship in one binary.
  • Structured tool errors: every tool error has a stable code LLM clients can branch on (path_not_allowed, unsupported_language, script_failed, ...).
  • Defense-in-depth path checks: allowed_paths is enforced after EvalSymlinks on both sides, blocking symlink jail-breaks.

Requirements

  • macOS or Linux
  • Podman (rootless). On macOS run podman machine start once before using.
  • Go 1.23+ to build from source

Quick start

# 1. Build the binary (signed with Developer ID on macOS if the cert is in your keychain)
make build

# 2. Build the runtime container image (first time only, ~2 min)
dist/data-toolbox-mcp build-runtime

# 3. Verify the environment
dist/data-toolbox-mcp doctor

# 4. Wire it into your MCP client (Claude Desktop config example)
cat >> ~/Library/Application\ Support/Claude/claude_desktop_config.json <<'JSON'
{
  "mcpServers": {
    "data-toolbox": {
      "command": "/absolute/path/to/dist/data-toolbox-mcp",
      "args": ["serve", "--config", "/Users/you/.config/data-toolbox-mcp/config.toml"]
    }
  }
}
JSON

A minimal config.toml:

[workspace]
workspace_dir = "~/.data-toolbox"
allowed_paths = ["~/data", "~/Downloads"]

[container]
image        = "localhost/data-toolbox-runtime:latest"
stop_on_exit = true

[container.limits]
cpu             = "1.0"
memory          = "2GB"
timeout_seconds = 60
network         = "none"

[query]
default_row_limit = 20000

See config.example.toml for the full schema. Full client setup — Claude Desktop, Cursor, troubleshooting — is in docs/en/reference/client-setup.md.

Subcommands

Command Purpose
serve (default) Start the MCP stdio server
build-runtime Unpack the embedded Dockerfile and podman build the runtime image
doctor Diagnose Podman, podman machine (macOS), runtime image, and config
version Show the binary version

Tools

Tool Arguments Returns
load_data workspace_id, file_path (host), table_name {rows_loaded, schema}
query_data workspace_id, sql {rows, row_count, limit_applied, limit_reached, truncated, total, total_unavailable_reason?}
execute_code workspace_id, language: "python", code {stdout, stderr, exit_code, host_work_dir}
list_workspaces {workspaces: [{id, last_used, container_state, host_work_dir}]}
delete_workspace workspace_id, dry_run? dry_run=false: {deleted, workspace_id}; dry_run=true: {would_delete, container_id, container_state, host_paths, disk_usage_bytes}
describe_runtime {python_version, container_image, packages, fonts, network, mount_points, notes}
attach_files workspace_id, paths: [string] (1–16, /work/... or relative) MCP content array: summary text + image / text / metadata blocks per file
load_from_work workspace_id, file_path (/work/...), table_name {rows_loaded, schema}
describe_workspace workspace_id {workspace_id, host_work_dir, container_state, tables: [{name, columns: [{name, type}]}]}

load_data infers the reader from the file extension (.csvread_csv_auto, .json / .jsonlread_json_auto, .parquetread_parquet). query_data auto-appends LIMIT [query] default_row_limit (default 20000) when the SQL has no LIMIT. execute_code only accepts language="python" in this version (ADR-0003); the runtime container ships with duckdb, pandas, polars, pyarrow, matplotlib, and Pillow, plus fonts-noto-cjk so Japanese matplotlib labels render without setup (ADR-0007). Call describe_runtime once at session start to inspect what's actually available.

attach_files (v0.3.0 / ADR-0008) returns files as MCP image content (PNG / JPG / SVG / GIF / WEBP / BMP) or text content (CSV / JSON / MD / etc.) so MCP clients render them inline; files above [attach] max_single_size_bytes (default 10 MiB) or beyond max_total_size_bytes (default 20 MiB) downgrade to metadata-only. load_from_work (v0.3.0 / ADR-0009) table-izes files that already live in /work — typically files written by execute_code — without going through allowed_paths.

describe_workspace (v0.4.0 / ADR-0010) returns every user table's column schema in one call — pair with list_workspaces for cross-session "what's in here?". query_data (v0.4.0) result now includes truncated and total, and a missing-table error carries an actionable hint (available tables + other workspaces). delete_workspace accepts dry_run: true to show what would be removed without acting.

Security model (essentials)

  • allowed_paths is enforced for every file load_data is asked to read. The path is made absolute and EvalSymlinks-resolved before being compared with the EvalSymlinks-resolved allowed entries.
  • The container runs with network=none by default. To enable network access (and thus in-container pip install), set [container.limits] network = "bridge" — there is intentionally no finer-grained ACL.
  • The container runs as a non-root user (UID 1000 from the runtime Dockerfile). On rootless Podman the host user is mapped to that UID via --userns keep-id:uid=1000,gid=1000.
  • Per-tool timeouts are enforced via context.WithTimeout; on expiry the podman exec child is killed and the MCP request still returns (no hung calls).
  • Tool errors are returned as structured JSON inside the MCP content block; LLM clients can branch on the code slug.

Full model: docs/en/reference/architecture.md §6.

Sample data

samples/ ships with three small datasets — sales.csv (40 rows), products.json (10 rows), logs.jsonl (41 rows) — and samples/README.md walks through a graded end-to-end verification (load → SQL → JOIN → window functions → quantiles → pandas → polars → workspace isolation → security boundaries).

Documentation

Acknowledgements

The tool surface and the per-workspace DuckDB + container pattern are derived from shell-agent-v2. data-toolbox-mcp extracts and reshapes those ideas as a standalone MCP server.

License

MIT.

About

DuckDB analysis and containerized Python execution exposed as a single-binary MCP server. Bring your own LLM client.

Resources

License

Stars

Watchers

Forks

Contributors