A lightweight MCP server that gives LLMs read-only access to SQL databases. Built for analysts, developers, and SREs who want Claude to explore schemas, run SELECT queries, and explain execution plans—without risking data mutations.
Java 21 | Spring Boot 3.4 | MCP Java SDK
git clone https://github.com/waabox/sql-mcp-server.git
cd sql-mcp-server
./mvnw clean package -DskipTestsCreate application.yml:
sql-mcp:
transport: stdio
connections:
- name: local
type: postgresql
host: localhost
port: 5432
database: mydb
username: myuser
password: mypass
read-only: trueLocal (STDIO):
claude mcp add sql -- java -jar /path/to/sql-mcp-server-1.0.0-SNAPSHOT.jar \
--spring.config.location=/path/to/application.ymlOr create .mcp.json in your project root:
{
"mcpServers": {
"sql": {
"command": "java",
"args": [
"-jar",
"/path/to/sql-mcp-server-1.0.0-SNAPSHOT.jar",
"--spring.config.location=/path/to/application.yml"
]
}
}
}Remote (HTTP/SSE) - when deployed on a server:
claude mcp add sql --transport sse http://sql-mcp.example.com/sseOr in .mcp.json:
{
"mcpServers": {
"sql": {
"url": "http://sql-mcp.example.com/sse",
"transport": "sse"
}
}
}Ask Claude: "List all tables in the local database"
Tool call (list_tables):
{ "connection": "local" }Response:
{
"tables": [
{ "name": "users", "type": "TABLE", "schema": "public" },
{ "name": "orders", "type": "TABLE", "schema": "public" },
{ "name": "products", "type": "TABLE", "schema": "public" }
]
}Ask Claude: "Show me the top 5 users by order count"
Tool call (execute_query):
{
"connection": "local",
"query": "SELECT u.id, u.name, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.name ORDER BY order_count DESC LIMIT 5",
"limit": 5
}| Parameter | Type | Required | Description |
|---|---|---|---|
connection |
string | Yes | Connection profile name from application.yml |
query |
string | Yes | SQL SELECT query to execute |
limit |
integer | No | Max rows to return (default: 1000) |
Response:
{
"columns": [
{ "name": "id", "type": "int8" },
{ "name": "name", "type": "varchar" },
{ "name": "order_count", "type": "int8" }
],
"rows": [
{ "id": 42, "name": "Alice", "order_count": 127 },
{ "id": 17, "name": "Bob", "order_count": 98 },
{ "id": 8, "name": "Carol", "order_count": 84 }
],
"rowCount": 3,
"executionTimeMs": 45
}| Field | Description |
|---|---|
columns |
Column metadata (name and SQL type) |
rows |
Result rows as JSON objects |
rowCount |
Number of rows returned |
executionTimeMs |
Query execution time in milliseconds |
| Database | Status |
|---|---|
| PostgreSQL | Supported |
| MySQL/MariaDB | Supported |
| SQLite | Supported |
The server enforces read-only access at multiple levels:
-
Statement validation: Only
SELECT,WITH, andEXPLAINstatements are allowed. Any query starting withINSERT,UPDATE,DELETE,DROP,CREATE,ALTER,TRUNCATE,GRANT, orREVOKEis rejected before execution. -
Dangerous pattern detection: Queries containing embedded DDL/DML keywords (e.g.,
SELECT * FROM users; DROP TABLE users) are blocked. Common SQL injection patterns (' OR '1'='1,UNION ALL SELECT, stacked queries) are detected and rejected. -
Table access control: Configure allow/deny lists to restrict which tables can be queried.
Important: This is a defense-in-depth layer, not a replacement for database-level permissions. Always use a read-only database user with minimal privileges.
| Tool | Description |
|---|---|
list_connections |
List configured database profiles |
test_connection |
Verify database connectivity |
list_tables |
List all tables and views |
describe_table |
Get column details, types, constraints |
list_foreign_keys |
Discover table relationships |
sample_rows |
Preview table data |
execute_query |
Run SELECT queries with safety guards |
explain_query |
Show execution plan (no execution) |
analyze_query |
Run EXPLAIN ANALYZE with actual stats |
sql-mcp:
transport: stdio # or 'http' for server deployment
connections:
- name: production
type: postgresql
host: db.example.com
port: 5432
database: appdb
username: ${DB_USER}
password: ${DB_PASS}
read-only: true
query:
default-timeout-ms: 30000
default-row-limit: 1000
max-row-limit: 10000
tables:
deny-list:
- "pg_*"
- "information_schema.*"
- "*_audit"
- "credentials"| Variable | Description | Default |
|---|---|---|
SQL_MCP_TRANSPORT |
stdio or http |
stdio |
DB_USER |
Database username | - |
DB_PASS |
Database password | - |
docker build -t sql-mcp-server:latest .
docker run -d \
-p 8080:8080 \
-e SQL_MCP_TRANSPORT=http \
-e DB_USER=myuser \
-e DB_PASS=mypass \
-v ./application.yml:/app/config/application.yml \
sql-mcp-server:latest \
--spring.config.location=/app/config/application.ymlSee docs/kubernetes.md for full Kubernetes manifests and Helm values.
- Kubernetes Deployment - ConfigMap, Secrets, Deployment, Helm
- Usage Scenarios - Real-world examples with Claude
./mvnw testThe integration tests spawn the MCP server as a subprocess and communicate via STDIO using the Spring AI MCP Client (spring-ai-mcp).
Setup:
- Testcontainers starts a PostgreSQL 16 container
- Two databases are created:
ecommerce_dbandhr_db - Each database is seeded with realistic schema and data
- A temporary
application.ymlis generated with dynamic port mappings - The MCP server JAR is launched as a subprocess
McpSyncClientconnects viaStdioClientTransport
┌─────────────────┐ STDIO ┌─────────────────┐
│ Test (JUnit) │◄─────────────── ►│ MCP Server │
│ McpSyncClient │ │ (subprocess) │
└─────────────────┘ └────────┬────────┘
│
▼
┌─────────────────┐
│ PostgreSQL │
│ (Testcontainer)│
├─────────────────┤
│ ecommerce_db │
│ - products │
│ - orders │
│ - customers │
├─────────────────┤
│ hr_db │
│ - employees │
│ - departments │
└─────────────────┘
Test databases:
| Database | Tables | Purpose |
|---|---|---|
ecommerce_db |
categories, products, customers, orders, order_items | E-commerce domain with FKs |
hr_db |
departments, employees | HR domain with self-referencing FK |
Test coverage (21 tests):
- Connection management (list, test, unknown connection)
- Schema introspection (list tables, describe, foreign keys, sample rows)
- Query execution (SELECT, JOIN, aggregates, row limits)
- Safety guards (blocks INSERT, DELETE, DROP)
- Query explanation (EXPLAIN, ANALYZE, JSON format)
- Cross-database operations: Tests switch between
ecommerceandhrconnections in the same session, verifying database isolation (tables from one DB don't appear in another) and correct query routing across multiple databases
Key test example:
@Test
void whenExecutingJoinQuery_shouldReturnResults() throws Exception {
String query = """
SELECT p.name as product, c.name as category
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE c.name = 'Electronics'
""";
Map<String, Object> args = Map.of(
"connection", "ecommerce",
"query", query
);
CallToolResult result = mcpClient.callTool(
new CallToolRequest("execute_query", args)
);
// Parse and assert response...
}- PostgreSQL, MySQL, SQLite support
- Schema introspection tools
- Safe query executor
- Query explanation mode
- Health check endpoint (
/health) - Prometheus metrics
- Query cost guards (pre-flight EXPLAIN)
- Result pagination
MIT - Emiliano Arango