pREST v2.5 Adds MySQL: A REST API and MCP Server for Your MySQL Database

pREST now speaks MySQL. Version 2.5 serves any MySQL 8.0.19+ database as a REST API. Set engine = "mysql" and point it at your database.

Every table gets CRUD, filters, paging, joins and JWT auth, and AI agents can read it over MCP. Install v2.5.1, the current release.

pREST v2 series: Architecture (rc6) → v2.0.0 / v2.1.0 GA → MCP tutorial → AI plugins → v2.2.0 → v2.3.0 → v2.4.0 → v2.4.1 + v2.4.2 → This post (v2.5: MySQL)

New to pREST? The pREST v2 guide lists every release and where to start.

Table of Contents

What you get

Point pREST at an existing MySQL database. You write no code and run no migrations.

  • GET, POST, PATCH and DELETE on every table.
  • Filters in the query string: ?qty=$gt.0, ?id=$in.1,3.
  • Paging, sorting, column selection, counts and joins.
  • JWT auth and a per-column allowlist in one TOML file.
  • An MCP endpoint, so AI agents can read your data.
  • Custom SQL endpoints from .sql files.

It fits internal tools, admin panels and quick APIs over a database you already have. It doesn’t support MariaDB or TiDB.

Quickstart

You need Docker and curl. Start MySQL first:

docker network create prest-demo

docker run -d --name mysql --network prest-demo \
  -e MYSQL_ROOT_PASSWORD=rootpw -e MYSQL_DATABASE=shop \
  -e MYSQL_USER=prest -e MYSQL_PASSWORD=prest \
  mysql:8.4

# wait until MySQL accepts connections
until docker exec mysql mysqladmin ping -uroot -prootpw --silent; do sleep 2; done

Add a table with a few rows:

