{"slug": "access-mysql-over-mcp-with-vsql-mcp", "title": "Access MySQL over MCP with vsql-mcp", "summary": "VillageSQL released vsql-mcp, an extension that runs a Model Context Protocol server inside the MySQL database server process rather than as a separate sidecar, serving MCP over Streamable HTTP from a background worker. The extension ships with six tools — list_schemas, list_tables, describe_table, query, explain, and write — plus two MCP resources, a schema overview and a table's CREATE TABLE statement; the write tool stays disabled and invisible to agents until an administrator enables it. Configuration is server state set via SET GLOBAL and read via SHOW VARIABLES, including vsql_mcp.db_url, vsql_mcp.schema, require_auth with a bearer token, and vsql_mcp.vsql_mcp_enabled; the extension is not bundled with the server and requires a Rust toolchain of 1.87 or newer to build with cargo vsql package.", "body_md": "[VillageSQL](https://villagesql.com/blog/tag/villagesql/)\n\n# Access MySQL over MCP with vsql-mcp\n\nThe Model Context Protocol (MCP) has become a standard way for AI agents to discover the tools an application, database, or service offers. Claude Code, Codex, Cursor, Antigravity, et al. can connect to an MCP server, ask it what tools are available, and call these tools instead of figuring out what they can or can't do on the fly or piecing together API calls. Setting up an MCP server for a database usually means running a separate process that holds a database credential and turns tool calls into queries. Then you have another process to deploy, another place a credential lives, and a set of guardrails that sit outside the database.\n\nThe [vsql-mcp](https://github.com/villagesql/vsql-mcp) extension from VillageSQL takes a different approach. It puts the MCP server inside the database. Install it, set a handful of global variables, and your MySQL server itself serves MCP over Streamable HTTP from a background worker in the database server process. There's no sidecar to run, and the rules about what an agent can see and do are server state: you set them with `SET GLOBAL`, read them with `SHOW VARIABLES`, and watch them work with `SHOW STATUS`.\n\nVillageSQL is the innovation platform for MySQL that adds an extension framework (similar to PostgreSQL's extension framework) to enable permissionless innovation. Instead of waiting for a feature to be implemented in a few years in a future version of MySQL, new functionality can be dynamically added to a version of MySQL you run today.\n\nThe extension comes packaged with a set of six tools. `list_schemas`, `list_tables`, and `describe_table` browse what you expose. `query` runs one read-only `SELECT`, and `explain` returns its plan. `write` is the sixth, and it stays off until you turn it on; until then an agent cannot see that it exists. Two MCP resources come with the tools: a schema overview and a table's `CREATE TABLE`. Guidance on adding your own tools is at the bottom of this post.\n\nThe rest of this post walks through using the extension end to end.\n\n## **If you would rather stop reading here and have your AI agent demonstrate this for you, open this dropdown and copy the prompt into your preferred AI coding tool.**\n\n```\nSet up a working demo of the vsql-mcp extension for VillageSQL, which serves\nthe Model Context Protocol from inside the database server. Work only against\na local throwaway server, and if the only VillageSQL or MySQL server you find\nlooks like something I depend on, stop and ask me before touching it.\n\nDo all of this yourself, and show me the real output of each step:\n\n1. Find a VillageSQL server, or install one. The install script needs a\n   codebase and a method:\n   `curl -fsSL https://install.villagesql.com | VSQL_CODEBASE=mysql-8.4 INSTALL_METHOD=prebuilt bash`.\n   Start it with `--vsql_allow_preview_extensions=ON`; on a server already\n   running, `SET PERSIST vsql_allow_preview_extensions=ON` takes effect at\n   once. Confirm with `SELECT VERSION()` before continuing.\n\n2. Build the extension. It is not bundled with the server yet. Install the\n   tooling with `cargo install cargo-vsql`, clone\n   https://github.com/villagesql/vsql-mcp, run `cargo vsql package` in it,\n   and copy `dist/vsql_mcp.veb` into the directory\n   `SHOW VARIABLES LIKE 'veb_dir'` names. You need a Rust toolchain, 1.87 or\n   newer. The SDK comes from crates.io.\n\n3. Create a small demo schema with two related tables and a few dozen rows,\n   then run `INSTALL EXTENSION vsql_mcp;` and show me the settings that\n   appeared under `SHOW GLOBAL VARIABLES LIKE 'vsql_mcp%'`.\n\n4. Configure it the least-privilege way: a dedicated database account with\n   SELECT on the demo schema only, `vsql_mcp.db_url` pointing at that\n   account, `vsql_mcp.schema` set to the demo schema, `require_auth` ON with\n   a random bearer token, and a free port. Turn `vsql_mcp.vsql_mcp_enabled`\n   ON and prove it is listening with `SELECT vsql_mcp.info();`.\n\n5. Register the endpoint with yourself (for Claude Code:\n   `claude mcp add --transport http vsql http://127.0.0.1:PORT/mcp --header\n   \"Authorization: Bearer TOKEN\"`), then answer a question about the demo\n   data using only the MCP tools, and show me which tools you called.\n\n6. Try to break it. Send a request with no bearer token and show the 401.\n   Ask the query tool to run an UPDATE. Query a table in another schema.\n   Set `vsql_mcp.allowed_tables` to one table and show that the others\n   disappear from list_tables and refuse describe_table. Show me each\n   refusal message.\n\nThen give me a table of what you ran and what came back, tell me anything\nthat did not behave the way this asked, and drop every user, schema, and\nsetting you created, remove the MCP registration, and leave my machine the\nway you found it.\n```\n\n## Build it\n\n`vsql-mcp` is written in Rust and uses \"preview capabilities\" of the VillageSQL Extension Framework (VEF). It isn't bundled with the server as of this writing, so you must build it yourself. You need a Rust toolchain (1.87 or newer) and the `cargo-vsql` tool. The VillageSQL Rust SDK comes from crates.io, so there is nothing else to clone:\n\n```\ncargo install cargo-vsql\ngit clone https://github.com/villagesql/vsql-mcp\ncd vsql-mcp\ncargo vsql package\n```\n\nThat produces `dist/vsql_mcp.veb`. Copy it into your server's extension directory, which the server can help you identify:\n\n```\nSHOW VARIABLES LIKE 'veb_dir';\n```\n\n## Connect an agent\n\nThe examples below use a toy shop schema. Create it to follow along:\n\n```\nCREATE DATABASE shop;\nUSE shop;\n\nCREATE TABLE customers (\n  id       INT PRIMARY KEY AUTO_INCREMENT,\n  name     VARCHAR(80) NOT NULL,\n  country  CHAR(2) NOT NULL,\n  joined   DATE NOT NULL\n);\n\nCREATE TABLE orders (\n  id          INT PRIMARY KEY AUTO_INCREMENT,\n  customer_id INT NOT NULL,\n  placed_on   DATE NOT NULL,\n  status      ENUM('pending','shipped','delivered','cancelled') NOT NULL,\n  total       DECIMAL(10,2) NOT NULL,\n  FOREIGN KEY (customer_id) REFERENCES customers(id)\n);\n\nINSERT INTO customers (name, country, joined) VALUES\n  ('Carla Reyes', 'MX', '2024-02-11'),\n  ('Ben Osei',    'GH', '2024-03-02'),\n  ('Grace Liu',   'SG', '2024-03-19'),\n  ('Tomas Vrba',  'CZ', '2024-05-07'),\n  ('Priya Nair',  'IN', '2024-06-23'),\n  ('Mei Tanaka',  'JP', '2024-08-14');\n\nINSERT INTO orders (customer_id, placed_on, status, total) VALUES\n  (1, '2024-04-03', 'delivered', 150.00),\n  (1, '2024-07-21', 'delivered', 270.00),\n  (1, '2024-09-02', 'cancelled',  80.00),\n  (2, '2024-04-18', 'delivered',  99.95),\n  (2, '2024-08-30', 'delivered', 210.50),\n  (3, '2024-05-26', 'delivered',  76.25),\n  (3, '2024-09-11', 'delivered', 200.00),\n  (4, '2024-06-14', 'delivered', 180.00),\n  (4, '2024-10-05', 'shipped',    60.00),\n  (5, '2024-07-08', 'delivered', 120.00),\n  (5, '2024-10-19', 'pending',   900.00),\n  (6, '2024-09-27', 'delivered',  95.50),\n  (6, '2024-10-23', 'cancelled',  40.00);\n```\n\nExposing it to agents is one server setting, one `INSTALL EXTENSION`, one least-privilege account, and six extension settings, all run as an administrator:\n\n```\n-- vsql-mcp declares VEF preview capabilities. SET PERSIST takes effect at\n-- once; a server you start yourself can take --vsql_allow_preview_extensions=ON.\nSET PERSIST vsql_allow_preview_extensions = ON;\n\nINSTALL EXTENSION vsql_mcp;\n\n-- A dedicated least-privilege account the server runs tool queries as.\nCREATE USER 'mcp'@'127.0.0.1' IDENTIFIED BY 'change-me';\nGRANT SELECT ON shop.* TO 'mcp'@'127.0.0.1';\n\nSET GLOBAL vsql_mcp.db_url = 'mysql://mcp:change-me@127.0.0.1:3399';\nSET GLOBAL vsql_mcp.schema = 'shop';\nSET GLOBAL vsql_mcp.require_auth = ON;\nSET GLOBAL vsql_mcp.bearer_token = 'a-long-random-token';\nSET GLOBAL vsql_mcp.port = 3400;\nSET GLOBAL vsql_mcp.vsql_mcp_enabled = ON;\n```\n\nThe `db_url` line is the important one. Queries, `EXPLAINs`, writes, and a table's `CREATE TABLE` all reach the database over a loopback connection as that account. Schema and table browsing runs inside the server instead, so it does not need `db_url`. That account's `GRANT` s are the real access boundary; the read-only and allowlist checks on top are defense in depth. Here the account can read the `shop` schema and nothing else.\n\nThe port defaults to 3100; these examples use 3400 because something else already had 3100. The server is now listening:\n\n```\nSELECT vsql_mcp.info();\n{\"enabled\":true,\"http_port\":3400,\"https_port\":0,\"port\":3400,\n \"protocol_version\":\"2025-06-18\",\"schema\":\"shop\",\"sessions_active\":0,\"ssl_port\":3143}\n```\n\n`http_port` and `https_port` are what's actually listening. `ssl_port` is only the configured default (3143), and `https_port` stays 0 because HTTPS starts only when `vsql_mcp.ssl_cert` and `vsql_mcp.ssl_key` are set.\n\nRegistering it with Claude Code is one command, and any client that speaks MCP over Streamable HTTP connects the same way, with a URL and a bearer header. There's no stdio transport, since there's no child process to spawn, so a stdio-only client needs a bridge in front (the [README](https://github.com/villagesql/vsql-mcp) covers that, plus the equivalent setup for Codex and Antigravity):\n\n```\nclaude mcp add --transport http vsql http://127.0.0.1:3400/mcp \\\n  --header \"Authorization: Bearer a-long-random-token\"\n```\n\nThat's the whole integration. Ask a question:\n\n```\nclaude -p \"Which customer has the highest total value of delivered orders? \\\nUse the vsql MCP tools.\" --allowedTools mcp__vsql\n**Carla Reyes** has the highest total value of delivered orders: **$420.00**.\n\nI joined `shop.customers` to `shop.orders`, kept only the rows with\n`status = 'delivered'`, and summed `total` for each customer. The top results are:\n\n| Customer | Delivered total |\n|---|---|\n| Carla Reyes | $420.00 |\n| Ben Osei | $310.45 |\n| Grace Liu | $276.25 |\n```\n\nA trace of the run shows the agent calling `list_schemas`, `list_tables`, and `describe_table` on both tables before writing the join and running it through the `query` tool. The database credential never left the server; the client got a URL and a bearer token, and nobody pasted a schema into a prompt. The `query` tool hands back JSON an agent can reason over. Here's the same question asked through it directly:\n\n```\nSELECT c.name, c.country, SUM(o.total) AS delivered_total\nFROM shop.customers c\nJOIN shop.orders o ON o.customer_id = c.id\nWHERE o.status = 'delivered'\nGROUP BY c.id, c.name, c.country\nORDER BY delivered_total DESC\nLIMIT 3;\n{\n  \"columns\": [\"name\", \"country\", \"delivered_total\"],\n  \"row_count\": 3,\n  \"rows\": [\n    {\"name\": \"Carla Reyes\", \"country\": \"MX\", \"delivered_total\": \"420.00\"},\n    {\"name\": \"Ben Osei\",    \"country\": \"GH\", \"delivered_total\": \"310.45\"},\n    {\"name\": \"Grace Liu\",   \"country\": \"SG\", \"delivered_total\": \"276.25\"}\n  ],\n  \"truncated\": false\n}\n```\n\n## Push on the guardrails\n\nSooner or later an agent asks for something you didn't intend to allow. `vsql-mcp` answers with a refusal that names the rule it hit, so an agent can read the message and adjust course instead of retrying blind.\n\nThe `query` tool accepts a single read-only statement. Asked to run an `UPDATE`, it answers:\n\n```\nonly a single read-only statement is allowed by the query tool\n```\n\nWrites have their own tool, and it's off by default. Until you set `vsql_mcp.allow_write = ON`, the `write` tool is absent from `tools/list` entirely, so an agent never plans around a tool it cannot use. With this configuration, `tools/list` returns five tools.\n\nWith `vsql_mcp.schema = 'shop'`, a query that reaches for another schema is refused:\n\n```\nschema 'mysql' is outside the exposed schema; only 'shop' is available,\nand table names must be qualified with it\n```\n\nYou can narrow it further. Setting `vsql_mcp.allowed_tables = 'orders'` confines the agent to named tables. The check runs on the statement's actual plan, so a join or subquery that touches an excluded table is caught too:\n\n```\ntable 'customers' is not in vsql_mcp.allowed_tables\n```\n\nThe one place the check doesn't reach is a stored function's body, which `EXPLAIN` doesn't descend into. The README covers it.\n\nThe allowlist also governs what an agent can learn, not only what it can read: `describe_table` refuses an excluded table, and `list_tables` omits it entirely.\n\nBeyond those, `max_rows` caps every `query` result (and marks it truncated so the agent knows), `query_timeout` bounds each call, and requests without the bearer token get HTTP 401 before any of this runs. The transport also validates the `Origin` header, as the MCP spec requires; a request claiming to come from a non-local origin gets HTTP 403. The listener binds to `127.0.0.1` only, so exposing it beyond the machine is a decision you make deliberately, with a reverse proxy in front. The [README](https://github.com/villagesql/vsql-mcp#known-limitations) lists the remaining boundaries plainly, and the boundary to design around is the grants. Guardrails narrow what an agent can do, and the `db_url` account's grants are what it can never exceed.\n\n## Watch the agent from SQL\n\nBecause the MCP server is server state, you can monitor it the way you monitor everything else in MySQL. After the agent calls above:\n\n```\nSHOW STATUS LIKE 'vsql_mcp%';\n+------------------------------+-------+\n| Variable_name                | Value |\n+------------------------------+-------+\n| vsql_mcp.http_port           | 3400  |\n| vsql_mcp.https_port          | 0     |\n| vsql_mcp.rows_returned_total | 11    |\n| vsql_mcp.sessions_active     | 5     |\n| vsql_mcp.tool_calls_total    | 15    |\n| vsql_mcp.tool_errors_total   | 6     |\n+------------------------------+-------+\n```\n\nThe refusals shown above are counted in `tool_errors_total`, along with any tool call that fails for ordinary reasons, like malformed SQL. If tool errors spike, something is off on the tool surface, whether that's an agent pushing at the rules or queries that simply fail, and you find out from the database rather than from a sidecar's log file.\n\n## Make it your own\n\nThe six tools are a list in the extension's own source, and the extension is GPL-2.0 Rust you can fork and modify. `src/tools.rs` holds a JSON array that advertises each tool, and a `match` that routes a call to its handler — so adding one of your own is a few edits.\n\nSay you want an agent to see a table's indexes before it writes a join. Advertise it in `tool_definitions()`:\n\n```\n{\n    \"name\": \"list_indexes\",\n    \"description\": \"Indexes on a table, one row per indexed column.\",\n    \"inputSchema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"schema\": { \"type\": \"string\" },\n            \"table\": { \"type\": \"string\" }\n        },\n        \"required\": [\"table\"]\n    }\n},\n```\n\nRoute it in `call()`:\n\n``` js\n\"list_indexes\" => list_indexes(args, cfg, exec),\n```\n\nThen write the handler. It receives the call arguments, the live configuration, and the executor, and every seam the built-in tools use is available to it:\n\n``` php\nfn list_indexes(args: &Json, cfg: &RequestConfig, exec: &dyn QueryExecutor) -> Result<Json, String> {\n    let table = arg_str(args, \"table\")?;\n    let schema = effective_schema(args.get(\"schema\").and_then(Json::as_str), cfg)?;\n    if !guardrails::table_allowed(table, &cfg.allowed_tables) {\n        return Err(format!(\"table '{table}' is not in vsql_mcp.allowed_tables\"));\n    }\n    let rows = exec.read_params(\n        \"SELECT INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE \\\n         FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? \\\n         ORDER BY INDEX_NAME, SEQ_IN_INDEX\",\n        vec![MyValue::from(schema.clone()), MyValue::from(table.to_owned())],\n        &[\n            col(\"INDEX_NAME\"),\n            numeric_col(\"SEQ_IN_INDEX\"),\n            col(\"COLUMN_NAME\"),\n            numeric_col(\"NON_UNIQUE\"),\n        ],\n        cfg.query_timeout,\n    )?;\n    let indexes: Vec<Json> = rows\n        .rows\n        .iter()\n        .map(|r| {\n            json!({\n                \"name\": field(r, \"INDEX_NAME\"),\n                \"column\": field(r, \"COLUMN_NAME\"),\n                \"position\": field(r, \"SEQ_IN_INDEX\"),\n                \"unique\": r.get(\"NON_UNIQUE\").and_then(Json::as_i64) == Some(0)\n            })\n        })\n        .collect();\n    Ok(json!({ \"schema\": schema, \"table\": table, \"indexes\": indexes }))\n}\n```\n\nThree of those lines are why you extend the extension instead of writing a sidecar. `guardrails::table_allowed` applies the operator's `allowed_tables` list, so the new tool honors a rule nobody had to teach it. `effective_schema` pins the call to the configured schema. And `read_params` runs a fixed-shape statement in-process through the `sql_query` capability, so a tool like this needs no `db_url` and opens no connection; arbitrary SQL goes through `exec.read()` over the loopback connection instead. The one import to add is `numeric_col`, alongside `col` at the top of the file.\n\nRebuild, then swap the package in while the extension is uninstalled:\n\n```\ncargo vsql package\nUNINSTALL EXTENSION vsql_mcp;\n```\n\nCopy the new `dist/vsql_mcp.veb` into your `veb_dir`, replacing the old one, then:\n\n```\nINSTALL EXTENSION vsql_mcp;\n```\n\nThe next tools/list includes `list_indexes`. Reinstalling returns every `vsql_mcp.*` setting to its default, including values you set with `SET PERSIST`, so re-apply the configuration afterward. Bump the version in `manifest.json` while you are in the tree, and run `cargo vsql test`: the `mysql-test/` suite covers the protocol, the guardrails, and the refusals, so a change that breaks one of them fails before an agent finds it.\n\n## Try it out\n\nThe [vsql-mcp repository](https://github.com/villagesql/vsql-mcp) covers every setting, the client setup for Codex and Antigravity, and the current limitations. Point an agent at a schema you know well, watch what it asks for, and tell us where the guardrails helped or got in the way, on [Discord](https://discord.gg/KSr6whd3Fr) or in a [GitHub issue](https://github.com/villagesql/vsql-mcp/issues).\n\nTo get started with VillageSQL Server, go to [villagesql.com](https://villagesql.com/).", "url": "https://wpnews.pro/news/access-mysql-over-mcp-with-vsql-mcp", "canonical_source": "https://villagesql.com/blog/mcp/", "published_at": "2026-09-23 18:01:08+00:00", "updated_at": "2026-09-23 18:31:05.221336+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "ai-tools", "developer-tools", "ai-infrastructure"], "entities": ["VillageSQL", "vsql-mcp", "MySQL", "Model Context Protocol", "Claude Code", "Codex", "Cursor", "Antigravity"], "alternates": {"html": "https://wpnews.pro/news/access-mysql-over-mcp-with-vsql-mcp", "markdown": "https://wpnews.pro/news/access-mysql-over-mcp-with-vsql-mcp.md", "text": "https://wpnews.pro/news/access-mysql-over-mcp-with-vsql-mcp.txt", "jsonld": "https://wpnews.pro/news/access-mysql-over-mcp-with-vsql-mcp.jsonld"}}