Skip to main content
Glama
Khushboo-Mishra

mysql-mcp-demo

README.md
# mysql-mcp-demo

A small, heavily-commented **MCP server for MySQL** that demonstrates all three
Model Context Protocol primitives — **tools**, **resources**, and **prompts** —
in about 1,100 lines of Python.

This repository exists to be **read**, not just run. It is the companion to a
workshop on building MCP servers, and every file is written as teaching
material: one primitive per file, comments that explain *why* rather than
*what*, and a demo database with deliberate flaws so the examples find something
real.

```
mcp_server/
├── database.py    read-only introspection — the only file not about MCP
├── execution.py   running queries and writes, plus every safety control
├── tools.py       6 TOOLS   — inspect structure (cannot read or change a row)
├── data_tools.py  6 TOOLS   — read rows, and INSERT / UPDATE / DELETE / ALTER
├── resources.py   4 RESOURCES + 2 templates — content the APPLICATION attaches
├── prompts.py     6 PROMPTS — workflows the USER invokes
└── server.py      wires them together (about 10 meaningful lines)
```

The server is **read-write**: it answers questions about the data by running
real queries, and it can change data and schema. It is locked to a single
throwaway demo database, and the controls that make that safe are in
`execution.py` and explained below — that design is itself part of the lesson.

---

## The one idea worth taking away

Most MCP tutorials only cover tools, which leaves people thinking MCP *is*
tools. It is three primitives, and they differ by **who is in control**:

| Primitive | Who decides | When it happens | Analogy |
|---|---|---|---|
| **Tool** | the **model** | mid-conversation, autonomously | a function the model may call |
| **Resource** | the **application** | up front, chosen by a human | a file you attach |
| **Prompt** | the **user** | explicitly, from a menu | a saved expert question |

Same data can appear as more than one. In this repo `get_table_ddl` is a tool
*and* `schema://table/{name}/ddl` is a resource — the same bytes, reached two
ways, because "the model fetches it when it needs it" and "the human attaches it
before starting" are genuinely different needs.

---

## Quick start

```bash
git clone https://github.com/Khushboo-Mishra/mysql-mcp-demo.git
cd mysql-mcp-demo
bash scripts/setup.sh
```

`setup.sh` checks prerequisites, creates the virtualenv, installs the two
dependencies, creates the demo database, and verifies the server end to end. It
stops with a specific message at the first thing that is missing.

Then see all three primitives in one pass:

```bash
bash scripts/run_explorer.sh
```

### Requirements

- **Python 3.10+**
- **MySQL 8.x** running locally (`brew services start mysql`)
- **Node.js** — optional, only for the MCP Inspector

Defaults to `root` on `127.0.0.1:3306` with no password — the Homebrew default,
so most people change nothing. Otherwise export `MYSQL_USER`, `MYSQL_PASSWORD`,
`MYSQL_HOST`, `MYSQL_PORT`.

---

## What gets built

**12 tools, 4 resources + 2 URI templates, and 6 prompts**, over a six-table
demo database.

### Tools — the model calls these

Split across two files **by blast radius**, not by subsystem. That is a
deliberate design choice worth copying: it keeps the risky surface small and
obvious to anyone reviewing the server or writing its database GRANT.

`tools.py` — inspect structure. Cannot read a row, cannot change anything.

| Tool | Purpose |
|---|---|
| `list_tables` | every table and view, with row estimates |
| `describe_table(table)` | columns, types, keys, indexes, foreign keys |
| `get_table_ddl(table)` | the exact `CREATE TABLE` |
| `list_relationships` | every declared foreign key |
| `find_sensitive_columns` | columns whose *name* suggests PII or secrets |
| `search_columns(keyword)` | find a column when you forget which table it is in |

`data_tools.py` — read rows and change data. This is the half with consequences.

| Tool | Purpose |
|---|---|
| `run_query(sql, limit)` | run a SELECT and get the rows — this is what answers data questions |
| `execute_statement(sql)` | INSERT / UPDATE / DELETE / CREATE / ALTER / DROP / TRUNCATE |
| `insert_row(table, values)` | structured insert, values sent as bound parameters |
| `update_rows(table, changes, where)` | structured update, `where` required |
| `delete_rows(table, where)` | structured delete, `where` required |
| `show_audit_log(limit)` | every statement the server has executed |

