Metadata-Version: 2.4
Name: mysqlpeek
Version: 0.1.0
Summary: A read-only MySQL/MariaDB MCP server for one or many instances: explore schema, cost a query with EXPLAIN before running it, and never write.
Author: sgr-xd
License: Apache-2.0
Project-URL: Homepage, https://github.com/sgr-xd/mysqlpeek
Project-URL: Issues, https://github.com/sgr-xd/mysqlpeek/issues
Keywords: mysql,mariadb,mcp,model-context-protocol,sql,read-only
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Database
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp>=2.0
Requires-Dist: pymysql>=1.1
Requires-Dist: cryptography>=42
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Dynamic: license-file

# mysqlpeek

A read-only [MySQL](https://www.mysql.com) / [MariaDB](https://mariadb.org) MCP server that
lets an agent explore your schema, **cost a query before running it**, and pick which of
several instances it is talking to — per call, never by a mode it can forget it is in.

Most MySQL MCP servers give a model one blunt instrument, "run this SQL", on one host, with a
regular expression standing between it and `DROP TABLE`. mysqlpeek is different on three
counts:

- **Writes are refused by the engine, not only by a parser.** Every connection runs inside a
  `READ ONLY` transaction with server-side caps on execution time, result rows and rows
  examined. A statement that gets past the parser still cannot write, and cannot run away.
- **Cost before execution.** `EXPLAIN` tells you how many rows a query would examine before a
  single one is read, and `max_join_size` makes the server refuse a query the optimiser
  expects to be too big — before it starts.
- **Several instances, one server.** A profiles file names your prod replica, staging and
  local boxes; every tool takes `instance=`, and every response says which one answered.

Works with MySQL 5.7.8+, MySQL 8 and 9, MariaDB 10.1+, and the managed flavours that keep
the standard session variables (RDS, Cloud SQL, Azure Database for MySQL). MariaDB reports
no `query_cost` and no tree-shaped plan; everything else behaves the same.

## Install

### As a Claude Code plugin (recommended)

```
/plugin marketplace add sgr-xd/mysqlpeek
/plugin install mysqlpeek
```

Claude Code then asks for four things:

| Field | Example |
|---|---|
| **Server** | `db.internal`, `host:3307`, or `mysqls://db.example.com` for TLS |
| **Username** | `readonly_user` |
| **Password** | masked; goes to your operating system's secure storage, never to a settings file |
| **Database** | optional — leave empty and every call names its own |

Port and TLS come from whatever you put in Server, so there is nothing else to set.

This also installs two skills: `mysql-query-craft`, which teaches the
explore → estimate → run discipline the tools are built around, and
`mysql-instance-health`, a one-shot sweep of replication, lock waits, long transactions,
connections, buffer pool, temp tables and top statements that reports whether the
instance is healthy right now.

### As a standalone MCP server

```bash
claude mcp add mysqlpeek --scope user \
  -e MYSQL_HOST=db.internal \
  -e MYSQL_USER=readonly_user \
  -e MYSQL_PASSWORD_FILE=$HOME/.config/mysqlpeek/password \
  -e MYSQL_DATABASE=shop \
  -- uvx mysqlpeek
```

Works with any MCP client, not only Claude Code — point it at `uvx mysqlpeek` over stdio.

### From source

```bash
git clone https://github.com/sgr-xd/mysqlpeek && cd mysqlpeek
uv venv && uv pip install -e ".[dev]"
pytest -m "not integration"      # unit tests, no database needed
pytest                           # adds live tests against a real server
```

## Configure

Only needed for the standalone and from-source paths; the plugin asks instead.

```bash
export MYSQL_HOST=db.internal                     # or host:port, or mysqls://host
export MYSQL_USER=readonly_user
export MYSQL_PASSWORD_FILE=~/.config/mysqlpeek/password   # preferred over MYSQL_PASSWORD
export MYSQL_DATABASE=shop                        # optional
```

| You enter | Host | Port | TLS |
|---|---|---|---|
| `db.internal` | db.internal | 3306 | no |
| `db.internal:3307` | db.internal | 3307 | no |
| `mysql://db.internal:3306` | db.internal | 3306 | no |
| `mysqls://db.example.com` | db.example.com | 3306 | yes |

`MYSQL_PORT`, `MYSQL_SSL`, `MYSQL_SSL_CA` and `MYSQL_SSL_VERIFY` still work and take
precedence if you set them. The password is never accepted as a command-line argument —
arguments leak through shell history and `ps`.

| Variable | Purpose |
|---|---|
| `MYSQLPEEK_PROFILES` | Path to a multi-instance profiles file — see below |
| `MYSQLPEEK_AUDIT_LOG` | Path to a JSONL record of every query decision |

## Tools

**Discovery** — schema exploration, no user SQL accepted:

| Tool | Purpose |
|---|---|
| `list_instances` | Configured instances, which is default, and each one's limits |
| `list_databases` | Schemas visible to your user, system ones flagged |
| `list_tables` | Tables with engine, approximate rows, size, primary key, partitioning |
| `describe_table` | Columns, types, nullability, key membership, charset, comments |
| `list_indexes` | Every index with its column order and cardinality |
| `show_create_table` | Full `CREATE TABLE` / `CREATE VIEW` DDL |

**Query**:

| Tool | Purpose |
|---|---|
| `run_select_query` | Execute a `SELECT` under enforced caps; reports `rows_examined` and the optimiser's `query_cost` |
| `sample_rows` | Preview rows from a table (SQL built server-side) |
| `profile_column` | One column's nulls, distinct count, range and most common values, over a bounded sample |

**Operations** — what the server is doing, and whether it is keeping up:

| Tool | Purpose |
|---|---|
| `list_running_queries` | Statements executing now, longest first (needs PROCESS to see other sessions) |
| `top_statements` | Statement digests from performance_schema ranked by time, count, rows examined or missing index |
| `table_storage_stats` | Tables by size with fragmentation, index-to-data ratio and auto-increment headroom |
| `replication_status` | Replica threads, lag, last error, `read_only` / `super_read_only` |
| `lock_waits` | Sessions blocked on row locks and who blocks them |
| `long_transactions` | Transactions open longer than N seconds, idle-in-transaction included |
| `server_health` | Connections, buffer pool hit rate, temp tables on disk, lock waits, history list |

**Cost & validation** — these read no table data:

| Tool | Purpose |
|---|---|
| `validate_query` | `EXPLAIN` — is the SQL legal, and do the names resolve? |
| `estimate_query_cost` | `EXPLAIN FORMAT=JSON` — per table: access type, index chosen, rows it would examine, share of the table; a `full_scan` / `heavy` / `selective` verdict |
| `explain_plan` | `EXPLAIN FORMAT=TREE` (MySQL 8.0.16+) or the classic table, plus whether every base table is reached through an index |

Every response names the instance that answered and the SQL actually executed, so a
rewritten `LIMIT` is visible rather than silent.

## Safety

mysqlpeek is read-only, enforced in three independent layers:

1. **Statement policy** — single statement only; must open with `SELECT`, `WITH…SELECT`,
   `SHOW`, `DESCRIBE` or `EXPLAIN`; DML/DDL keywords, `INTO OUTFILE`, locking reads
   (`FOR UPDATE`, `FOR SHARE`), `LOAD_FILE()`, `SLEEP()`, lock functions, executable
   comments (`/*! … */`) and cap-raising hints (`SET_VAR`, `MAX_EXECUTION_TIME`) are
   rejected. `mysql.user` and the other credential tables are refused, backticked or not.
   A missing `LIMIT` is appended; an oversized one is clamped.
2. **Session guards** — every connection is put into this state the moment it opens, and a
   connection where any guard fails is refused outright:

   | Guard | Statement |
   |---|---|
   | no writes | `SET SESSION TRANSACTION READ ONLY` |
   | time cap | `SET SESSION max_execution_time = …` (MariaDB: `max_statement_time`) |
   | result cap | `SET SESSION sql_select_limit = …` |
   | examined-rows cap | `SET SESSION max_join_size = …` with `sql_big_selects = 0` — the optimiser refuses a statement it expects to examine more rows than this, before reading any |

   Multi-statement execution is off at the protocol level, so `SELECT 1; DROP …` is a
   syntax error to the server.
3. **Database grants** — connect as a user with only `SELECT`. This is the layer that cannot
   be argued with, and the one you should not skip. It is also the only layer that stops
   server-state statements such as `SET GLOBAL`: the parser refuses them, but a
   `READ ONLY` transaction does not, so an account holding `SUPER` or
   `SYSTEM_VARIABLES_ADMIN` is one parser bug away from changing the server. Do not point
   mysqlpeek at `root`:

```sql
CREATE USER 'readonly_user'@'%' IDENTIFIED BY '…';
GRANT SELECT ON shop.* TO 'readonly_user'@'%';
-- and, if you want the ops tools:
GRANT PROCESS, REPLICATION CLIENT ON *.* TO 'readonly_user'@'%';
```

### Tuning the caps

| Variable | Default |
|---|---|
| `MYSQLPEEK_MAX_EXECUTION_TIME` | `30` (seconds) |
| `MYSQLPEEK_MAX_RESULT_ROWS` | `10000` |
| `MYSQLPEEK_MAX_JOIN_SIZE` | `100000000` (rows the optimiser may plan to examine) |
| `MYSQLPEEK_DEFAULT_LIMIT` | `100` |
| `MYSQLPEEK_MAX_LIMIT` | `10000` |

## Several instances

Point `MYSQLPEEK_PROFILES` at a JSON file, and every tool gains an optional `instance`
argument.

**The file holds references, never values** — so it is safe to commit:

```json
{
  "default": "prod-replica",
  "limits": { "max_limit": 5000 },
  "instances": {
    "prod-replica": {
      "description": "read replica of the primary",
      "host":     "mysqls://db-ro.example.com",
      "user":     "${env:PROD_MYSQL_USER:-readonly}",
      "password": "${cmd:vault kv get -field=password secret/mysql-prod}",
      "database": "shop",
      "limits":   { "default_limit": 25, "max_limit": 200, "max_join_size": 5000000 }
    },
    "staging": {
      "host":     "db-staging.example.com:3307",
      "password": "${file:~/.config/mysqlpeek/staging.pw}"
    },
    "local": {
      "host":     "127.0.0.1",
      "user":     "root",
      "password": "${env:LOCAL_MYSQL_PW:-}"
    }
  }
}
```

| Scheme | Example | Notes |
|---|---|---|
| `env` | `${env:PROD_MYSQL_PASSWORD}` | `${env:VAR:-default}` supplies a fallback |
| `file` | `${file:~/.config/mysqlpeek/prod.pw}` | Trailing newline stripped |
| `cmd` | `${cmd:op read op://vault/mysql-prod/password}` | stdout of a command, run without a shell |

A literal password in the file is rejected with an error pointing at the reference syntax.
A profiles file writable by anyone but its owner is refused, because a `${cmd:…}` reference
means the file decides what gets executed.

Limits layer: environment defaults → file-wide `limits` → per-instance `limits`. Connections
are lazy, so an unreachable instance does not stop the server starting. There is
deliberately no `use_instance` tool: selection is an argument on every call, and every
response carries the `instance` that answered it.

## Releasing

Three files carry the version and must agree: `.claude-plugin/plugin.json`,
`.claude-plugin/marketplace.json` (`plugins[0].version`) and `pyproject.toml`. **Bump on
every shipped change**, documentation included — the plugin cache is keyed by version, so an
unchanged version means installed copies silently keep the previous build.

## License

Apache-2.0
