Metadata-Version: 2.4
Name: wp-db-mcp
Version: 2.0.0
Summary: MCP server for WordPress MySQL/MariaDB database exploration, read-only when the connecting database user is SELECT-only
Author-email: Sérgio Santos <me@s3rgiosan.com>
License: MIT
Project-URL: Homepage, https://github.com/s3rgiosan/wp-db-mcp
Project-URL: Repository, https://github.com/s3rgiosan/wp-db-mcp
Project-URL: Issues, https://github.com/s3rgiosan/wp-db-mcp/issues
Keywords: mcp,wordpress,wp,mysql,database,model-context-protocol
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mcp[cli]<2.0.0,>=1.0.0
Requires-Dist: aiomysql>=0.2.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
Requires-Dist: ruff>=0.3.0; extra == "dev"
Requires-Dist: mypy>=1.8.0; extra == "dev"
Dynamic: license-file

# WP Database MCP

MCP server for exploring WordPress MySQL/MariaDB databases — schema inspection, relationship
mapping, and raw SQL querying. It is read-only when you point it at a `SELECT`-only database
user, which is how it is meant to be run; see [Security](#security) for why that grant, rather
than anything in this process, is what makes it read-only.

## Features

- Auto-detects WordPress table prefix
- Full schema generation (JSON / CSV)
- WordPress relationship mapping (posts, terms, meta, users, comments)
- Raw SQL querying with SELECT-only validation as a guardrail (see [Security](#security))
- Automatic redaction of credential-shaped values (passwords, keys, tokens, secrets)
- Multisite support, including correct resolution of network-shared tables
- Query timeout (MySQL and MariaDB syntax both handled) and a configurable row cap
- Post search by title/content, with sane content-type/status defaults
- Startup audit of the database account's grants
- Extensible to plugin tables (WooCommerce, ACF, etc.)

## Security

**Read-only is enforced by the database grant, not by this server.** Create a MySQL/MariaDB
user that only has `SELECT`, and use that user's credentials. That grant is the actual
security boundary; everything else below is defence in depth on top of it.

### The SQL validator is a guardrail, not a boundary

`wp_query` runs every statement through `validate_select_only()` before execution. It rejects
non-`SELECT`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements, DDL/DML keywords, filesystem/locking/timing
functions, `SHOW GRANTS`/`SHOW VARIABLES`, MySQL "executable comments" (`/*! ... */`), and access
to the `mysql`, `performance_schema`, and `sys` schemas. It is lexical analysis of a
language with a real grammar, and it exists to stop a confused model from firing off a
destructive statement by accident and to explain clearly why it was rejected. It is **not** a
sandbox a determined or malicious caller cannot escape.

Concretely: `aiomysql` unconditionally ORs in the `CLIENT.MULTI_STATEMENTS` protocol flag with
no way to opt out, so a stacked statement (`SELECT 1; DROP TABLE wp_posts`) is always
transportable to the server at the wire level, regardless of what the validator rejects in
Python first. If the account connecting has write privileges, a bypass of the validator is a
bypass of everything. **The only thing that actually stops a write is the grant.**

`information_schema` is deliberately **not** blocked. It exposes metadata rather than data, and
MySQL scopes its rows to the connecting account's privileges — a `SELECT`-only grant on one
database sees only that database's objects. Blocking it also achieved nothing while `SHOW TABLES`
and `DESCRIBE` remained allowed and return the same information; it only rejected the SQL
spelling of a question the caller could already ask another way. `mysql` is the schema that
actually holds credentials, and stays blocked.

Known gap in the validator itself: double-quoted regions are treated as string literals (MySQL's
default `sql_mode`), so under `sql_mode=ANSI_QUOTES` a value like `"mysql".user` would not be
recognized as a schema reference and could evade the system-schema check. Unquoted and
backtick-quoted forms (`` `mysql`.user ``) are caught correctly.
Statements containing a backslash are checked under both readings of backslash-escape handling
(MySQL default and `sql_mode=NO_BACKSLASH_ESCAPES`); a statement that is genuinely ambiguous
between the two (e.g. `'it\'s'`) is rejected with a message asking the caller to use a doubled
quote (`'it''s'`) instead.

### Startup grant audit

On startup the server runs `SHOW GRANTS FOR CURRENT_USER` and checks the result for write
privileges (`INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `CREATE`, `ALL PRIVILEGES`). If any
are present:

- By default, it logs a loud warning and starts anyway (so upgrading does not break an existing
  deployment that hasn't been reconfigured yet).
- With `WP_REQUIRE_READONLY_GRANTS=1`, it refuses to start.

If `SHOW GRANTS` itself fails (some managed hosts deny it), the check is skipped with a warning
rather than blocking startup.

### Empty-password refusal

The server refuses to start if `WP_DB_PASSWORD` is unset, unless `WP_DB_ALLOW_EMPTY_PASSWORD=1`
is also set. An empty password used to silently produce an unauthenticated connection (often as
`root`) — that is opt-out security, and it is now opt-in instead.

### Redaction

Values are masked as `"<redacted>"` before results are serialized, so this covers every tool,
including raw `wp_query` output. Two shapes are handled:

- **Named columns**: any column named `user_pass`, `user_activation_key`, or `session_tokens`,
  or whose name ends in `_key`, `_secret`, `_token`, or `_password` (case-insensitive) has its
  value masked.
- **Key/value rows**: rows shaped like meta tables, where a `meta_key`/`option_name`/`key`
  column's *value* matches one of the same patterns — the sibling `meta_value`/`option_value`/
  `value` column is masked, and the key name itself is left visible so you can still see which
  field was withheld.

Responses carry `"redacted": true` when anything was masked, so the model can tell data was
withheld rather than concluding a column is empty. Set `WP_ALLOW_SENSITIVE=1` to disable
redaction entirely.

**Known gap**: `user_email` is not redacted. It is not credential-shaped by the rules above, and
treating it as sensitive would make ordinary "who authored this" lookups useless. If your threat
model requires masking email addresses, do it downstream.

### What this does *not* protect against

- A database user with write grants. If the account has `INSERT`/`UPDATE`/`DELETE`, nothing in
  this server stops those statements from reaching the server at the protocol level.
- Read access to any data the grant permits. `SELECT`-only still means the account (and this
  server) can read everything the grant covers — there is no column- or row-level access
  control here beyond redaction of credential-shaped values.
- Resource exhaustion from a huge but valid `SELECT`. The row limit bounds how many rows this
  process turns into Python objects; it does **not** bound the work the database does or what it
  sends back. `SELECT * FROM wp_postmeta` on a large site is still executed in full and still
  crosses the wire in full — the surplus rows are read and discarded. Use the query timeout, and
  a database that can afford the query, as the real limits.

## Requirements

- Python 3.10+
- MySQL or MariaDB database
- A `SELECT`-only database user (see [Database User Setup](#database-user-setup-recommended) —
  this is the actual security boundary, not optional hardening)

## Installation

No installation required when using `uvx`. The MCP server is installed automatically when configured.

Alternatively, install manually:

```bash
pip install wp-db-mcp
```

## Database User Setup (recommended)

Create a dedicated read-only MySQL/MariaDB user. This grant — not the in-process SQL validator —
is what makes the server safe to point at a production database:

```sql
CREATE USER 'wp_mcp_reader'@'localhost' IDENTIFIED BY 'your_secure_password';
GRANT SELECT ON your_wordpress_db.* TO 'wp_mcp_reader'@'localhost';
FLUSH PRIVILEGES;
```

Verify the grant afterwards with `SHOW GRANTS FOR 'wp_mcp_reader'@'localhost';` — it should list
only `SELECT`, nothing else. The server performs this same check automatically at startup and
warns (or, with `WP_REQUIRE_READONLY_GRANTS=1`, refuses to start) if it finds more.

## Environment Variables

| Variable | Default | Description |
|---|---|---|
| `WP_DB_HOST` | `127.0.0.1` | Database host (ignored if socket is set) |
| `WP_DB_PORT` | `3306` | Database port (ignored if socket is set) |
| `WP_DB_SOCKET` | (empty) | Unix socket path (for Local, MAMP, etc.) |
| `WP_DB_USER` | `root` | Database user |
| `WP_DB_PASSWORD` | (empty) | Database password |
| `WP_DB_NAME` | `wordpress` | Database name |
| `WP_TABLE_PREFIX` | (auto-detect) | Table prefix (e.g. `wp_`) |
| `WP_MAX_ROWS` | `1000` | Maximum rows per query |
| `WP_QUERY_TIMEOUT` | `30` | Query timeout in seconds |
| `WP_DB_ALLOW_EMPTY_PASSWORD` | (unset) | Set to `1` to allow starting with an empty `WP_DB_PASSWORD`. Without it, the server refuses to start rather than silently connecting unauthenticated |
| `WP_REQUIRE_READONLY_GRANTS` | (unset) | Set to `1` to make the startup grant audit fatal: if `SHOW GRANTS FOR CURRENT_USER` finds any write privilege, the server refuses to start instead of just warning |
| `WP_ALLOW_SENSITIVE` | (unset) | Set to `1` to disable automatic redaction of credential-shaped values (`user_pass`, `*_key`, `*_secret`, `*_token`, `*_password`, etc.) |

## MCP Client Configuration

### Claude Desktop / Cursor / VSCode

Add to your MCP settings JSON:

```json
{
  "mcpServers": {
    "wp-db": {
      "command": "uvx",
      "args": [
        "--from",
        "wp-db-mcp",
        "wp-db-mcp"
      ],
      "env": {
        "WP_DB_HOST": "127.0.0.1",
        "WP_DB_PORT": "3306",
        "WP_DB_USER": "wp_mcp_reader",
        "WP_DB_PASSWORD": "your_password",
        "WP_DB_NAME": "your_wordpress_db"
      }
    }
  }
}
```

### Claude Code

```bash
claude mcp add wp-db \
  -e WP_DB_HOST=127.0.0.1 \
  -e WP_DB_USER=wp_mcp_reader \
  -e WP_DB_PASSWORD=secret \
  -e WP_DB_NAME=mysite \
  -- uvx --from wp-db-mcp wp-db-mcp
```

### Local by Flywheel (Socket Connection)

Local uses Unix sockets. Find your socket path in Local's site info (Database tab), then:

```json
{
  "mcpServers": {
    "wp-db": {
      "command": "uvx",
      "args": [
        "--from",
        "wp-db-mcp",
        "wp-db-mcp"
      ],
      "env": {
        "WP_DB_SOCKET": "/Users/you/Library/Application Support/Local/run/XXXXXXXX/mysql/mysqld.sock",
        "WP_DB_USER": "root",
        "WP_DB_PASSWORD": "root",
        "WP_DB_NAME": "local"
      }
    }
  }
}
```

To find your socket path in Local:

1. Open Local app
2. Select your site
3. Click "Database" tab
4. Look for "Socket" path

Note: Local's default `root` user has full privileges, not `SELECT`-only. That's fine for a
disposable local site, but the [startup grant audit](#startup-grant-audit) will warn about it —
that warning is correct, not a bug.

## Upgrading to 2.0

Version 2.0.0 is a breaking release. Before upgrading:

- **Tool surface shrank from 21 to 17 tools.** Several tools were merged into a single tool that
  takes a type discriminator argument. See the [tool mapping table](#renamed--merged-tools)
  below and update any saved prompts, scripts, or client configs that call the old names
  directly by name.
- **`wp_get_meta` (replacing the three old `*_get_*_meta` tools) now caps results at 100 rows by
  default**, up to the server's `WP_MAX_ROWS`. The tools it replaces ran unbounded
  `SELECT *` with no limit at all. If you rely on pulling every row of an ACF repeater field (or
  anything else with many meta rows per object) in one call, pass an explicit `limit`.
- **`wp_search_posts` and `wp_get_term_posts` now exclude revisions, trashed/auto-draft rows,
  and non-content post types by default.** If you were relying on those tools returning
  everything unfiltered, pass an explicit `post_type` and/or `post_status` to opt back in for
  that dimension (see [Available Tools](#available-tools)).
- **Sensitive-looking values are now redacted by default**, including in raw `wp_query` results.
  Set `WP_ALLOW_SENSITIVE=1` if you need the old unmasked behavior.
- **Review your database user's grant.** If it has more than `SELECT`, the server will now warn
  loudly at startup (or refuse to start with `WP_REQUIRE_READONLY_GRANTS=1`). See
  [Security](#security) for why this matters — the in-process validator was never a substitute
  for it.
- If you ran 1.0.0–1.1.1 against a database with a privileged account, see the
  [`CHANGELOG.md`](CHANGELOG.md) `[2.0.0]` Security section before upgrading.

### Renamed / merged tools

| Old tool | New call |
|---|---|
| `wp_get_post_meta(post_id=42)` | `wp_get_meta(object_type="post", object_id=42)` |
| `wp_get_user_meta(user_id=7)` | `wp_get_meta(object_type="user", object_id=7)` |
| `wp_get_comment_meta(comment_id=15)` | `wp_get_meta(object_type="comment", object_id=15)` |
| `wp_get_connected_users(post_id=42, name="speakers")` | `wp_get_user_connections(entity_type="post", entity_id=42, name="speakers")` |
| `wp_get_user_connected_posts(user_id=7, name="speakers")` | `wp_get_user_connections(entity_type="user", entity_id=7, name="speakers")` |
| `wp_get_shadow_related_posts(post_id=42, taxonomy="x", meta_key="y")` | `wp_get_shadow_relations(entity_type="post", entity_id=42, taxonomy="x", meta_key="y")` |
| `wp_get_shadow_source_post(term_id=99, meta_key="y")` | `wp_get_shadow_relations(entity_type="term", entity_id=99, meta_key="y")` |

`wp_get_connected_posts` and `wp_list_shadow_posts` / `wp_list_shadow_taxonomies` /
`wp_list_connected_posts` / `wp_list_connection_names` are unchanged by the rename.

## Available Tools

### Schema & Structure

#### wp_list_tables

List all tables with engine, row count, and size. Supports multisite `site_id` filter.

#### wp_describe_table

Show columns, types, keys, and indexes for a specific table. Accepts full name or core suffix (e.g. `posts` instead of `wp_posts`). `users`, `usermeta`, and multisite's network tables (`blogs`, `site`, `sitemeta`, `signups`, `registration_log`) always resolve against the base prefix, regardless of `site_id`.

#### wp_get_schema

Generate a complete database schema with all columns, indexes, and detected relationships. Outputs JSON or CSV. Toggle `include_plugins` to include non-core tables. Uses optimized batch queries. `users`/`usermeta` and network tables are included at the base prefix on every site.

### Relationships

#### wp_get_relationships

Map how WordPress tables relate to each other:

- `wp_posts` -> `wp_postmeta` (via `post_id`)
- `wp_posts` -> `wp_term_relationships` -> `wp_term_taxonomy` -> `wp_terms`
- `wp_posts` -> `wp_comments` (via `comment_post_ID`)
- `wp_users` -> `wp_usermeta` (via `user_id`)
- `wp_users` -> `wp_posts` (via `post_author`)
- `wp_terms` -> `wp_termmeta` (via `term_id`)
- Self-referential: post_parent, comment_parent, taxonomy parent

Auto-detects all multisite prefixes.

### Querying

#### wp_query

Execute raw SQL queries validated by `validate_select_only()` (a guardrail, not a security
boundary — see [Security](#security)). Blocked: INSERT, UPDATE, DELETE, DROP, system schemas,
and all other write/DDL operations. Row limit and timeout enforced. Returns `has_more` and
`redacted` indicators. Read-only is actually guaranteed by the database grant on the connecting
user, not by this validation.

#### wp_search_posts

Search posts by title or content using LIKE matching. **Defaults exclude** `post_type IN
('revision', 'nav_menu_item', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation',
'customize_changeset', 'oembed_cache', 'user_request')` and `post_status IN ('trash',
'auto-draft', 'inherit')`. Pass an explicit `post_type` and/or `post_status` to replace the
default for that dimension with an exact-match filter. Returns content preview (first 200 chars)
and a `redacted` indicator.

### Posts & Terms

#### wp_get_post_terms

Get all terms for a post, traversing the full relationship chain. Filter by taxonomy.

#### wp_get_term_posts

Get all posts for a term. Same default `post_type`/`post_status` exclusions as `wp_search_posts`
apply unless overridden. Returns `has_more` indicator.

#### wp_list_taxonomies

List all taxonomies registered in the database with term counts and total usage.

### Meta Data

#### wp_get_meta

Get all meta key-value pairs for a post, user, or comment. Takes `object_type`
(`"post"` | `"user"` | `"comment"`) and `object_id`, and routes to `wp_postmeta`, `wp_usermeta`,
or `wp_commentmeta` accordingly. Filter by `meta_key` (exact or LIKE pattern). Results are capped
at `limit` (default 100, hard ceiling `WP_MAX_ROWS`) — the tools this replaces ran unbounded
`SELECT *`, so a large ACF repeater or similar can now be truncated; pass an explicit `limit` if
you need more. `site_id` is ignored for `object_type="user"` since `wp_usermeta` is shared
network-wide in multisite.

### Post Relationships (WP Content Connect)

These tools query relationships created by the [WP Content Connect](https://github.com/10up/wp-content-connect) library.

#### wp_list_connection_names

Discover all relationship names registered in WP Content Connect. Queries both `post_to_post` and `post_to_user` tables to list distinct relationship names with their connection counts. Useful for exploring what relationships exist before querying specific connections.

#### wp_get_connected_posts

Get posts connected to a post via the `post_to_post` table. Supports filtering by relationship
name and direction (`from`, `to`, or `any`). The `direction` semantics are a raw read of which
physical column (`id1` vs `id2`) `post_id` sits in — this is not guaranteed to match the
registered relationship direction in WP Content Connect, especially for reciprocal
relationships. Prefer `any` (the default) unless you have independently confirmed which column
corresponds to which side.

#### wp_get_user_connections

Query the `post_to_user` relationship table in either lookup direction, via `entity_type`
(`"post"` | `"user"`) and `entity_id`. `entity_type="post"` returns the users connected to that
post; `entity_type="user"` returns the posts connected to that user. Replaces the old
`wp_get_connected_users` and `wp_get_user_connected_posts`.

#### wp_list_connected_posts

List all post connections for a given relationship name. Returns all connection pairs with both posts' details (ID, title, type) and the relationship order.

### Shadow Taxonomy Relationships

These tools support the "shadow taxonomy" pattern where posts are related through taxonomy terms that store the source post ID in term meta.

#### wp_list_shadow_taxonomies

Discover **candidate** shadow taxonomies — this is heuristic, not verified. It finds
`(taxonomy, meta_key)` pairs where at least one term carries a purely numeric meta value, which
is only the necessary shape for the shadow-taxonomy pattern, not proof of it (ordinary numeric
term meta, e.g. a `thumbnail_id` or a display `order`, will false-positive here too). Each
candidate reports two confidence signals:

- `numeric_meta_ratio`: fraction of all terms in that taxonomy that carry a numeric value for
  that `meta_key`. Close to `1.0` for a real shadow taxonomy (the meta is on every term); low
  for an incidental numeric field used on a handful of terms.
- `post_id_resolution_ratio`: of the numeric-valued terms, the fraction whose value actually
  resolves to an existing row in the posts table. Close to `1.0` for a real shadow taxonomy;
  close to `0.0` for incidental numeric meta (prices, quantities, non-post foreign keys) that
  happens to be numeric but isn't a post ID.

These ratios are reported, not filtered on — low-confidence rows are still returned. Treat every
row as a hypothesis to check with `wp_get_shadow_relations` or a manual `wp_query`, not a fact.

#### wp_get_shadow_relations

Traverse a shadow taxonomy relationship in either direction, via `entity_type`
(`"post"` | `"term"`):

- `entity_type="post"`: given a source post ID and `taxonomy` (required), finds all terms where
  the term meta matches the post ID, then returns all posts assigned to those terms.
- `entity_type="term"`: given a shadow term ID (reverse lookup), finds the single source post
  whose ID is stored in the term's meta. `taxonomy` is ignored in this direction.

Replaces the old `wp_get_shadow_related_posts` and `wp_get_shadow_source_post`. The response
shape flips with `entity_type` — see the tool's own docstring for the exact JSON shape.

#### wp_list_shadow_posts

List all posts using a shadow taxonomy relationship. Returns all posts assigned to shadow terms, with term info (ID, name) and source post details (ID, title, type).

## Usage Examples

Once configured, just ask questions in natural language. The AI will automatically use the appropriate tools.

### Explore the schema

- "What tables are in my WordPress database?"
- "Show me the structure of the posts table"
- "What are all the relationships between WordPress tables?"
- "Generate the full database schema"

### Query content

- "How many published posts do I have?"
- "Search for posts containing 'hello'"
- "What taxonomies are registered?"
- "List all categories and their post counts"

### Inspect specific data

- "Get all terms for post ID 1"
- "Show me the meta data for user ID 1"
- "What posts are in the 'uncategorized' category?"
- "Get all comments for post ID 5"

### Run custom queries

- "Run this query: SELECT post_title, post_date FROM wp_posts WHERE post_status = 'publish' ORDER BY post_date DESC LIMIT 10"
- "Show me the 5 most recent users"
- "Count posts by post type"

### Content connections (WP Content Connect)

For sites using the [WP Content Connect](https://github.com/10up/wp-content-connect) library:

- "What Content Connect relationships are defined in the database?"
- "List all connection types/relationship names"
- "What posts are connected to post ID 42?"
- "Find all posts connected to post 15 via the 'related_articles' relationship"
- "Show posts connected FROM post 100 (where it's the source)"
- "Show posts connected TO post 100 (where it's the target)"
- "What users are connected to post ID 50?"
- "Find all speakers connected to this event post"
- "What posts is user ID 5 connected to?"
- "Show all events that user 12 is associated with"
- "List all posts using the 'related_articles' relationship"
- "Show me all connections for the 'speakers' relationship"

### Shadow taxonomy relationships

For sites using shadow taxonomies (where posts are related through taxonomy terms that store post IDs in term meta):

- "What shadow taxonomies exist in the database?"
- "Discover all shadow taxonomy relationships"
- "Find all sessions related to speaker post ID 25 using the 'speaker_shadow' taxonomy"
- "What posts are related to post 100 via shadow taxonomy 'event_shadow' with meta key 'shadow_post_id'?"
- "Get the source post for shadow term ID 150"
- "Which speaker post does term ID 42 represent?"
- "List all posts using the 'speaker_shadow' taxonomy with meta key 'shadow_post_id'"
- "Show me all relationships in the 'event_shadow' shadow taxonomy"

## Multisite

For multisite installations, pass `site_id` to any tool:

- `site_id=None` or `site_id=1` -> main site (uses base prefix, e.g. `wp_`)
- `site_id=2` -> sub-site 2 (uses `wp_2_`)
- `site_id=3` -> sub-site 3 (uses `wp_3_`)

The `wp_get_relationships` tool auto-detects all multisite prefixes.

`wp_users`, `wp_usermeta`, and the network-level tables (`wp_blogs`, `wp_site`, `wp_sitemeta`,
`wp_signups`, `wp_registration_log`) are shared network-wide and always resolve against the base
prefix, regardless of `site_id` — this is now consistent across every tool that touches them
(`wp_describe_table`, `wp_get_schema`, `wp_get_relationships`, `wp_get_meta`).

## Extending for Plugins

Set `include_plugins=true` in `wp_get_schema` to include all tables beyond WordPress core.

For WooCommerce, the schema will include tables like:

- `wp_wc_orders`, `wp_wc_order_product_lookup`
- `wp_woocommerce_*` tables

Use `wp_query` to explore any table directly.

## Smoke testing

Unit tests mock the database driver, so they cannot prove behavior that only appears against a
real MySQL/MariaDB server — most importantly, that the server-side statement timeout uses the
right syntax for each flavor (MySQL's `MAX_EXECUTION_TIME` errors on MariaDB). `tests/smoke/`
is a standalone harness that boots the real server against a real, fully fabricated database and
exercises every tool.

Quick start (see `tests/smoke/README.md` for the full command reference):

```bash
cd tests/smoke
docker compose up -d --wait
uv run python run_smoke.py --port 13306 --flavor-label mysql8
uv run python run_smoke.py --port 13307 --flavor-label mariadb11
docker compose down -v
```

Every value in `tests/smoke/seed.sql` is fabricated (`ACME CORP`, `example.test`, round numbers,
2020 dates) — no real site content ever belongs in that file.

## License

MIT
