使用说明
MCP SQL Server
A Python Model Context Protocol (MCP) server that enables AI assistants to interact with Microsoft SQL Server databases. Built on the MCP Python SDK (MCPServer, SDK 2.x), it provides tools for executing queries, exploring schemas, retrieving object definitions, and managing stored procedures -- with multi-database support, connection pooling, caching, audit logging, and security validation.
Features
- 10 MCP Tools for query execution, schema discovery, object inspection, and stored procedure management
- 5 MCP Resources for browsing database metadata, functions, pool statistics, and configured connections
- Multi-Database Support via a
DatabaseRegistrypattern -- connect to multiple SQL Server instances from a single server - Thread-Safe Connection Pooling with health checks, idle/stale retirement, and configurable pool sizing
- TTL-Based Metadata Caching (60-second default) for schema discovery queries
- SQL Security Validation blocking DDL, DCL, admin commands, and system procedures
- Audit Logging with SQL hashing, execution timing, and sensitive data masking
- Structured Logging in JSON or text format with request correlation IDs
- Error Sanitization that redacts IPs, credentials, and connection details from error messages
- Strict Type Safety with full mypy strict mode compliance
- Comprehensive test suite at 95%+ code coverage (no live database required)
Architecture Overview
+------------------+
| MCP Client |
| (Claude, etc.) |
+--------+---------+
| stdio
+--------v---------+
| server.py |
| MCP Server |
| (tool & resource|
| registration) |
+--------+---------+
|
+--------------+--------------+
| |
+---------v----------+ +----------v---------+
| tools/ | | resources/ |
| - query_execution | | - database_info |
| - schema_discovery | | (tables, funcs, |
| - object_defs | | pool stats, |
| - stored_procs | | db info, |
| - registry_tools | | databases) |
+--------+-----------+ +----------+----------+
| |
+----------- utils.py ---------+
|
+--------v---------+
| registry.py |
| DatabaseRegistry |
| (named DB mgmt) |
+--------+---------+
|
+--------v---------+
| database.py |
| DatabaseManager |
| (cursor mgmt, |
| query/stmt exec)|
+--------+---------+
|
+--------v---------+
| pool.py |
| ConnectionPool |
| (thread-safe, |
| health checks) |
+--------+---------+
|
+--------v---------+
| pyodbc |
| (ODBC Driver) |
+------------------+
Cross-Cutting Concerns:
config.py ........... Pydantic models, .env loading, multi-DB config
security.py ......... SQL validation, keyword blocking, identifier checks
cache.py ............ TTL cache with @cached decorator
audit.py ............ Query hashing, execution timing, audit events
errors.py ........... Error sanitization and error responses
logging_config.py ... Structured JSON/text logging, request correlation
sql_lexer.py ........ Dependency-free T-SQL tokenizer used by security/audit
utils.py ............ Lazy accessors (breaks tools/resources <-> server import cycle)
Module Responsibilities
| Module | Responsibility |
|---|---|
server.py |
MCPServer initialization, tool/resource registration, lifecycle management |
registry.py |
Named database management with lazy initialization and thread-safe access |
database.py |
Connection management, cursor context managers, query/statement execution |
pool.py |
Thread-safe connection pooling with Queue, health checks, stale/idle retirement |
config.py |
Pydantic DatabaseConfig and PoolConfig models, .env loading, multi-DB env parsing |
security.py |
SQL validation, blocked keyword detection, identifier sanitization, bracket quoting |
sql_lexer.py |
Dependency-free T-SQL tokenizer (words, strings, quoted identifiers, numbers, paren depth) used by validation and audit masking |
cache.py |
TTLCache class with thread-safe get/set, @cached decorator, global metadata cache |
audit.py |
AuditLogger for queries/statements/procedures, SQL hashing, timed_operation() context manager |
errors.py |
Error sanitization (credentials, IPs, echoed data values), simplification of common SQL Server errors, error-response builder |
logging_config.py |
StructuredFormatter (JSON), StandardFormatter (text), request ID correlation via ContextVar |
utils.py |
Lazy import helpers to break circular dependencies between tools/resources and server |
tools/ |
Tool implementations organized by domain (query, schema, objects, procedures, registry) |
resources/ |
MCP resource handlers returning formatted markdown strings |
Installation
Prerequisites
- Python 3.10+
- An ODBC driver for SQL Server (e.g., Microsoft ODBC Driver 17 or 18)
- Access to a SQL Server instance
Setup
# Create virtual environment
python3 -m venv .venv
# Install package with dev dependencies
.venv/bin/pip install -e ".[dev]"
MCP Client Integration
The server uses stdio transport and works with any MCP-compatible client. Below are setup instructions for the most common clients.
Prerequisites for all clients:
- Clone and install the project (see Setup above)
- Copy
.env.exampleto.envand configure your database credentials- Note the absolute path to the project's Python interpreter:
/path/to/mcp-sql-server/.venv/bin/python- On WSL2 with SQL Server running on Windows, use
127.0.0.1rather thanlocalhostfor the host: under mirrored networkinglocalhostlogins can hang until the login timeout.
Claude Code (CLI)
Register the server using claude mcp add. All flags go before the server name, and -- separates the command.
Basic (reads .env from the server's working directory):
claude mcp add --transport stdio mcp-sql-server -- \
/path/to/mcp-sql-server/.venv/bin/python -m mcp_sql_server.server
With inline environment variables (no .env file needed):
claude mcp add --transport stdio \
-e DB_HOST=sqlserver.example.com \
-e DB_USER=myuser \
-e DB_PASSWORD=mypassword \
-e DB_NAME=MyDatabase \
-e DB_DRIVER="ODBC Driver 18 for SQL Server" \
-e DB_TRUST_CERT=true \
mcp-sql-server -- \
/path/to/mcp-sql-server/.venv/bin/python -m mcp_sql_server.server
Project-scoped (shared via .mcp.json in your repo):
claude mcp add --transport stdio --scope project mcp-sql-server -- \
/path/to/mcp-sql-server/.venv/bin/python -m mcp_sql_server.server
Manage registered servers:
claude mcp list # List all registered servers
claude mcp get mcp-sql-server # Show server details
claude mcp remove mcp-sql-server # Remove the server
Claude Desktop
Edit your claude_desktop_config.json:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
Open Claude Desktop → Settings → Developer → Edit Config, then add:
{
"mcpServers": {
"mcp-sql-server": {
"command": "/path/to/mcp-sql-server/.venv/bin/python",
"args": ["-m", "mcp_sql_server.server"],
"env": {
"DB_HOST": "sqlserver.example.com",
"DB_USER": "myuser",
"DB_PASSWORD": "mypassword",
"DB_NAME": "MyDatabase",
"DB_DRIVER": "ODBC Driver 18 for SQL Server",
"DB_TRUST_CERT": "true"
}
}
}
}
Restart Claude Desktop after saving. A hammer icon should appear in the input area confirming the tools are loaded.
Note: All
envvalues must be strings. The.envfile is looked for in the server's working directory; if the client starts the server somewhere else, setMCP_SQL_SERVER_ENV_FILEto the file's full path, or pass the variables via theenvblock.
Cursor
Go to Settings → Tools & Integrations → MCP and add a new server, or edit the config file directly at .cursor/mcp.json in your project root:
{
"mcpServers": {
"mcp-sql-server": {
"command": "/path/to/mcp-sql-server/.venv/bin/python",
"args": ["-m", "mcp_sql_server.server"],
"env": {
"DB_HOST": "sqlserver.example.com",
"DB_USER": "myuser",
"DB_PASSWORD": "mypassword",
"DB_NAME": "MyDatabase",
"DB_DRIVER": "ODBC Driver 18 for SQL Server",
"DB_TRUST_CERT": "true"
}
}
}
}
Windsurf
Open Settings → Plugins (MCP servers) under Cascade, or edit the config file directly:
- macOS:
~/.codeium/windsurf/mcp_config.json - Windows:
%USERPROFILE%\.codeium\windsurf\mcp_config.json
{
"mcpServers": {
"mcp-sql-server": {
"command": "/path/to/mcp-sql-server/.venv/bin/python",
"args": ["-m", "mcp_sql_server.server"],
"env": {
"DB_HOST": "sqlserver.example.com",
"DB_USER": "myuser",
"DB_PASSWORD": "mypassword",
"DB_NAME": "MyDatabase",
"DB_DRIVER": "ODBC Driver 18 for SQL Server",
"DB_TRUST_CERT": "true"
}
}
}
}
VS Code
Add to your VS Code settings.json (or workspace .vscode/settings.json):
{
"mcp": {
"servers": {
"mcp-sql-server": {
"command": "/path/to/mcp-sql-server/.venv/bin/python",
"args": ["-m", "mcp_sql_server.server"],
"env": {
"DB_HOST": "sqlserver.example.com",
"DB_USER": "myuser",
"DB_PASSWORD": "mypassword",
"DB_NAME": "MyDatabase",
"DB_DRIVER": "ODBC Driver 18 for SQL Server",
"DB_TRUST_CERT": "true"
}
}
}
}
}
Generic MCP Client (JSON)
Any client supporting the MCP stdio transport can use this server. The JSON configuration pattern is the same across clients:
{
"command": "/path/to/mcp-sql-server/.venv/bin/python",
"args": ["-m", "mcp_sql_server.server"],
"env": {
"DB_HOST": "sqlserver.example.com",
"DB_USER": "myuser",
"DB_PASSWORD": "mypassword",
"DB_NAME": "MyDatabase",
"DB_DRIVER": "ODBC Driver 18 for SQL Server",
"DB_TRUST_CERT": "true"
}
}
Note: The code default for
DB_DRIVERisODBC Driver 17 for SQL Server. If you have only Driver 18 installed, you must setDB_DRIVERexplicitly.
Environment Variables vs .env File
There are two ways to provide database credentials to the server:
| Method | Best For | How It Works |
|---|---|---|
env block in JSON config |
Claude Desktop, Cursor, Windsurf, VS Code | Client injects variables into the server process at startup |
.env file |
Claude Code CLI, local development | Server reads it via python-dotenv on startup, without modifying the process environment |
When using the env block: The MCP client spawns the server as a subprocess and injects the variables directly. This is the most reliable method since it doesn't depend on the working directory.
When using the .env file: the server looks in this order, taking the first that applies:
$MCP_SQL_SERVER_ENV_FILE, if set -- the full path to the file.envin the server's working directory.envbeside the installed package, which exists only for an editable checkout (pip install -e .)
Only the working directory is checked, never its parents: climbing ancestors would let an unrelated directory's .env supply database credentials without anyone choosing it. If the client starts the server outside your project, set MCP_SQL_SERVER_ENV_FILE.
Multi-database variables (e.g., DB_ANALYTICS_HOST) work with both methods. See Multi-Database Support for details.
Configuration
Copy .env.example to .env in the directory the server runs from and configure your database connection. To keep it elsewhere, point MCP_SQL_SERVER_ENV_FILE at it.
Configuration Sources and Precedence
The default database can be configured two ways:
DB_*variables, from the MCP client'senvblock or the.envfileSQL_SERVER_*variables (SQL_SERVER_HOST,SQL_SERVER_PORT,SQL_SERVER_USER,SQL_SERVER_PASSWORD,SQL_SERVER_DATABASE,SQL_SERVER_DRIVER,SQL_SERVER_ENCRYPT,SQL_SERVER_TRUST_CERT), typically set in.claude/settings.local.jsonfor Claude Code
Precedence, highest first:
DB_*set in the process environment (e.g. by the MCP client)SQL_SERVER_*DB_*from.env
Named databases (DB_{ALIAS}_*) do not read SQL_SERVER_*.
Database Connection
| Variable | Default | Required | Description |
|---|---|---|---|
DB_HOST |
- | Yes | SQL Server hostname or IP |
DB_PORT |
1433 |
No | SQL Server port |
DB_USER |
- | Yes | Database username |
DB_PASSWORD |
- | Yes | Database password |
DB_NAME |
- | Yes | Database name |
DB_DRIVER |
ODBC Driver 17 for SQL Server |
No | ODBC driver name |
DB_TIMEOUT |
30 |
No | Connection timeout (seconds) |
DB_QUERY_TIMEOUT |
120 |
No | Query timeout (seconds) |
DB_ENCRYPT |
false |
No | Enable encrypted connection |
DB_TRUST_CERT |
false |
No | Trust server certificate |
Connection Pool
| Variable | Default | Description |
|---|---|---|
DB_POOL_MIN_SIZE |
1 |
Minimum connections pre-created |
DB_POOL_MAX_SIZE |
5 |
Maximum connections allowed |
DB_POOL_IDLE_TIMEOUT |
300 |
Idle connection retirement (seconds) |
DB_POOL_HEALTH_CHECK_INTERVAL |
30 |
Health check frequency (seconds) |
DB_POOL_ACQUIRE_TIMEOUT |
10.0 |
Timeout waiting for a connection (seconds) |
DB_POOL_MAX_LIFETIME |
3600 |
Maximum connection age before retirement (seconds) |
Logging
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
INFO |
Log level (DEBUG, INFO, WARNING, ERROR) |
LOG_FORMAT |
text |
Output format (text or json) |
LOG_LEVEL and LOG_FORMAT are read from the process environment first, then from .env.
Query Files
| Variable | Default | Description |
|---|---|---|
QUERY_DIR |
<repo-root>/query/ |
Directory containing .sql files for execute_query_file |
Configuration File
| Variable | Default | Description |
|---|---|---|
MCP_SQL_SERVER_ENV_FILE |
unset | Full path to the .env file. Overrides the working-directory lookup. |
Example .env
DB_HOST=sqlserver.example.com
DB_PORT=1433
DB_USER=myuser
DB_PASSWORD=mypassword
DB_NAME=MyDatabase
DB_DRIVER=ODBC Driver 18 for SQL Server
DB_ENCRYPT=true
DB_TRUST_CERT=true
DB_POOL_MIN_SIZE=2
DB_POOL_MAX_SIZE=10
LOG_LEVEL=INFO
LOG_FORMAT=text
Startup Validation
On startup the server checks every database's configuration and logs problems at ERROR (alias, field, and error type only; never values). The server still starts: a misconfigured database returns its configuration error when a tool targets it, and the other databases keep working.
Security tip: connect with a login that has only the
db_datareaderrole unless you needexecute_statement. The server's SQL validation is defense in depth, not a substitute for database permissions.
Multi-Database Support
The server supports connecting to multiple SQL Server databases simultaneously via the DatabaseRegistry pattern. Every tool accepts an optional database parameter (defaults to "default").
How It Works
- The default database reads from standard
DB_*environment variables. - Additional databases are declared in
DB_DATABASESas a comma-separated list of aliases. - Each alias reads from prefixed environment variables:
DB_{ALIAS}_HOST,DB_{ALIAS}_USER, etc. - Each database gets its own
DatabaseManagerandConnectionPool, lazily initialized on first use.
Configuration Pattern
# Default database (always required)
DB_HOST=primary.example.com
DB_USER=user1
DB_PASSWORD=pass1
DB_NAME=PrimaryDB
# Declare additional databases
DB_DATABASES=analytics,archive
# Analytics database
DB_ANALYTICS_HOST=analytics.example.com
DB_ANALYTICS_USER=analyst
DB_ANALYTICS_PASSWORD=pass2
DB_ANALYTICS_NAME=AnalyticsDB
DB_ANALYTICS_POOL_MAX_SIZE=3
# Archive database
DB_ARCHIVE_HOST=archive.example.com
DB_ARCHIVE_USER=reader
DB_ARCHIVE_PASSWORD=pass3
DB_ARCHIVE_NAME=ArchiveDB
DB_ARCHIVE_TIMEOUT=60
Usage in Tools
# Query the default database
execute_query(sql="SELECT TOP 10 * FROM Users")
# Query a named database
execute_query(sql="SELECT TOP 10 * FROM Events", database="analytics")
# List all configured databases, checking each is actually reachable
list_databases()
# Returns: {"success": True, "databases": [{"name": "default", ..., "status": "ok"}, ...]}
# Skip the connection check when you only want the configured names
list_databases(probe=False)
Alias Rules
- Must match
^[a-zA-Z][a-zA-Z0-9_]{0,63}$(start with letter, max 64 chars) "default"is reserved and always present- Each alias has independent pool configuration via
DB_{ALIAS}_POOL_*variables
Available Tools
Breaking change: tool names no longer have a leading underscore (
_execute_query→execute_query,_list_tables→list_tables, etc.). Update MCP clients and any saved permission rules to match — e.g.mcp__<server>__execute_queryinstead ofmcp__<server>___execute_query.
execute_query
Execute a read-only SELECT query against the database.
| Parameter | Type | Default | Description |
|---|---|---|---|
sql |
str |
required | SQL query (must start with SELECT or WITH) |
params |
list[str] |
None |
Positional parameter values for ? placeholders |
limit |
int |
1000 |
Max rows to return (1--10,000) |
database |
str |
"default" |
Target database alias |
The SQL runs unmodified (CTEs, ORDER BY, and UNION all work). Rows are capped on the server with SET ROWCOUNT (limit+1) and read with fetchmany; the session is reset afterwards and any connection whose state cannot be restored is retired. If more than limit rows exist, truncated is set to True.
Response:
{
"success": true,
"columns": ["id", "name"],
"rows": [{"id": 1, "name": "Alice"}],
"row_count": 1,
"truncated": false
}
execute_statement
Execute a data modification statement (INSERT, UPDATE, DELETE).
| Parameter | Type | Default | Description |
|---|---|---|---|
sql |
str |
required | SQL statement (must start with INSERT, UPDATE, or DELETE) |
params |
list[str] |
None |
Positional parameter values for ? placeholders |
database |
str |
"default" |
Target database alias |
Only one statement is allowed per call; see SQL Validation.
Response:
{
"success": true,
"affected_rows": 5
}
execute_query_file
Execute a SQL query from a .sql file in the configured query directory.
| Parameter | Type | Default | Description |
|---|---|---|---|
filename |
str |
required | Filename (e.g., "my_query.sql" or "my_query") |
database |
str |
"default" |
Target database alias |
Filename must match ^[a-zA-Z0-9_-]+\.sql$. Path traversal is prevented.
list_tables
List all tables in the database, optionally filtered by schema.
| Parameter | Type | Default | Description |
|---|---|---|---|
schema |
str |
None |
Schema name to filter by |
database |
str |
"default" |
Target database alias |
Response:
{
"success": true,
"tables": [{"schema": "dbo", "name": "Users", "type": "BASE TABLE"}],
"count": 1
}
describe_table
Get detailed column information for a table.
| Parameter | Type | Default | Description |
|---|---|---|---|
table_name |
str |
required | Table name |
schema |
str |
"dbo" |
Schema name |
database |
str |
"default" |
Target database alias |
Response:
{
"success": true,
"table": "dbo.Users",
"columns": [
{"name": "id", "type": "int", "max_length": null, "precision": 10, "nullable": "NO", "default_value": null}
]
}
get_view_definition
Get the SQL source code of a database view.
| Parameter | Type | Default | Description |
|---|---|---|---|
view_name |
str |
required | View name |
schema |
str |
"dbo" |
Schema name |
database |
str |
"default" |
Target database alias |
get_function_definition
Get the SQL source code of a user-defined function.
| Parameter | Type | Default | Description |
|---|---|---|---|
function_name |
str |
required | Function name |
schema |
str |
"dbo" |
Schema name |
database |
str |
"default" |
Target database alias |
list_procedures
List all stored procedures in the database.
| Parameter | Type | Default | Description |
|---|---|---|---|
schema |
str |
None |
Schema name to filter by |
database |
str |
"default" |
Target database alias |
Response:
{
"success": true,
"procedures": [{"schema": "dbo", "name": "usp_GetUsers", "created": "...", "modified": "..."}],
"count": 1
}
execute_procedure
Execute a stored procedure with optional named parameters.
| Parameter | Type | Default | Description |
|---|---|---|---|
proc_name |
str |
required | Procedure name |
schema |
str |
"dbo" |
Schema name |
params |
dict[str, Any] |
None |
Parameter name-value pairs |
database |
str |
"default" |
Target database alias |
System procedures (xp_*, sp_*) are blocked. Parameter names are validated as safe identifiers.
At most 10,000 rows are returned; the response includes "truncated": true when more rows existed. Only the first result set is read.
Runs read-only: any data changes made by the procedure are rolled back when the connection is returned to the pool.
Example:
execute_procedure(
proc_name="GetUsersByDepartment",
params={"DeptId": 5, "Status": "active"}
)
list_databases
List all configured database connections and whether each one is reachable.
| Parameter | Type | Default | Description |
|---|---|---|---|
probe |
bool | true |
Open a real connection per database to check reachability. false returns the configured names without connecting. |
Response:
{
"success": true,
"databases": [
{"name": "default", "host": "server1", "port": 1433, "database": "MyDB", "status": "ok"},
{"name": "reports", "host": "server1", "port": 1433, "database": "Reports",
"status": "unreachable", "error": "Cannot open database \"Reports\" requested by the login"},
{"name": "archive", "status": "misconfigured", "error": "password: string_too_short"}
],
"count": 3
}
status is one of:
| Value | Meaning |
|---|---|
ok |
A connection opened and SELECT 1 succeeded. |
unreachable |
The probe ran and failed; error gives a value-free reason. |
misconfigured |
The configuration is invalid, so no probe was attempted. Calls that target it return that error; other databases keep working. |
unknown |
No probe was requested (probe=false, or the sqlserver://databases resource). |
status never claims reachability that was not measured. A database that is OFFLINE on the server, or that the login cannot open, reports unreachable rather than ok.
Probes run in parallel with a 5-second timeout each, separate from DB_TIMEOUT. The sqlserver://databases resource never probes -- resources are read speculatively and must not open connections as a side effect -- so it reports unknown.
Available Resources
Resources return formatted markdown strings for browsing database metadata.
| URI | Description |
|---|---|
sqlserver://tables |
All tables grouped by schema with type indicators |
sqlserver://database/info |
Database version, edition, collation, and name |
sqlserver://functions |
All user-defined functions grouped by schema with return types |
sqlserver://pool/stats |
Connection pool metrics (acquisitions, health checks, peak usage, etc.) |
sqlserver://databases |
All configured database connections in a markdown table |
Security
SQL Validation
All queries and statements are tokenized before execution. Keywords inside string literals, quoted identifiers ([...], "..."), and comments are ignored, so WHERE note = 'DROP' and leading -- comments are fine.
Blocked Keywords (DDL/DCL/Admin):
| Category | Keywords |
|---|---|
| DDL | DROP, TRUNCATE, ALTER, CREATE |
| DCL | GRANT, REVOKE, DENY |
| Admin | SHUTDOWN, BACKUP, RESTORE, DBCC, KILL, RECONFIGURE, CHECKPOINT |
| External Access | OPENROWSET, OPENQUERY, OPENDATASOURCE, BULK |
| Dynamic SQL / control flow | EXEC, EXECUTE, DECLARE, USE, WAITFOR, BEGIN, COMMIT, ROLLBACK, SAVE, IF, WHILE, GOTO, RETURN, PRINT, RAISERROR, THROW, OPEN, CLOSE, DEALLOCATE, SETUSER, REVERT, RECEIVE, SEND, ADD |
| Legacy text/image | WRITETEXT, UPDATETEXT, READTEXT |
| Side effects | NEXT VALUE (as in NEXT VALUE FOR), ENABLE/DISABLE TRIGGER, GET/MOVE/END CONVERSATION |
| File readers | fn_xe_file_target_read_file, fn_trace_gettable, fn_get_audit_file, fn_get_audit_file_v2, fn_dump_dblog, fn_dump_dblog_xtp, fn_xe_telemetry_blob_target_read_file, dm_os_file_exists, dm_os_enumerate_filesystem |
Blocked Prefixes: xp_*, sp_* (system stored procedures)
One statement per call. T-SQL does not need ; between statements, so the validator allows only one statement at the top level (outside parentheses). A single trailing ; is fine. Unbalanced parentheses are rejected.
execute_queryaccepts onlySELECTorWITHfirst, rejectsINSERT,UPDATE,DELETE,MERGE,INTO, andSETanywhere, and allows a second top-levelSELECTonly afterUNION,EXCEPT, orINTERSECT.execute_statementaccepts onlyINSERT,UPDATE, orDELETEfirst.SETis allowed once in anUPDATE, and must come before any top-levelFROM,WHERE,OUTPUT, orOPTION; a top-levelSELECTonly inINSERT ... SELECT;INTOonly inINSERT INTOorOUTPUT ... INTO.UPDATE STATISTICSis rejected outright. Nested (composable) DML is not allowed.- CTE-prefixed DML (
WITH c AS (...) DELETE ...) is not supported by either tool.
Unbracketed column names that match a blocked word (for example Send, Receive, Open) must be written in brackets: [Send].
Query hints are allowed: join/union hints such as INNER MERGE JOIN and OPTION (MERGE JOIN), and OPTION (USE HINT(...)) / OPTION (USE PLAN ...).
FETCH and statement labels after DML are accepted; they are harmless because tools never open cursors.
Use a read-only login. The validator is defense in depth. For read-only use, connect with a login that only has db_datareader.
Identifier Validation
Table, column, schema, and parameter names are validated against ^[a-zA-Z_][a-zA-Z0-9_]*$ and bracket-quoted to prevent SQL injection.
Query File Security
The execute_query_file tool validates filenames against ^[a-zA-Z0-9_-]+\.sql$ and uses Path.relative_to() to prevent path traversal attacks.
Error Sanitization
Error messages are automatically scrubbed to remove:
- IP addresses
- Usernames and passwords
- Connection string details (SERVER, UID, PWD)
- Data values SQL Server echoes in errors: duplicate key values (2627/2601), truncated values (2628), values in conversion failures (245), and values in conversion overflows (248)
Object names (e.g. Invalid object name 'dbo.Req') are kept for debugging.
Connection Pooling
The ConnectionPool class provides thread-safe connection management using Python's queue.Queue.
Lifecycle
- Initialization: Pre-creates
min_sizeconnections on first database access - Acquisition: Returns a healthy connection from the pool, or creates a new one (up to
max_size) - Health Check: Executes
SELECT 1before returning connections that exceed thehealth_check_interval - Release: Rolls back any pending transaction, checks staleness, and returns to pool
- Retirement: Connections are closed when they exceed
max_lifetime,idle_timeout, or fail health checks - Invalid connections: A connection whose session state could not be reset after a query (e.g.
SET ROWCOUNTor database changed) is closed on release instead of being reused - Login failures: If a new connection can't be created and the pool holds none,
acquire()raises the login error immediately; if others are checked out it waits for one to be released, without retrying the login
Pool Statistics
Available via the sqlserver://pool/stats resource:
| Metric | Description |
|---|---|
total_connections |
Total connections currently created |
in_use |
Connections currently checked out |
available |
Connections waiting in the pool |
peak_usage |
Maximum concurrent connections observed |
total_acquisitions |
Cumulative connection checkouts |
total_releases |
Cumulative connection returns |
failed_acquisitions |
Timeouts or creation failures |
health_checks |
Total health checks performed |
transaction_resets |
Rollbacks performed on release |
pool_size / max_size |
Configured maximum pool size |
min_size |
Minimum connections pre-created |
closed |
Whether the pool has been shut down |
Caching
Schema metadata queries (list_tables, describe_table, list_procedures) are cached using a thread-safe TTLCache with a 60-second default TTL.
Features
- Thread-safe get/set/invalidate/clear operations via
threading.Lock - Automatic expiration on access (lazy cleanup)
@cacheddecorator for transparent function-level caching; keys bind arguments to the function signature, so positional and keyword calls share an entry- Global
invalidate_metadata_cache()to clear all cached entries cleanup_expired()for bulk removal of stale entriesstats()method returning total, valid, and expired entry counts and the default TTL
Observability
Audit Logging
The AuditLogger tracks all database operations:
- Queries: SQL hash, masked preview (first 100 chars), duration, row count, truncation status, target database
- Statements: SQL hash, masked preview, statement type (INSERT/UPDATE/DELETE), duration, affected rows, target database
- Procedures: Procedure name, schema, duration, row count, target database
- Validation Failures: SQL hash, short masked preview, error, target database
Previews replace every string and numeric literal (and "double-quoted" tokens, which are strings under QUOTED_IDENTIFIER OFF) with ? and drop comments (WHERE cpf = '123' → WHERE cpf = ?). SQL that cannot be tokenized is logged as <unparseable> and hashed as a constant, never raw. sql_hash is the first 16 hex chars of the SHA-256 of the masked SQL: a query-shape fingerprint, so queries that differ only in literal values share a hash and the values cannot be recovered from it.
Structured Logging
Two output formats controlled by LOG_FORMAT:
text(default):2024-01-15 10:30:00 - module.name - INFO - [3f2a9c1b7d4e] messagejson:{"timestamp": "...", "level": "INFO", "logger": "...", "message": "...", "request_id": "3f2a9c1b7d4e"}
Every tool call gets a 12-character request_id, so all log lines from one call (including audit events) share it. Lines outside a tool call show - (text) or omit the field (JSON).
Error Handling
Tools never raise to the client. Every failure is returned as a sanitized error dictionary:
{
"success": false,
"error": "Simplified message",
"error_context": "query"
}
Common SQL Server errors are automatically simplified (e.g., "Invalid object name 'Foo'" becomes "Object not found: Foo").
Testing
The test suite covers all modules with mocked database connections; run it to see the current count.
# Run all tests
.venv/bin/pytest tests/ -v
# Run with coverage report
.venv/bin/pytest tests/ -v --cov=mcp_sql_server --cov-report=term-missing
# Run a specific test file
.venv/bin/pytest tests/test_server.py -v
# Run tests matching a pattern
.venv/bin/pytest tests/ -v -k "test_execute_query"
Test Files
| File | Tests | Description |
|---|---|---|
test_server.py |
Tool and resource registration, end-to-end tool behavior | |
test_database.py |
DatabaseManager query/statement execution, cursor management |
|
test_pool.py |
Connection pool lifecycle, health checks, retirement, metrics | |
test_config.py |
Configuration loading, validation, connection string generation | |
test_config_multi.py |
Multi-database config, alias parsing, prefixed env vars | |
test_registry.py |
DatabaseRegistry lazy init, get/close, from_env() |
|
test_security.py |
SQL validation, blocked keywords, identifier checks | |
test_cache.py |
TTL cache operations, expiry, @cached decorator, thread safety |
|
test_server_registration.py |
MCP SDK registration surface (tool/resource names, schemas) | |
test_concurrency.py |
Parallel pool/registry access and per-call request IDs | |
test_sql_lexer.py |
T-SQL tokenizer: strings, identifiers, comments, depth | |
test_audit.py |
Masked SQL previews and query-shape hashes | |
test_errors.py |
Error sanitization and value redaction | |
test_logging_config.py |
Text/JSON formats, request ID propagation | |
test_utils.py |
Lazy server accessors | |
test_query_dir.py |
Query directory resolution and file loading | |
test_entrypoint.py |
python -m mcp_sql_server.server over stdio: single registry, clean shutdown (no DB connection) |
|
conftest.py |
Shared pytest fixtures (mock database, config objects) |
Type Safety
The codebase uses strict type annotations validated with mypy in strict mode.
# Run type checker
.venv/bin/python -m mypy src/mcp_sql_server/
Mypy configuration (from pyproject.toml):
disallow_untyped_defs,disallow_incomplete_defs,disallow_untyped_decorators,check_untyped_defsdisallow_any_generics(all generics must be parameterized)warn_return_any,warn_unused_configs,warn_redundant_casts,warn_unused_ignores,warn_no_returnstrict_equalityandstrict_concatenateno_implicit_reexport
Project Structure
pyproject.toml # Package metadata, dependencies, mypy config
+-- src/
| +-- mcp_sql_server/
| +-- __init__.py # Package version
| +-- server.py # MCPServer, tool/resource registration
| +-- database.py # DatabaseManager with pooled/non-pooled modes
| +-- pool.py # Thread-safe ConnectionPool
| +-- config.py # Pydantic configs, .env loading, multi-DB support
| +-- registry.py # DatabaseRegistry for named database management
| +-- security.py # SQL validation, keyword blocking
| +-- sql_lexer.py # T-SQL tokenizer for validation and masking
| +-- cache.py # TTLCache and @cached decorator
| +-- audit.py # AuditLogger, SQL hashing, timed_operation
| +-- errors.py # Exception hierarchy, error sanitization
| +-- logging_config.py # Structured/standard formatters, request IDs
| +-- utils.py # Lazy import helpers (circular dep avoidance)
| +-- tools/
| | +-- __init__.py # Tool exports
| | +-- query_execution.py # execute_query, execute_statement, execute_query_file
| | +-- schema_discovery.py # list_tables, describe_table
| | +-- object_definitions.py # get_view_definition, get_function_definition
| | +-- stored_procedures.py # list_procedures, execute_procedure
| | +-- registry_tools.py # list_databases
| +-- resources/
| +-- __init__.py # Resource exports
| +-- database_info.py # tables, db info, functions, pool stats, databases
+-- tests/
+-- conftest.py # Shared fixtures
+-- test_server.py # Server integration tests
+-- test_database.py # DatabaseManager tests
+-- test_pool.py # ConnectionPool tests
+-- test_config.py # Config loading tests
+-- test_config_multi.py # Multi-database config tests
+-- test_registry.py # DatabaseRegistry tests
+-- test_security.py # Security validation tests
+-- test_cache.py # TTLCache tests
+-- test_server_registration.py # MCP registration surface tests
+-- test_concurrency.py # Thread-safety tests
+-- test_sql_lexer.py # Tokenizer tests
+-- test_audit.py # Audit masking tests
+-- test_errors.py # Error sanitization tests
+-- test_logging_config.py # Logging tests
+-- test_utils.py # Lazy accessor tests
+-- test_query_dir.py # Query directory tests
+-- test_entrypoint.py # -m entrypoint / registry lifecycle test
Development Commands
# Install with dev dependencies
.venv/bin/pip install -e ".[dev]"
# Run the MCP server (stdio transport)
.venv/bin/python -m mcp_sql_server.server
# Run tests
.venv/bin/pytest tests/ -v
# Run tests with coverage
.venv/bin/pytest tests/ -v --cov=mcp_sql_server --cov-report=term-missing
# Type check
.venv/bin/python -m mypy src/mcp_sql_server/
# Run via entry point (after install)
.venv/bin/mcp-sql-server
Dependencies
Runtime
| Package | Version | Purpose |
|---|---|---|
mcp |
>=2.0,<3 | Model Context Protocol SDK (MCPServer) |
pyodbc |
>=5.0.0 | ODBC database connectivity |
python-dotenv |
>=1.0.0 | Environment variable loading from .env |
pydantic |
>=2.12 | Configuration validation and modeling |
Development
| Package | Version | Purpose |
|---|---|---|
pytest |
>=7.0.0 | Test framework |
pytest-asyncio |
>=0.21.0 | Async test support |
pytest-cov |
>=4.0.0 | Coverage reporting |
mypy |
>=1.0.0 | Static type checking |
License
MIT — see LICENSE.