docker exec -i mysql mysql -uprest -pprest shop <<'SQL'
CREATE TABLE items (
  id         BIGINT AUTO_INCREMENT PRIMARY KEY,
  name       VARCHAR(100) NOT NULL,
  qty        INT NOT NULL DEFAULT 0,
  price      DECIMAL(10,2),
  meta       JSON,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO items (name, qty, price, meta) VALUES
  ('hat',    2, 19.90, JSON_OBJECT('kind', 'apparel')),
  ('mug',   10,  8.50, JSON_OBJECT('kind', 'kitchen')),
  ('scarf',  0, 24.00, JSON_OBJECT('kind', 'apparel'));
SQL

Start pREST and call it:

docker run -d --name prest --network prest-demo -p 3000:3000 \
  -e PREST_ENGINE=mysql \
  -e PREST_DB_URL='mysql://prest:prest@mysql:3306/shop' \
  -e PREST_JWT_DEFAULT=false \
  prest/prest:v2.5.1

curl -s localhost:3000/_health
# {"adapter":"mysql"}

curl -s localhost:3000/shop/shop/items
# [{"created_at":"2026-10-09T02:36:16","id":1,"meta":{"kind":"apparel"},"name":"hat","price":"19.90","qty":2},
#  {"created_at":"2026-10-09T02:36:16","id":2,"meta":{"kind":"kitchen"},"name":"mug","price":"8.50","qty":10},
#  {"created_at":"2026-10-09T02:36:16","id":3,"meta":{"kind":"apparel"},"name":"scarf","price":"24.00","qty":0}]

The first start takes about 15 seconds. The path is /{connection}/{database}/{table}, and here both names are shop. PREST_JWT_DEFAULT=false turns auth off for the demo; we turn it on below. The image runs on Intel and Apple Silicon.

Query and write

Reads use query-string filters:

# items in stock
curl -s 'localhost:3000/shop/shop/items?qty=$gt.0&_select=id,name,qty'
# [{"id":1,"name":"hat","qty":2},{"id":2,"name":"mug","qty":10}]

# pick rows by id
curl -s 'localhost:3000/shop/shop/items?id=$in.1,3&_select=id,name'
# [{"id":1,"name":"hat"},{"id":3,"name":"scarf"}]

# case-insensitive match
curl -s 'localhost:3000/shop/shop/items?name=$ilike.HAT&_select=id,name'
# [{"id":1,"name":"hat"}]

# sort and page
curl -s 'localhost:3000/shop/shop/items?_order=-price&_page=1&_page_size=2&_select=name,price'
# [{"name":"scarf","price":"24.00"},{"name":"hat","price":"19.90"}]

# count
curl -s 'localhost:3000/shop/shop/items?_count=*'
# [{"count":3}]

# filter on a JSON field
curl -s 'localhost:3000/shop/shop/items?meta->>kind:jsonb=apparel&_select=id,name'
# [{"id":1,"name":"hat"},{"id":3,"name":"scarf"}]

Writes take JSON bodies:

# insert one row
curl -s -X POST localhost:3000/shop/shop/items -H 'Content-Type: application/json' \
  -d '{"name":"socks","qty":5,"price":6.00,"meta":{"kind":"apparel"}}'
# {"created_at":"2026-10-09T02:36:46","id":4,"meta":{"kind":"apparel"},"name":"socks","price":"6.00","qty":5}

# update
curl -s -X PATCH 'localhost:3000/shop/shop/items?name=socks' \
  -H 'Content-Type: application/json' -d '{"qty":7}'
# {"rows_affected":1}

# update and return the row
curl -s -X PATCH 'localhost:3000/shop/shop/items?name=socks&_returning=*' \
  -H 'Content-Type: application/json' -d '{"qty":8}'
# [{"created_at":"2026-10-09T02:36:46","id":4,"meta":{"kind":"apparel"},"name":"socks","price":"6.00","qty":8}]

# delete
curl -s -X DELETE 'localhost:3000/shop/shop/items?name=socks'
# {"rows_affected":1}

# insert many rows at once
curl -s -X POST localhost:3000/batch/shop/shop/items -H 'Content-Type: application/json' \
  -d '[{"name":"cap","qty":1},{"name":"bowl","qty":3,"price":12.00}]'
# [{"created_at":"2026-10-09T02:36:46","id":5,"meta":null,"name":"cap","price":null,"qty":1},
#  {"created_at":"2026-10-09T02:36:46","id":6,"meta":null,"name":"bowl","price":"12.00","qty":3}]

$ilike matched HAT because MySQL collations ignore case by default. POST returns the full row with its new id. PATCH returns a count unless you add _returning=*. Batch records can have different fields, and missing ones get their defaults.

Auth and permissions

Turn on login and limit what each table exposes. One file does both. Save this as prest.toml:

engine = "mysql"

[pg]
url = "mysql://prest:prest@mysql:3306/shop"

[auth]
enabled = true

[jwt]
key = "replace-with-a-random-secret-of-at-least-32-bytes"
algo = "HS256"

[access]
restrict = true

  [[access.tables]]
  name = "items"
  permissions = ["read"]
  fields = ["id", "name", "qty"]

Restart pREST with it, add a user, and log in:

docker rm -f prest
docker run -d --name prest --network prest-demo -p 3000:3000 \
  -v "$PWD/prest.toml:/app/prest.toml:ro" prest/prest:v2.5.1

# pREST creates the prest_users table; add a user with a bcrypt hash
HASH=$(docker run --rm httpd:2.4-alpine htpasswd -bnBC 10 "" s3cret | tr -d ':\n')
docker exec -i mysql mysql -uprest -pprest shop \
  -e "INSERT INTO prest_users (name, username, password) VALUES ('Ada', 'ada', '$HASH')"

TOKEN=$(curl -s -X POST localhost:3000/auth -H 'Content-Type: application/json' \
  -d '{"username":"ada","password":"s3cret"}' | jq -r .token)

curl -s localhost:3000/shop/shop/items -H "Authorization: Bearer $TOKEN"
# [{"id":1,"name":"hat","qty":2},{"id":2,"name":"mug","qty":10},{"id":3,"name":"scarf","qty":0},
#  {"id":5,"name":"cap","qty":1},{"id":6,"name":"bowl","qty":3}]

curl -s 'localhost:3000/shop/shop/items?_select=price' -H "Authorization: Bearer $TOKEN"
# {"error":"you don't have permission for this action, please check the permitted fields for this table"}

curl -s -X POST localhost:3000/shop/shop/items -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"name":"x"}'
# {"error": "authorization required"}

Reads return only id, name and qty. Asking for price fails, and so does any write. In production, give pREST its own MySQL user and grant it one database, not root.

AI agents over MCP