**Why both a general `execute_statement` and structured wrappers?** Structured
tools are safer — arguments are typed and values are bound, so the model never
writes SQL text and cannot produce something malformed. But they only do what
you anticipated. A general SQL door handles the long tail: window functions,
an `ALTER` you did not foresee. Real servers ship both, and the walkthrough
should say why.

### Resources — the application attaches these

| URI | Type | Contents |
|---|---|---|
| `schema://tables` | JSON | table inventory |
| `schema://ddl` | SQL | DDL for the whole schema |
| `schema://relationships` | JSON | all foreign keys |
| `schema://overview` | Markdown | human-readable summary |
| `schema://table/{name}` | JSON | one table — **templated** |
| `schema://table/{name}/ddl` | SQL | one table's DDL — **templated** |

A **static** resource has a fixed URI and appears in `resources/list`, so a
client can show it in a picker. A **templated** resource has `{placeholders}`
and appears in `resources/templates/list` instead — there is no fixed list, so
the client fills in the blank.

### Prompts — the user invokes these

| Prompt | Arguments | What it does |
|---|---|---|
| `audit_schema` | — | five-step health check: keys, relationships, PII, naming |
| `explain_table` | `table` | explains one table in plain language |
| `ask_data` | `question` | writes the query, **runs it**, and answers in plain language |
| `modify_data` | `request` | preview → confirm → apply → verify, for changes |
| `document_schema` | — | generates reference documentation |
| `onboarding_tour` | `role` | a guided first look, tailored to a role |

---

## Deciding: tool, resource, or prompt?

The question people get stuck on. Work through it in this order.

**1. Does it perform an action, or fetch something the model chooses?**
→ **Tool.** Anything the model should be able to decide to do on its own.

**2. Is it a document a human would sensibly attach before starting?**
→ **Resource.** Reference material, whole-schema context, anything stable.

**3. Is it a task someone repeats, where the *way you ask* is the expertise?**
→ **Prompt.** Ship the good question instead of expecting rediscovery.

Two heuristics that resolve most remaining doubt:

> **Who initiates?** Model → tool. Application → resource. User → prompt.

> **Would you want this in a menu?** If yes, it is a prompt. Menus are for
> people, and only prompts are surfaced to people as commands.

### Worked examples from this repo

| Feature | Choice | Why |
|---|---|---|
| Fetch one table's structure | **tool** | the model needs it mid-reasoning, unpredictably |
| Whole-schema DDL | **both** | tool for the model; resource for a human to attach up front |
| Schema audit | **prompt** | a repeatable task where knowing *what to ask* is the value |
| Search for a column | **tool** | takes an argument the model chooses at call time |
| Markdown overview | **resource** | passive reference, no decision required |

### Where people get it wrong

- **Everything as tools.** Works, but the model burns calls fetching context a
  human could have attached once — and users get no discoverable entry points.
- **Resources for things that need arguments the model picks.** If the model
  decides the parameter, it is a tool.
- **Prompts that do work.** A prompt returns *text*. If you find yourself
  querying the database inside a prompt, you wanted a tool.

---

## The demo database

`mcp_demo`, six tables, deliberately imperfect so the examples find real
problems:

| Table | Deliberate flaw |
|---|---|
| `CUSTOMERS` | `EMAIL`, `PHONE` — the sensitive-column scan fires |
| `PRODUCTS` | `SKU` is `UNIQUE` but not the PK — a natural key worth discussing |
| `ORDERS` | (clean — the reference example) |
| `ORDER_ITEMS` | `PRODUCT_ID` looks like a foreign key but **has no constraint** |
| `AUDIT_LOG` | **no primary key at all** |
| `legacy_notes` | `snake_case` while everything else is `UPPER_CASE` |

Run `audit_schema` against it and every one of those should surface. That is the
demo: the tools find genuine problems, not toy ones.

---

## Running it

### The explorer — every primitive in one pass

```bash
bash scripts/run_explorer.sh
```

Prints the `initialize` handshake, then lists and exercises tools, resources
(static *and* templated), and prompts. Best first thing to run, and the clearest
thing to show on a terminal during a talk.

### The MCP Inspector — Anthropic's own client

```bash
bash scripts/run_inspector.sh
```

Open the printed `http://localhost:6274?...` URL — the token is required. It has
separate **Tools**, **Resources**, and **Prompts** tabs, which is the most
convincing way to show all three: none of it is our code, so if the Inspector
drives the server, the server is genuinely spec-compliant.

