Building your own MCP server

Build a read-only SQL Server MCP server with Python, reusable authentication, and focused database tools for Claude Code.

In this note

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.

SQL / a bounded sample

Five rows to return. One row to check.

recent_orders(limit=5)Requested result5 + 1TOP (?)Bound parameter: 6
Illustrative recent orders
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.

Illustrative records. Fetching one extra row detects truncation while keeping the requested result cap. Database permissions enforce access separately.

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.

Keep this article in your starred list.

↑ ↓ to explore · Enter to open