The same server answers MCP at /_mcp, and agents get read-only tools. With the token from above:

curl -s -X POST localhost:3000/_mcp -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -r '.result.tools[].name'
# prest.describe_table
# prest.list_databases
# prest.list_schemas
# prest.list_tables
# prest.select.shop.shop.items
# prest.select_table

curl -s -X POST localhost:3000/_mcp -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"prest.select_table","arguments":{"database":"shop","schema":"shop","table":"items","filters":{"name":"hat"},"limit":5}}}'
# {"jsonrpc":"2.0","id":2,"result":{"database":"shop","schema":"shop","table":"items",
#  "columns":["id","name","qty"],"rows":[{"id":1,"name":"hat","qty":2}],"count":1}}

The agent sees the same columns as REST, so price stays hidden. Asking for it returns unsupported column: price. Without a token, /_mcp answers 401. To connect Cursor or Claude, follow the MCP tutorial.

MySQL and Postgres together

One pREST can serve both engines. Give each database an alias:

[[databases]]
alias = "app"
url = "postgres://postgres:postgres@pg:5432/app?sslmode=disable"

[[databases]]
alias = "shop"
engine = "mysql"
url = "mysql://prest:prest@mysql:3306/shop"

The first path segment picks the database:

curl -s localhost:3000/app/public/users
# [{"id": 1, "email": "ada@example.com"}, {"id": 2, "email": "linus@example.com"}]

curl -s 'localhost:3000/shop/shop/items?_select=id,name&_page=1&_page_size=2'
# [{"id":1,"name":"hat"},{"id":2,"name":"mug"}]

Auth and permissions work the same on both. Each MySQL alias only serves the database in its URL. It helps during a migration, or when one API sits over an old app and a new one.

What’s different on MySQL

Most requests work the same. These don’t:

Postgres MySQL
PATCH / DELETE Returns rows Returns a count; add _returning=* for rows
$ilike Always ignores case Follows the column collation
$all, ltree, $tsquery, vectors Supported unsupported operator
FULL JOIN Supported Not available
DECIMAL Number String ("19.90")
DATETIME With time zone No time zone
{schema} in the URL A schema The MySQL database
_QUERIES SQL files $1, "name" ?, `name`

Full list: DIFFERENCES.md.

Versions and limits

  • MySQL 8.0.19 or newer. CI tests 8.0, 8.4 and 26.7.
  • MariaDB, TiDB and Aurora MySQL aren’t supported.
  • TLS modes are disable, require and skip-verify. Client certificates aren’t supported yet.
  • Set [mysql] prepare = true if you want server-side prepared statements.

Also in v2.5

  • _join now works with access.restrict = true, and joined tables return their allowed columns. Thanks to Roshan931 for the first fix (#364).
  • Middleware plugins load again (#948), and the Docker image builds them on start.
  • Upgrading from v2.4? Postgres configs work as before. Pull prest/prest:v2.5.1.

FAQ

How do I turn a MySQL database into a REST API without code?

Run pREST v2.5.1 with engine = "mysql" and your database URL. Every table gets REST endpoints with filters and paging. The quickstart above takes about five minutes.

Which MySQL versions does pREST support?

MySQL 8.0.19 and newer. CI tests 8.0, 8.4 and 26.7.

Does pREST work with MariaDB or Aurora MySQL?

No. This adapter targets MySQL 8 only. MariaDB, TiDB and Aurora MySQL aren’t supported.

Can one pREST serve MySQL and PostgreSQL?

Yes. Add each database under [[databases]] with an alias. Set engine = "mysql" on the MySQL ones.

Can AI agents query MySQL through pREST?

Yes, through the MCP endpoint at /_mcp. Agents get read-only tools. They see only the columns your permissions allow.

Is pREST free?

Yes. It’s MIT licensed, and the Docker images are free.

Release notes: v2.5.1. Code: github.com/prest/prest.

Using Postgres instead? See pREST in 10 minutes. Something broke on your MySQL? Open an issue with your version and the request.

Also from Insurgency Labs

Building internal tools on MySQL? DeliveryCompass shows PR cycle time and review patterns from GitHub, with read-only access. See the DeliveryCompass overview. It’s free during Early Access. It doesn’t replace Jira or measure deploy-based DORA.