Suggested tour: **Tools** → `describe_table` with `ORDERS`; **Resources** →
`schema://overview`; **Prompts** → `audit_schema`.

### Claude Desktop / Claude Code

```bash
bash scripts/install_claude.sh              # Claude Code
bash scripts/install_claude.sh --desktop    # also Claude Desktop
```

Then ask: *"Audit this database"* — or use the `audit_schema` prompt from the
menu, which is where prompts finally become visible.

> **`--desktop` must be run from Terminal.app, not from inside Claude Desktop.**
> Claude Desktop holds its config in memory and rewrites the file from that
> copy, so an edit made while it is running is silently discarded. The script
> quits the app, edits, and relaunches — which would kill the session you
> launched it from.

---

## Code walkthrough order

For presenting, this order builds up cleanly:

1. **`server.py`** — 10 lines. The whole architecture in one screen.
2. **`database.py`** — plain MySQL, no MCP. Establishes that MCP is a thin layer
   over code you already have. Stop on `safe_identifier` and explain why table
   names cannot be bound parameters.
3. **`tools.py`** — the decorator, and how the docstring *is* the prompt the
   model reads.
4. **`resources.py`** — static vs templated URIs, and why `get_table_ddl` is
   deliberately duplicated as a resource.
5. **`prompts.py`** — that a prompt returns text, and that the text tells the
   model which tools to use.
6. **`examples/explore_server.py`** — the client side, showing what actually
   crosses the wire.

---

## Going further

This server is scoped to one database to keep the examples short. To take it
further:

- **Multiple schemas** — take `schema` as a tool argument rather than reading
  `MYSQL_DEMO_SCHEMA`. Add an allowlist so an agent cannot reach production.
- **Query execution** — a `run_query` tool. Doable, but it changes the security
  story completely: the server then needs credentials that read your tables, and
  results enter the model's context. Enforce `SELECT`-only, inject a `LIMIT`, and
  use a read-only database user.
- **Remote transport** — `mcp.run(transport="streamable-http")`. Same tools,
  same code, different pipe. Add authentication before exposing it.
- **Caching** — `describe_table` hits the database on every call. A short TTL
  cache is worth it once a model starts calling it in a loop.

---

## Security notes

This server **can change your data**. That is a deliberate choice for a
workshop — showing how to build write capability *safely* is more useful than
pretending the question never comes up — but it means the controls matter.

### The five controls, all in `execution.py`

| Control | What it stops |
|---|---|
| **Schema lock** | every statement runs on a connection pinned to the demo database; a reference to any other database is refused |
| **One statement per call** | a second statement cannot ride along on a legitimate one |
| **Separate read/write doors** | `run_query` refuses to write and `execute_statement` refuses to read, so neither can be talked into the other's job |
| **Row cap** | a broad `SELECT` cannot flood the model's context |
| **Audit log** | every statement is recorded and readable via `show_audit_log` |

A denylist also refuses statements that would escape the schema lock, reach the
filesystem, or change server-wide state — privilege changes, user management,
file import/export, and database-level operations.

One subtlety worth showing in a walkthrough: the schema lock cannot work by
pattern alone, because `a.b` in SQL is usually `alias.column` (`SELECT c.NAME
FROM CUSTOMERS c`), not `schema.table`. Rejecting every dotted name breaks
ordinary joins — which is exactly the bug the first version had. So it compares
each qualifier against the **actual list of databases on the server**: a real
database name is refused, a table alias passes untouched.

### Point it at a restricted user

The controls above are defense in depth, **not** the defense. In anything beyond
a demo, connect as a MySQL user whose grant covers only the schema you intend to
expose. If the credentials cannot reach production, neither can a
prompt-injection or a model mistake.

### Two more things worth stating plainly:

- **Table names cannot be bound parameters.** `SHOW CREATE TABLE %s` is not
  valid SQL, so identifiers must be interpolated — a genuine injection sink.
  `database.safe_identifier` is what makes it safe, and it is the single most
  important function in the project.
- **The connecting MySQL user is the real boundary.** Give it a read-only
  `GRANT` scoped to the schemas you mean to expose. The code's read-only-ness is
  defense in depth, not the defense.

---

## License

MIT — see [LICENSE](LICENSE).