A database question usually starts with a little reconnaissance. What tables are available? Which columns matter? What do the most recent records look like? Copying those answers into chat works, but it makes every follow-up another trip between two tools.
A small MCP server can make that trip repeatable. This walkthrough builds a Python
server for a fictional SQL Server database called SampleStore. It exposes
two bounded operations and keeps the connection code separate, so the protocol remains
a thin layer over an ordinary database client.
A tool call reaches an ordinary function
In the local stdio setup, Claude Code starts the server as a process. The server advertises its tools, receives calls, and returns results over standard input and output. The handler still has to connect to SQL Server and execute a query. MCP supplies the conversation contract, not a database driver.
The Python SDK's FastMCP interface can describe a function from its name, docstring, and type hints. Its bundled FastMCP is the API used here; examples for the separate FastMCP package may have different interfaces.
Start with questions that have boundaries
The first version needs to list visible tables and retrieve recent orders. It doesn't need arbitrary SQL, stored-procedure execution, or a connection manager for every server on the machine. Each operation should be understandable before it reaches the database.
Five rows to return. One row to check.
recent_orders(limit=5)Requested result5 + 1TOP (?)Bound parameter: 6
| OrderId | Total | Role |
|---|---|---|
| 1128 | 42.00 | Returned |
| 2127 | 18.50 | Returned |
| 3126 | 76.20 | Returned |
| 4125 | 24.00 | Returned |
| 5124 | 55.80 | Returned |
| 6123 | 16.00 | Detects more |
"truncated": true
The sixth row reports more data. It stays out of the five-row result.
A larger explorer can add schema inspection and procedure definitions later. Reading a procedure's source is a different capability from executing it. Naming those operations separately helps keep the intended access visible.
Read-only starts at the database
Use a dedicated identity granted only the required reads on the sample database. Microsoft documents SQL Server database roles; narrowly granting SELECT on the intended objects can be more specific than membership in a database-wide reader role.
A regular expression checking that a query starts with SELECT is not a SQL security boundary. Statements can contain multiple operations, and textual filters don't reliably parse the language. The example avoids model-supplied SQL entirely and sends values as parameters. It also caps rows, sets timeouts, and reports truncation.
A row ceiling bounds the response, not necessarily the database's work. Sorting or filtering can still be expensive before TOP returns its rows. Query plans, indexes, and timeouts matter when the sample becomes a real service.
Build the Python server
This example uses Python 3.10 or newer, the SDK's v1 interface, and
pyodbc. Install an appropriate Microsoft ODBC driver separately. Confirm
that the sample connection works before debugging the MCP layer.
python3 -m venv .venv
.venv/bin/python -m pip install "mcp==1.16.0" "pyodbc>=5,<6"
Create this table in a disposable SampleStore database using a setup
identity. The MCP's own identity should have SELECT access to it, not permission to
create or change it.
CREATE TABLE dbo.Orders (
OrderId int PRIMARY KEY,
Total decimal(10, 2) NOT NULL,
CreatedAt datetime2 NOT NULL
);
INSERT INTO dbo.Orders VALUES
(101, 24.50, '2026-10-01T10:00:00'),
(102, 18.00, '2026-10-01T11:00:00'),
(103, 42.75, '2026-10-02T09:00:00');
Save the following as server.py. Supply
SQLSERVER_CONNECTION_STRING through your local environment or secret
configuration, using the restricted identity. Keep real credentials out of committed
project files.
import os
from contextlib import closing
import pyodbc
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("sample-sql")
def read_rows(sql: str, parameters: tuple = ()) -> list:
connection_string = os.environ["SQLSERVER_CONNECTION_STRING"]
with closing(pyodbc.connect(connection_string, timeout=5)) as connection:
connection.timeout = 10
with closing(connection.cursor()) as cursor:
cursor.execute(sql, parameters)
return cursor.fetchall()
@mcp.tool()
def list_tables() -> dict:
"""List up to 100 visible schema/table names in the sample database."""
rows = read_rows("""
SELECT TOP (101) s.name, t.name
FROM sys.tables AS t
JOIN sys.schemas AS s ON s.schema_id = t.schema_id
ORDER BY s.name, t.name
""")
return {
"tables": [f"{row[0]}.{row[1]}" for row in rows[:100]],
"truncated": len(rows) > 100,
}
@mcp.tool()
def recent_orders(limit: int = 5) -> dict:
"""Read recent SampleStore orders. Limit must be between 1 and 50."""
if type(limit) is not int or not 1 <= limit <= 50:
raise ValueError("limit must be an integer from 1 to 50")
rows = read_rows("""
SELECT TOP (?) OrderId, Total, CreatedAt
FROM dbo.Orders
ORDER BY CreatedAt DESC, OrderId DESC
""", (limit + 1,))
return {
"orders": [
{"id": row[0], "total": str(row[1]),
"created_at": row[2].isoformat()}
for row in rows[:limit]
],
"truncated": len(rows) > limit,
"limit": limit,
}
if __name__ == "__main__":
mcp.run(transport="stdio")
The handler owns both query templates. It binds the requested limit as an ODBC parameter and fetches one extra row to detect truncation. Decimal totals become strings so the response doesn't silently lose their decimal representation. Ordering also includes OrderId to make equal timestamps deterministic.
closing closes the connection and cursor after each call. A small sample
can reconnect for each operation; a busier implementation may need pooling and
lifecycle management.
Login and query timeouts
are distinct settings here. Driver-specific timeout behavior should be checked against
the installed driver.
The extra row is useful precisely because it does not become another returned record. It answers whether the sample was cut short. That gives the assistant a reason to refine the question without quietly presenting a partial result as the whole database.
Keep authentication behind one adapter
read_rows owns the connection details. The tool functions don't need to
know whether the environment uses SQL authentication, integrated authentication, or
another supported method. The operating system, driver, and identity still have to
support that method.
If an existing local client already connects, investigate reusing its supported configuration or service. The VS Code mssql extension uses SQL Tools Service. An adapter can integrate with that service, but it is a separate implementation from this ODBC example.
Connection profiles don't necessarily contain reusable secrets, and extension binary paths change with platform and version. A working editor session doesn't guarantee that a new process inherits its authentication. Test the underlying connection directly and keep this complexity outside the tool descriptions.
Register and inspect the connection
Launch Claude Code from an environment that supplies the connection string. Register the virtual environment's Python executable and the server file with absolute paths:
claude mcp add --transport stdio --scope local sample-sql -- /absolute/path/to/project/.venv/bin/python /absolute/path/to/project/server.py
claude mcp get sample-sql
Ask for the visible tables, then the two most recent orders. The sample should return orders 103 and 102 and indicate that additional records exist. Confirm the tool inventory contains only the two intended operations.
A test of database permissions should independently confirm that the same identity cannot update the sample table. That check belongs to the connection setup; a successful read alone doesn't establish read-only access.
Build a small interface you can trust
The useful result is a repeatable path from a question to inspectable data. A narrow tool contract, a tested connection adapter, parameterized queries, and explicit limits make that path understandable.
Start with the operations that answer real questions. Add schema or definition tools when they become useful, and test their boundaries as carefully as their happy path. The same design applies to a notes index or API wrapper: the assistant chooses an operation, and ordinary code decides exactly what that operation can do.