VillageSQL MCP Extension
vsql_mcp exposes a VillageSQL database as a
Model Context Protocol server, so MCP
clients — Claude Code, Claude Desktop, IDE agents — can discover the schema and
run governed queries without a sidecar process. Where vsql-rest turns a schema
into a REST API for programs, vsql-mcp turns it into a tool surface for
agents.
It serves MCP over the Streamable HTTP transport (spec revision
2025-06-18) on a listener owned by a background worker inside the server
process. See Quick start to go from installing the extension to
a connected agent.
Requirements
vsql_mcp uses the VEF preview capabilities thread_worker, sys_var,
status_var, and sql_query, so the server must allow preview extensions:
mysqld --vsql_allow_preview_extensions=ON ...
On a running server, SET PERSIST vsql_allow_preview_extensions = ON takes
effect at once: the next INSTALL EXTENSION succeeds, with no restart.
SET GLOBAL is rejected, because the server requires the setting to survive a
restart.
Without preview extensions allowed, the install fails:
ERROR 3219 (HY000): Failed to load VEF extension 'vsql_mcp': extension requires preview capabilities but vsql_allow_preview_extensions is OFF
Building
Requires cargo-vsql (cargo install cargo-vsql) and a Rust toolchain 1.88+.
The VillageSQL Rust SDK comes
from crates.io.
cargo vsql package
That compiles in release mode and writes dist/vsql_mcp.veb. Copy the bundle
into the directory the server loads extensions from. Ask the server which
directory that is:
SHOW VARIABLES LIKE 'veb_dir';
cargo vsql install packages and installs into a server build tree, if you
have one:
export VillageSQL_BUILD_DIR=/path/to/villagesql/build
cargo vsql install
Installing
INSTALL EXTENSION vsql_mcp;
The extension registers its configuration and status variables immediately;
nothing listens until you turn vsql_mcp.vsql_mcp_enabled ON.
vsql_mcp.db_url is only needed for the query and write tools — see
How queries run.
UNINSTALL EXTENSION vsql_mcp takes those variables away again, and with them
every value you gave them, including values set with SET PERSIST. A reinstall
therefore starts from the defaults in Configuration, and the
configuration has to be applied again.
Quick start
Expose one schema and connect an agent to it. Run this SQL in a MySQL client
connected to your VillageSQL server as an administrator (for example
mysql -u root -h 127.0.0.1); the server must already be running with preview
extensions allowed (see Requirements):
INSTALL EXTENSION vsql_mcp;
-- A dedicated least-privilege account the server runs tool queries as.
CREATE USER 'mcp'@'127.0.0.1' IDENTIFIED BY 'change-me';
GRANT SELECT ON mydb.* TO 'mcp'@'127.0.0.1';
SET GLOBAL vsql_mcp.db_url = 'mysql://mcp:[email protected]:3306';
SET GLOBAL vsql_mcp.schema = 'mydb';
SET GLOBAL vsql_mcp.require_auth = ON;
SET GLOBAL vsql_mcp.bearer_token = 'a-long-random-token';
SET GLOBAL vsql_mcp.vsql_mcp_enabled = ON;
The server now listens on http://127.0.0.1:3100/mcp (the default port).
Register that URL and the bearer token with your client.
Claude Code
claude mcp add --transport http vsql http://127.0.0.1:3100/mcp \
--header "Authorization: Bearer a-long-random-token"
Codex
export VSQL_MCP_TOKEN=a-long-random-token
codex mcp add vsql --url http://127.0.0.1:3100/mcp \
--bearer-token-env-var VSQL_MCP_TOKEN
--bearer-token-env-var takes the name of an environment variable, not the
token itself. Codex reads that variable when it connects, so export it in the
shell you start Codex from. Passing the token where the name belongs is accepted
without complaint and the server then loads no tools.
The command writes the server to ~/.codex/config.toml, which you can edit
directly instead:
[mcp_servers.vsql]
url = "http://127.0.0.1:3100/mcp"
bearer_token_env_var = "VSQL_MCP_TOKEN"
Run /mcp in a Codex session to confirm the tools arrived.
Antigravity
Add the server to ~/.gemini/config/mcp_config.json:
{
"mcpServers": {
"vsql": {
"serverUrl": "http://127.0.0.1:3100/mcp",
"headers": { "Authorization": "Bearer a-long-random-token" }
}
}
}
Antigravity names the endpoint field serverUrl, where most other clients call
it url. Recent builds accept url too, but serverUrl is the documented
field and the one every version reads.
Your agent can now discover the schema and query it. Any other client that
speaks MCP Streamable HTTP connects the same way — a URL plus an
Authorization: Bearer header (Cursor, VS Code, Windsurf, and the OpenAI
Responses API all support it).
There is no stdio transport: the server runs inside the database, so there is no
child process for a client to spawn. A stdio-only client, or a remote client
that proxies through a vendor cloud (such as Claude Desktop's connectors, which
cannot reach 127.0.0.1), needs a generic stdio↔HTTP bridge or a tunnel in
front of the endpoint.
How queries run
Three of the six tools — list_schemas, list_tables, and describe_table —
plus the vsql://<schema> resource listing run in-process, via the
sql_query capability. Their column layout is fixed in the extension's own
code, so no round trip is needed to name or type a result.
Everything else — the query tool's arbitrary SQL, the write tool, EXPLAIN,
and SHOW CREATE TABLE (behind the vsql://<schema>/<table> resource) — runs
through a loopback client connection to the server, configured by
vsql_mcp.db_url. sql_query cannot host these: as of this SDK version it
reports no column names or types at all, so it cannot label an arbitrary
query's result columns or SHOW CREATE TABLE's Create Table column; and a
session is only reachable from the worker thread that opened it, so it cannot
host the KILL-based write timeout described in
Known Limitations either.
Point db_url at a dedicated MySQL account and those tool calls run as that
account under its real GRANTs — the allowlist and read-only enforcement
below are defense in depth on top of the grants, not a replacement for them.
db_url is still required if you use query or write; it is not required
for schema/table browsing alone.
Configuration
All variables are SET GLOBAL vsql_mcp.<name>.
HTTPS is served when both ssl_cert and ssl_key are set, and not otherwise —
leaving either empty is how you turn TLS off, whatever ssl_port says.
Port and TLS changes take effect when the server is next enabled — toggle
vsql_mcp_enabled OFF then ON after changing them.
Tools
The server advertises six tools via tools/list:
write is advertised in tools/list only while allow_write is ON, so an
agent never plans around a tool it cannot use.
Tool results follow the MCP shape: a content array with a JSON text block,
plus isError. A rejected statement returns isError: true with a message
naming the guardrail that stopped it. An UPDATE sent to the query tool is
refused with:
only a single read-only statement is allowed by the query tool
Guardrails
Applied to every query, explain, and write call:
-
Read-only enforcement.
queryaccepts a singleSELECT/SHOW/WITH/EXPLAIN/DESCRIBE; the loopback session is also setREAD ONLY.writeaccepts a singleINSERT/UPDATE/DELETE/REPLACEand only whenallow_writeis ON. A trailing second statement is rejected. -
No row ceiling on writes.
max_rowscaps what aqueryreturns; nothing caps what awritechanges. An unqualifiedDELETEempties the table. Givedb_urlan account whose grants match the blast radius you are willing to accept. -
Schema scoping. With
schemaset, a reference to any other schema is rejected. Table names must be qualified with the exposed schema (mydb.orders): the loopback connection has no default database, so a bare name does not resolve. Aliases and table-qualified columns work normally —p.totalis a column, not a schema.Scoping reads the statement, so a view inside the exposed schema that selects from another one is reachable through it, as SQL intends for a view. Use
allowed_tableswhen you need confinement that holds through a view: that check plans the statement and sees the underlying table. -
Table allowlist. With
allowed_tablesset, the statement is planned withEXPLAIN FORMAT=JSONand every table it touches (joins and subqueries included) must be on the list. When the optimizer answers without naming a table —MIN()/MAX()from an index, an impossibleWHERE,LIMIT 0— the tables named in the statement text are checked instead, so an elided plan is not a way past the list. Matching is on the table name.The allowlist also governs what an agent can learn about a table, not only what it can read:
describe_tableandvsql://<schema>/<table>refuse an excluded table, andlist_tablesandresources/listomit it.SHOWandDESCRIBEcannot be planned, so their object is read from the statement instead. The forms naming one table —DESCRIBE t,SHOW CREATE TABLE t,SHOW COLUMNS FROM t,SHOW INDEX FROM t— are checked against the list like any other reference. The forms that enumerate, such asSHOW TABLESandSHOW DATABASES, are refused while a list is set, because their answer is the names the list exists to withhold; the message points atlist_tablesanddescribe_table, which filter. -
No writes to the filesystem.
SELECT ... INTO OUTFILEandINTO DUMPFILEare refused. They begin withSELECT, so neither the statement allowlist nor the read-only session stops them; without this the only thing that would is thedb_urlaccount lackingFILE. -
Row cap.
max_rowscaps aqueryresult and marks ittruncated. -
Statement timeout.
query_timeoutbounds each call. A read is stopped byMAX_EXECUTION_TIME; a write, which that setting does not govern, is stopped by killing the statement, so it rolls back rather than landing after the call has returned. A client read timeout backs both up.
Resources
Clients can pull schema context without a tool round-trip:
vsql://<schema>— a schema overview (its tables)vsql://<schema>/<table>— that table'sCREATE TABLEDDL
Authentication
Set require_auth = ON and bearer_token to require
Authorization: Bearer <token> on every request; requests without it get
HTTP 401. This is a single static token — for per-user identity, use a dedicated
db_url account so MySQL GRANTs do the enforcing.
The transport also validates the Origin header (a request from a non-local
origin gets HTTP 403), as the MCP Streamable HTTP spec requires.
Monitoring
SHOW STATUS LIKE 'vsql_mcp%' reports:
SELECT vsql_mcp.info(); returns the same liveness summary as JSON. The
worker tries to bind the listener on its next wakeup, which comes every 25 ms,
so a call right after vsql_mcp_enabled is set can report "starting": true
with "enabled": false.
Known Limitations
- Most SQL still runs through a loopback connection.
sql_query(as of this SDK version) reports no column names or types, so it can only host the three tools whose result shape is already fixed in this extension's own code (see How queries run).query,write,explain, and the table-DDL resource still reach the database over a client connection configured byvsql_mcp.db_url. Setdb_urlto a dedicated account. - No SSE / server-initiated messages.
GET /mcpreturns HTTP 405, which the spec allows for servers without a stream; there are no progress notifications. - Requests are handled one at a time. The worker drains and serves requests
serially;
query_timeoutbounds how long any one call can hold the line. A stalled request body is read on a helper thread with a bounded wait, so a slow client cannot wedge the worker or block disable/shutdown. - The allowlist does not see through a stored function.
allowed_tablesandschemaare enforced on the tables a statement plans withEXPLAIN, which does not descend into a function body. ASELECT some_function()names no table, so it passes both checks even if the function reads an excluded table. Likewise a read tool can call any function thedb_urlaccount may call — including one that opens a network connection (http_get()and the like) — so a read grants the full reach of installed functions, not only table reads. When you need hard confinement, put it in thedb_urlaccount'sGRANTs, not only inallowed_tables. - Secrets are visible to privileged users.
bearer_tokenanddb_url(which embeds a password) are global variables readable viaSHOW VARIABLESby users with the privilege. - A
writethat overruns is killed, not waited out.MAX_EXECUTION_TIMEapplies toSELECTonly, soquery_timeoutis enforced on a write by issuingKILL QUERYagainst its own connection. The statement is aborted and rolled back, so a timed-out write does not take effect and the tool says so. If the kill itself cannot be delivered the result falls back to reporting the outcome as unknown.
Security Considerations
vsql_mcp opens a network listener and executes agent-supplied SQL, so treat it
as a security boundary:
- Bind is
127.0.0.1only; front it with a reverse proxy if you need remote access, and terminate TLS there or viassl_cert/ssl_key. - Give
db_urla least-privilege account. Read-only is enough unless you enable thewritetool, and the account'sGRANTs are the real access control. - Keep
require_authON for anything beyond a local experiment, and prefer a proxy that maps identities over the single static token. allowed_tablesandschemanarrow what an agent can reach even within the account's grants.
Adding a tool
The tool surface lives in src/tools.rs. tool_definitions() holds the
definition of every tool, list() decides which of them an agent sees, and
call() routes a tool name to its handler. A tool of your own is one
definition, one match arm, and one function.
Add the definition to tool_definitions():
{
"name": "list_indexes",
"description": "Indexes on a table, one row per indexed column.",
"inputSchema": {
"type": "object",
"properties": {
"schema": { "type": "string" },
"table": { "type": "string" }
},
"required": ["table"]
}
},
Route the name in call():
"list_indexes" => list_indexes(args, cfg, exec),
Then write the handler, which receives the call arguments, the live configuration, and the executor:
fn list_indexes(args: &Json, cfg: &RequestConfig, exec: &dyn QueryExecutor) -> Result<Json, String>
describe_table is the closest handler to copy, and the helpers it uses are
available to any handler:
guardrails::table_allowedapplies the operator'sallowed_tableslist.effective_schemareturns the configured schema whenvsql_mcp.schemais set, and the caller'sschemaargument when it is not.exec.read_paramsruns a statement with positional parameters. Give it the result columns in the order the select list names them, because the in-process path cannot read the server's own column names back. It uses that in-process path throughsql_querywhere the capability is available, and thedb_urlconnection where it is not.exec.read()runs arbitrary SQL, always over thedb_urlconnection.
Build the new bundle and copy it into veb_dir:
cargo vsql package
Then reload the extension. No server restart is involved:
UNINSTALL EXTENSION vsql_mcp;
INSTALL EXTENSION vsql_mcp;
The next tools/list includes your tool. The reinstall also returns every
vsql_mcp.* setting to its default, so apply the configuration again.
The mcp_basic, mcp_protocol, and mcp_security tests assert the exact tool
list, so a new tool has to be named in them too. Bump the version in
manifest.json and Cargo.toml together for any behavior change.
Testing
See TESTING.md.
Contributing
See the VillageSQL Contributing Guide.
Reporting Bugs and Requesting Features
Open an issue on GitHub.
Contact
- Discord: https://discord.gg/KSr6whd3Fr
- GitHub Issues: https://github.com/villagesql/vsql-mcp/issues
License
GPL-2.0. See LICENSE.